Skip to content

Commit da201ca

Browse files
Merge pull request #748 from smarterclayton/report_level_status
clusteroperator: Report when OLM reaches "level" and check syncs
2 parents 176bf33 + 1c10730 commit da201ca

File tree

9 files changed

+259
-77
lines changed

9 files changed

+259
-77
lines changed

cmd/catalog/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,6 @@ func main() {
9696
http.Handle("/metrics", promhttp.Handler())
9797
go http.ListenAndServe(":8081", nil)
9898

99-
_, done := catalogOperator.Run(stopCh)
99+
_, done, _ := catalogOperator.Run(stopCh)
100100
<-done
101101
}

cmd/olm/main.go

Lines changed: 171 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/http"
77
"os"
8+
"reflect"
89
"strings"
910
"time"
1011

@@ -13,21 +14,20 @@ import (
1314
v1 "k8s.io/api/core/v1"
1415
k8serrors "k8s.io/apimachinery/pkg/api/errors"
1516
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17+
"k8s.io/apimachinery/pkg/runtime/schema"
18+
"k8s.io/apimachinery/pkg/util/wait"
19+
"k8s.io/client-go/discovery"
20+
"k8s.io/client-go/tools/clientcmd"
1621

1722
configv1 "github.com/openshift/api/config/v1"
1823
configv1client "github.com/openshift/client-go/config/clientset/versioned/typed/config/v1"
19-
clusteroperatorv1helpers "github.com/openshift/library-go/pkg/config/clusteroperator/v1helpers"
20-
operatorv1helpers "github.com/openshift/library-go/pkg/operator/v1helpers"
2124
"github.com/operator-framework/operator-lifecycle-manager/pkg/api/client"
2225
"github.com/operator-framework/operator-lifecycle-manager/pkg/controller/install"
2326
"github.com/operator-framework/operator-lifecycle-manager/pkg/controller/operators/olm"
2427
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorclient"
2528
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/signals"
2629
"github.com/operator-framework/operator-lifecycle-manager/pkg/metrics"
2730
olmversion "github.com/operator-framework/operator-lifecycle-manager/pkg/version"
28-
"k8s.io/apimachinery/pkg/runtime/schema"
29-
"k8s.io/client-go/discovery"
30-
"k8s.io/client-go/tools/clientcmd"
3131
)
3232

3333
const (
@@ -128,98 +128,198 @@ func main() {
128128
http.Handle("/metrics", promhttp.Handler())
129129
go http.ListenAndServe(":8081", nil)
130130

131-
ready, done := operator.Run(stopCh)
131+
ready, done, sync := operator.Run(stopCh)
132132
<-ready
133133

134134
if *writeStatusName != "" {
135-
opStatusGV := schema.GroupVersion{
136-
Group: "config.openshift.io",
137-
Version: "v1",
135+
monitorClusterStatus(sync, stopCh, opClient, configClient)
136+
}
137+
138+
<-done
139+
}
140+
141+
func monitorClusterStatus(syncCh chan error, stopCh <-chan struct{}, opClient operatorclient.ClientInterface, configClient configv1client.ConfigV1Interface) {
142+
var (
143+
syncs int
144+
successfulSyncs int
145+
hasClusterOperator bool
146+
)
147+
go wait.Until(func() {
148+
// slow poll until we see a cluster operator API, which could be never
149+
if !hasClusterOperator {
150+
opStatusGV := schema.GroupVersion{
151+
Group: "config.openshift.io",
152+
Version: "v1",
153+
}
154+
err := discovery.ServerSupportsVersion(opClient.KubernetesInterface().Discovery(), opStatusGV)
155+
if err != nil {
156+
log.Infof("ClusterOperator api not present, skipping update (%v)", err)
157+
time.Sleep(time.Minute)
158+
return
159+
}
160+
hasClusterOperator = true
138161
}
139-
err := discovery.ServerSupportsVersion(opClient.KubernetesInterface().Discovery(), opStatusGV)
140-
if err != nil {
141-
log.Infof("ClusterOperator api not present, skipping update (%v)", err)
142-
} else {
143-
existing, err := configClient.ClusterOperators().Get(*writeStatusName, metav1.GetOptions{})
144-
if k8serrors.IsNotFound(err) {
145-
log.Info("Existing operator status not found, creating")
146-
created, err := configClient.ClusterOperators().Create(&configv1.ClusterOperator{
147-
ObjectMeta: metav1.ObjectMeta{
148-
Name: *writeStatusName,
149-
},
150-
})
151-
if err != nil {
152-
log.Fatalf("ClusterOperator create failed: %v\n", err)
162+
163+
// Sample the sync channel and see whether we're successfully retiring syncs as a
164+
// proxy for "working" (we can't know when we hit level, but we can at least verify
165+
// we are seeing some syncs succeeding). Once we observe at least one successful
166+
// sync we can begin reporting available and level.
167+
select {
168+
case err, ok := <-syncCh:
169+
if !ok {
170+
// syncCh should only close if the Run() loop exits
171+
time.Sleep(5 * time.Second)
172+
log.Fatalf("Status sync channel closed but process did not exit in time")
173+
}
174+
syncs++
175+
if err == nil {
176+
successfulSyncs++
177+
}
178+
// grab any other sync events that have accumulated
179+
for len(syncCh) > 0 {
180+
if err := <-syncCh; err == nil {
181+
successfulSyncs++
153182
}
183+
syncs++
184+
}
185+
// if we haven't yet accumulated enough syncs, wait longer
186+
// TODO: replace these magic numbers with a better measure of syncs across all queueInformers
187+
if successfulSyncs < 5 || syncs < 10 {
188+
log.Printf("Waiting to observe more successful syncs")
189+
return
190+
}
191+
}
154192

155-
created.Status = configv1.ClusterOperatorStatus{
193+
// create the cluster operator in an initial state if it does not exist
194+
existing, err := configClient.ClusterOperators().Get(*writeStatusName, metav1.GetOptions{})
195+
if k8serrors.IsNotFound(err) {
196+
log.Info("Existing operator status not found, creating")
197+
created, createErr := configClient.ClusterOperators().Create(&configv1.ClusterOperator{
198+
ObjectMeta: metav1.ObjectMeta{
199+
Name: *writeStatusName,
200+
},
201+
Status: configv1.ClusterOperatorStatus{
156202
Conditions: []configv1.ClusterOperatorStatusCondition{
157203
configv1.ClusterOperatorStatusCondition{
158204
Type: configv1.OperatorProgressing,
159-
Status: configv1.ConditionFalse,
160-
Message: fmt.Sprintf("Done deploying %s.", olmversion.OLMVersion),
205+
Status: configv1.ConditionTrue,
206+
Message: fmt.Sprintf("Installing %s", olmversion.OLMVersion),
161207
LastTransitionTime: metav1.Now(),
162208
},
163209
configv1.ClusterOperatorStatusCondition{
164210
Type: configv1.OperatorFailing,
165211
Status: configv1.ConditionFalse,
166-
Message: fmt.Sprintf("Done deploying %s.", olmversion.OLMVersion),
167212
LastTransitionTime: metav1.Now(),
168213
},
169214
configv1.ClusterOperatorStatusCondition{
170215
Type: configv1.OperatorAvailable,
171-
Status: configv1.ConditionTrue,
172-
Message: fmt.Sprintf("Done deploying %s.", olmversion.OLMVersion),
216+
Status: configv1.ConditionFalse,
173217
LastTransitionTime: metav1.Now(),
174218
},
175219
},
176-
Versions: []configv1.OperandVersion{{
220+
},
221+
})
222+
if createErr != nil {
223+
log.Errorf("Failed to create cluster operator: %v\n", createErr)
224+
return
225+
}
226+
existing = created
227+
err = nil
228+
}
229+
if err != nil {
230+
log.Errorf("Unable to retrieve cluster operator: %v", err)
231+
return
232+
}
233+
234+
// update the status with the appropriate state
235+
previousStatus := existing.Status.DeepCopy()
236+
switch {
237+
case successfulSyncs > 0:
238+
setOperatorStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
239+
Type: configv1.OperatorFailing,
240+
Status: configv1.ConditionFalse,
241+
})
242+
setOperatorStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
243+
Type: configv1.OperatorProgressing,
244+
Status: configv1.ConditionFalse,
245+
Message: fmt.Sprintf("Deployed %s", olmversion.OLMVersion),
246+
})
247+
setOperatorStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
248+
Type: configv1.OperatorAvailable,
249+
Status: configv1.ConditionTrue,
250+
})
251+
// we set the versions array when all the latest code is deployed and running - in this case,
252+
// the sync method is responsible for guaranteeing that happens before it returns nil
253+
if version := os.Getenv("RELEASE_VERSION"); len(version) > 0 {
254+
existing.Status.Versions = []configv1.OperandVersion{
255+
{
177256
Name: "operator",
178-
Version: olmversion.Full(),
179-
}},
180-
}
181-
_, err = configClient.ClusterOperators().UpdateStatus(created)
182-
if err != nil {
183-
log.Fatalf("ClusterOperator update status failed: %v", err)
257+
Version: version,
258+
},
259+
{
260+
Name: "operator-lifecycle-manager",
261+
Version: olmversion.OLMVersion,
262+
},
184263
}
185-
} else if err != nil {
186-
log.Fatalf("ClusterOperators get failed: %v", err)
187264
} else {
188-
clusteroperatorv1helpers.SetStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
189-
Type: configv1.OperatorProgressing,
190-
Status: configv1.ConditionFalse,
191-
Message: fmt.Sprintf("Done deploying %s.", olmversion.OLMVersion),
192-
LastTransitionTime: metav1.Now(),
193-
})
194-
clusteroperatorv1helpers.SetStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
195-
Type: configv1.OperatorFailing,
196-
Status: configv1.ConditionFalse,
197-
Message: fmt.Sprintf("Done deploying %s.", olmversion.OLMVersion),
198-
LastTransitionTime: metav1.Now(),
199-
})
200-
clusteroperatorv1helpers.SetStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
201-
Type: configv1.OperatorAvailable,
202-
Status: configv1.ConditionTrue,
203-
Message: fmt.Sprintf("Done deploying %s.", olmversion.OLMVersion),
204-
LastTransitionTime: metav1.Now(),
205-
})
206-
207-
olmOperandVersion := configv1.OperandVersion{Name: "operator", Version: olmversion.Full()}
208-
// look for operator version, even though in OLM's case should only be one
209-
for _, item := range existing.Status.Versions {
210-
if item.Name == "operator" && item != olmOperandVersion {
211-
// if a cluster wide upgrade has occurred, hopefully any existing operator statuses have been deleted
212-
log.Infof("Updating version from %v to %v\n", item.Version, olmversion.Full())
213-
}
214-
}
215-
operatorv1helpers.SetOperandVersion(&existing.Status.Versions, olmOperandVersion)
216-
_, err = configClient.ClusterOperators().UpdateStatus(existing)
217-
if err != nil {
218-
log.Fatalf("ClusterOperator update status failed: %v", err)
219-
}
265+
existing.Status.Versions = nil
266+
}
267+
default:
268+
setOperatorStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
269+
Type: configv1.OperatorFailing,
270+
Status: configv1.ConditionTrue,
271+
Message: "Waiting for updates to take effect",
272+
})
273+
setOperatorStatusCondition(&existing.Status.Conditions, configv1.ClusterOperatorStatusCondition{
274+
Type: configv1.OperatorProgressing,
275+
Status: configv1.ConditionFalse,
276+
Message: fmt.Sprintf("Waiting to see update %s succeed", olmversion.OLMVersion),
277+
})
278+
// TODO: use % errors within a window to report available
279+
}
280+
281+
// update the status
282+
if !reflect.DeepEqual(previousStatus, &existing.Status) {
283+
if _, err := configClient.ClusterOperators().UpdateStatus(existing); err != nil {
284+
log.Errorf("Unable to update cluster operator status: %v", err)
220285
}
221286
}
287+
288+
// if we've reported success, we can sleep longer, otherwise we want to keep watching for
289+
// successful
290+
if successfulSyncs > 0 {
291+
time.Sleep(5 * time.Minute)
292+
}
293+
294+
}, 5*time.Second, stopCh)
295+
}
296+
297+
func setOperatorStatusCondition(conditions *[]configv1.ClusterOperatorStatusCondition, newCondition configv1.ClusterOperatorStatusCondition) {
298+
if conditions == nil {
299+
conditions = &[]configv1.ClusterOperatorStatusCondition{}
300+
}
301+
existingCondition := findOperatorStatusCondition(*conditions, newCondition.Type)
302+
if existingCondition == nil {
303+
newCondition.LastTransitionTime = metav1.NewTime(time.Now())
304+
*conditions = append(*conditions, newCondition)
305+
return
222306
}
223307

224-
<-done
308+
if existingCondition.Status != newCondition.Status {
309+
existingCondition.Status = newCondition.Status
310+
existingCondition.LastTransitionTime = newCondition.LastTransitionTime
311+
}
312+
313+
existingCondition.Reason = newCondition.Reason
314+
existingCondition.Message = newCondition.Message
315+
}
316+
317+
func findOperatorStatusCondition(conditions []configv1.ClusterOperatorStatusCondition, conditionType configv1.ClusterStatusConditionType) *configv1.ClusterOperatorStatusCondition {
318+
for i := range conditions {
319+
if conditions[i].Type == conditionType {
320+
return &conditions[i]
321+
}
322+
}
323+
324+
return nil
225325
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDGDCCAgCgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdsb2Nh
3+
bGhvc3QtY2FAMTU1MjQyMzAyMDAeFw0xOTAzMTIyMDM3MDFaFw0yMDAzMTEyMDM3
4+
MDFaMB8xHTAbBgNVBAMMFGxvY2FsaG9zdEAxNTUyNDIzMDIxMIIBIjANBgkqhkiG
5+
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAubkXRqN2xYxJiVhMjHnOtPCkU44QcLosVpIj
6+
tbUgzjJt0BDv/XNCMhbpD3dfKjMKZiKXt1dKDK2Tl52AceWqipVQlCf7kiX+CjuO
7+
gTAIEbVC7FWdu/sDI8BWbhs5knT+8Y7a5uGVexclZifvcbASuVtedLH47XI25Ak4
8+
s103Usy5Z2WXOLd79w/tsAr1kvQzveIdbn+upMu4to2wmfXhiLaU2qMhGoz+2hzm
9+
z+SXkB7uCgFbGuLIUj99/faSZ3CAH6EwPIerAKtY+1hdVmsjqpIrSs4jD7YyfmVN
10+
3+/MLTSMyHrghHYKt/SiRdCuVrbMhCylU8NFry+iuBIsOA202QIDAQABo1wwWjAO
11+
BgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIw
12+
ADAlBgNVHREEHjAcgglsb2NhbGhvc3SCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG
13+
9w0BAQsFAAOCAQEAacr9G8nNsHQpLCW+0meGmDz9deTfLYldFCbCjsPiUDWs9tUn
14+
O+04ykac2tEqZt2Ovkp6gntRPBCOKpgwHYvo0CJtCaL4yh6wYMvlbjHmHR/y+Ioy
15+
HymMmaQ06iVIhb2KoKFJvFtFUVNg6QE9w7dm9/C73eHcv3JhqYhGw3qBfUI6lmIc
16+
lWGj6WGVNfslofTYMkshbRGNZ3gFGkvcQvPOhKb/K4A3X9ZTGy9XyydVAOpdk/5n
17+
FBD4gOJJVSq2jJ5SOTJd5Z/YrY2tbCfZeuuPuxBK4XG3hnLN2fk9URwfCDc9EUQg
18+
aYagxskTB6jaDkFD5lfXxEc3W+/mP62i7mH/fQ==
19+
-----END CERTIFICATE-----
20+
-----BEGIN CERTIFICATE-----
21+
MIIC4jCCAcqgAwIBAgIBATANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdsb2Nh
22+
bGhvc3QtY2FAMTU1MjQyMzAyMDAeFw0xOTAzMTIyMDM3MDBaFw0yMDAzMTEyMDM3
23+
MDBaMCIxIDAeBgNVBAMMF2xvY2FsaG9zdC1jYUAxNTUyNDIzMDIwMIIBIjANBgkq
24+
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnQb0E1iZ/R1J8bdzDP/EFx73JpU6fw6T
25+
aTY9QTWWgt4EcamLpJK5Z+dOLhj/i6rQbe/vKpI6BbBo+S6MuBemyUbc4VpoTde6
26+
Hn26uWSlkQA72GLHYWvD+ahdRpLxOFddog9xcfEoYN/rlpwMp030y6clQhrb4WML
27+
x1uQzqyOvzRHAN4NqxmLXbepTyWqiM3tLe2f4mPfcg/vhwQ5TSqR/Rm3FPh3rDdA
28+
zvk9bGkvyX8iAUoLw/0aHe2dzTfnvBvkTJFEaLq61FLQ/zfMVRhPI2Fwljxq+jSq
29+
FoYju/vr1sWxKc+AFxDdAZdRey2Afi1bVf8JHiDU8FSe9UcfqBUoyQIDAQABoyMw
30+
ITAOBgNVHQ8BAf8EBAMCAqQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsF
31+
AAOCAQEAmrIS4kJNVjKj4vSj0lNWzOjk31CI26rKwPo+cFhvnPh+eg6wI+3I/gLC
32+
yf9X5KIPaNS5MGzNEmpr7Ml7IviqUn8rSoVryoQwKtqnMhsGr3/Y/Rrd27OIYEW+
33+
6/phRyI2rM8Vzo0RVdqcQT+6qvknbZ4fr/3Or3YbjycyfqNeL0SzXff+c8s9skDw
34+
r9OV5uMvmVJv3VNBhAEX83I4zJsfrH9XtAmz255aw24vBGMUHYEdH15K/IBxh4LZ
35+
Y5AXZhVazjlzwWwnUpu8k88vesCUay8c4VtXfXHQTk/oS/ZDn7eQ7hTvzqYfEH2k
36+
znJYRthnuUZo6M/rtMWzXK6QuunRtg==
37+
-----END CERTIFICATE-----
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-----BEGIN RSA PRIVATE KEY-----
2+
MIIEowIBAAKCAQEAubkXRqN2xYxJiVhMjHnOtPCkU44QcLosVpIjtbUgzjJt0BDv
3+
/XNCMhbpD3dfKjMKZiKXt1dKDK2Tl52AceWqipVQlCf7kiX+CjuOgTAIEbVC7FWd
4+
u/sDI8BWbhs5knT+8Y7a5uGVexclZifvcbASuVtedLH47XI25Ak4s103Usy5Z2WX
5+
OLd79w/tsAr1kvQzveIdbn+upMu4to2wmfXhiLaU2qMhGoz+2hzmz+SXkB7uCgFb
6+
GuLIUj99/faSZ3CAH6EwPIerAKtY+1hdVmsjqpIrSs4jD7YyfmVN3+/MLTSMyHrg
7+
hHYKt/SiRdCuVrbMhCylU8NFry+iuBIsOA202QIDAQABAoIBAEqc4o39c+TvdEea
8+
Ur6I3RNyLgJna5FuKgvpkDEbAH/2YImblF7VZD2tWJpfEbtpX/8iXKNKjTREs6vQ
9+
md6oLviX/hRXb8kKPGIuBRU/j65VjPpXdxQjRuKhDdgUVe/R0u6GvsjMzfnylZLR
10+
7m9VFmCjJXJqYaA7J3Q7hC0DAQvhBiWk0lZHR7cjGeG37fIT2yzH7gf8M4VeYjCn
11+
asatNUuAOORVfGudtKLCgFk/bmO1Nb5UwCYcz4OXVEpDBWrcg1SsvYwKxyUDxO8a
12+
8A7TAWWEXjWK+sPmaJkUzRfnd/1chvlzcaawXfgfXRHcAaLWRaBu4fdYS7fwMYy6
13+
+/0Pa5ECgYEA0GCkaAl7qicfHTY6xTkBvJwkXu/rDIfzJCRVtdlXOhPmJ+F3+0Rj
14+
0d+O6LMNSyJpYdOYeWOJbjHMJ92XIRJVxqF+K2O6dToEMTG2XbqMm2gtyn16BoTt
15+
ngzcWqeo+zqwvHxLcM6L/tjivnbsI7mVDpdcBJZwVd6VwrR2NgRh0tUCgYEA5CsF
16+
rJUlOR3JJ1CUTrT1G4smBES00lL3QFlhkiF4zWOW6NwhswZlYPkzqe6tgxmtGAuQ
17+
mJINMcqWUkU18BWLh8RRTH+oKcUbmZkTqP9k/bqe6foIm8UyxVsSF80S4tRtMcWm
18+
87Nd2h+FbYY2MP9RFscdDDd5FHf+weSCbnn0s/UCgYEAz05WQeqtTSp+meFJtsxw
19+
HeR5irnFbkIScvJzEueXEACcCTEW3LO9Wx6+XmND5mvly51nI90S7L4+Das2n4BO
20+
Nb6UdzZQWi/N2+NJOxZMrI+Ifts2eyXkAElrMAV85/QLwHkn1KKoRHIhortNUn1e
21+
/ZU3xpikScmX1I0UzciuScECgYAbWrEOdL8GrvR7uyRcn0M3byI6psYK5RlxZIXX
22+
EB48eXERL7r2jJDA5H92IwA4VG61EEXglLnyOzh0WonR47NbroSUqEVP5KqfaoO5
23+
4gyIgsQkhu5bRnQExxtPMS3Pdeo1al3On7Vjvh2v+MQscZ+WHH72BPyGILCxLCUa
24+
+5IDtQKBgGE1Wl2dmdAyedzCX93oOjnVQ2xdH4s+4k7yHBYEt9AIzbuZCSZLMsf+
25+
hDoU/TokDRXkrHnRvZvhpljgjRJULktnmZxRWW8e/YXrp+gTvSq7/bZCob8Dgs80
26+
w21YuIgo6sXV2uvqGUbZ3YvJQU0GnoFB/GztGlmuVyU0jpsJKq5P
27+
-----END RSA PRIVATE KEY-----

manifests/0000_50_olm_06-olm-operator.deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ spec:
3838
path: /healthz
3939
port: 8080
4040
env:
41+
- name: RELEASE_VERSION
42+
value: "0.0.1-snapshot"
4143
- name: OPERATOR_NAMESPACE
4244
valueFrom:
4345
fieldRef:

manifests/0000_50_olm_14-operatorstatus.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ apiVersion: config.openshift.io/v1
33
kind: ClusterOperator
44
metadata:
55
name: operator-lifecycle-manager
6+
status:
7+
versions:
8+
- name: operator
9+
version: "0.0.1-snapshot"

0 commit comments

Comments
 (0)