Skip to content

Commit d9753ed

Browse files
authored
Merge pull request #25 from cnvergence/init-vw-provider
Add initializing virtual workspace provider
2 parents fb13f51 + de20df4 commit d9753ed

File tree

17 files changed

+991
-40
lines changed

17 files changed

+991
-40
lines changed

apiexport/provider.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import (
4141
kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache"
4242
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
4343
"github.com/kcp-dev/logicalcluster/v3"
44+
45+
mcpcache "github.com/kcp-dev/multicluster-provider/internal/cache"
4446
)
4547

4648
var _ multicluster.Provider = &Provider{}
@@ -52,7 +54,7 @@ var _ multicluster.Provider = &Provider{}
5254
type Provider struct {
5355
config *rest.Config
5456
scheme *runtime.Scheme
55-
cache WildcardCache
57+
cache mcpcache.WildcardCache
5658
object client.Object
5759

5860
log logr.Logger
@@ -70,7 +72,7 @@ type Options struct {
7072

7173
// WildcardCache is the wildcard cache to use for the provider. If this is
7274
// nil, a new wildcard cache will be created for the given rest.Config.
73-
WildcardCache WildcardCache
75+
WildcardCache mcpcache.WildcardCache
7476

7577
// ObjectToWatch is the object type that the provider watches via a /clusters/*
7678
// wildcard endpoint to extract information about logical clusters joining and
@@ -92,7 +94,7 @@ func New(cfg *rest.Config, options Options) (*Provider, error) {
9294
}
9395
if options.WildcardCache == nil {
9496
var err error
95-
options.WildcardCache, err = NewWildcardCache(cfg, cache.Options{
97+
options.WildcardCache, err = mcpcache.NewWildcardCache(cfg, cache.Options{
9698
Scheme: options.Scheme,
9799
})
98100
if err != nil {
@@ -125,7 +127,7 @@ func (p *Provider) Run(ctx context.Context, mgr mcmanager.Manager) error {
125127
if err != nil {
126128
return fmt.Errorf("failed to get %T informer: %w", p.object, err)
127129
}
128-
shInf, _, _, err := p.cache.getSharedInformer(p.object)
130+
shInf, _, _, err := p.cache.GetSharedInformer(p.object)
129131
if err != nil {
130132
return fmt.Errorf("failed to get shared informer: %w", err)
131133
}
@@ -155,7 +157,7 @@ func (p *Provider) Run(ctx context.Context, mgr mcmanager.Manager) error {
155157

156158
// create new scoped cluster.
157159
clusterCtx, cancel := context.WithCancel(ctx)
158-
cl, err := newScopedCluster(p.config, clusterName, p.cache, p.scheme)
160+
cl, err := mcpcache.NewScopedCluster(p.config, clusterName, p.cache, p.scheme)
159161
if err != nil {
160162
p.log.Error(err, "failed to create cluster", "cluster", clusterName)
161163
cancel()

envtest/workspaces.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,69 @@ func NewWorkspaceFixture(t TestingT, clusterClient kcpclient.ClusterClient, pare
170170
return ws, parent.Join(ws.Name)
171171
}
172172

173+
// NewInitializingWorkspaceFixture creates a new workspace under the given parent
174+
// using the given client, and waits for it to be stuck in the initializing phase.
175+
func NewInitializingWorkspaceFixture(t TestingT, clusterClient kcpclient.ClusterClient, parent logicalcluster.Path, options ...WorkspaceOption) (*tenancyv1alpha1.Workspace, logicalcluster.Path) {
176+
t.Helper()
177+
178+
ctx, cancelFunc := context.WithCancel(context.Background())
179+
t.Cleanup(cancelFunc)
180+
181+
ws := &tenancyv1alpha1.Workspace{
182+
ObjectMeta: metav1.ObjectMeta{
183+
GenerateName: "e2e-workspace-",
184+
},
185+
Spec: tenancyv1alpha1.WorkspaceSpec{
186+
Type: tenancyv1alpha1.WorkspaceTypeReference{
187+
Name: tenancyv1alpha1.WorkspaceTypeName("universal"),
188+
Path: "root",
189+
},
190+
},
191+
}
192+
for _, opt := range options {
193+
opt(ws)
194+
}
195+
196+
// we are referring here to a WorkspaceType that may have just been created; if the admission controller
197+
// does not have a fresh enough cache, our request will be denied as the admission controller does not know the
198+
// type exists. Therefore, we can require.Eventually our way out of this problem. We expect users to create new
199+
// types very infrequently, so we do not think this will be a serious UX issue in the product.
200+
Eventually(t, func() (bool, string) {
201+
err := clusterClient.Cluster(parent).Create(ctx, ws)
202+
return err == nil, fmt.Sprintf("error creating workspace under %s: %v", parent, err)
203+
}, wait.ForeverTestTimeout, time.Millisecond*100, "failed to create %s workspace under %s", ws.Spec.Type.Name, parent)
204+
205+
wsName := ws.Name
206+
t.Cleanup(func() {
207+
if os.Getenv("PRESERVE") != "" {
208+
return
209+
}
210+
211+
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(time.Second*30))
212+
defer cancelFn()
213+
214+
err := clusterClient.Cluster(parent).Delete(ctx, ws)
215+
if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) {
216+
return // ignore not found and forbidden because this probably means the parent has been deleted
217+
}
218+
require.NoErrorf(t, err, "failed to delete workspace %s", wsName)
219+
})
220+
221+
Eventually(t, func() (bool, string) {
222+
err := clusterClient.Cluster(parent).Get(ctx, client.ObjectKey{Name: ws.Name}, ws)
223+
require.Falsef(t, apierrors.IsNotFound(err), "workspace %s was deleted", parent.Join(ws.Name))
224+
require.NoError(t, err, "failed to get workspace %s", parent.Join(ws.Name))
225+
if actual, expected := ws.Status.Phase, corev1alpha1.LogicalClusterPhaseInitializing; actual != expected {
226+
return false, fmt.Sprintf("workspace phase is %s, not %s\n\n%s", actual, expected, toYaml(t, ws))
227+
}
228+
return true, ""
229+
}, workspaceInitTimeout, time.Millisecond*100, "%s workspace %s is not stuck on initializing", ws.Spec.Type, parent.Join(ws.Name))
230+
231+
t.Logf("Created %s workspace %s as /clusters/%s on shard %q", ws.Spec.Type, parent.Join(ws.Name), ws.Spec.Cluster, WorkspaceShardOrDie(t, clusterClient, ws).Name)
232+
233+
return ws, parent.Join(ws.Name)
234+
}
235+
173236
// WorkspaceShard returns the shard that a workspace is scheduled on.
174237
func WorkspaceShard(ctx context.Context, kcpClient kcpclient.ClusterClient, ws *tenancyv1alpha1.Workspace) (*corev1alpha1.Shard, error) {
175238
shards := &corev1alpha1.ShardList{}

examples/apiexport/main.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package main
1919
import (
2020
"context"
2121
"fmt"
22-
"net/http"
2322
"os"
2423

2524
"github.com/spf13/pflag"
@@ -139,9 +138,3 @@ func main() {
139138
os.Exit(1)
140139
}
141140
}
142-
143-
type RoundTripperFunc func(*http.Request) (*http.Response, error)
144-
145-
func (f RoundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
146-
return f(r)
147-
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# `initializingworkspaces` Example Controller
2+
3+
This folder contains an example controller for the `initializingworkspaces` provider implementation. It reconciles `ConfigMap` objects across kcp workspaces.
4+
5+
It can be tested by applying the necessary manifests from the respective folder while connected to the `root` workspace of a kcp instance:
6+
7+
```sh
8+
$ kubectl apply -f ./manifests/bundle.yaml
9+
workspacetype.tenancy.kcp.io/examples-initializingworkspaces-multicluster created
10+
workspace.tenancy.kcp.io/example1 created
11+
workspace.tenancy.kcp.io/example2 created
12+
workspace.tenancy.kcp.io/example3 created
13+
```
14+
15+
Then, start the example controller by passing the virtual workspace URL to it:
16+
17+
```sh
18+
$ go run . --server=$(kubectl get workspacetype examples-initializingworkspaces-multicluster -o jsonpath="{.status.virtualWorkspaces[0].url}") --initializer=root:examples-initializingworkspaces-multicluster
19+
```
20+
21+
Observe the controller reconciling every logical cluster and creating the child workspace `initialized-workspace` and the `kcp-initializer-cm` ConfigMap in each workspace and removing the initializer when done.
22+
23+
```sh
24+
2025-07-24T10:26:58+02:00 INFO Starting to initialize cluster {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "0c69fc44-4886-42f8-b620-82a6fe96a165", "cluster": "1i6ttu8js47cs302"}
25+
2025-07-24T10:26:58+02:00 INFO Creating child workspace {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "0c69fc44-4886-42f8-b620-82a6fe96a165", "cluster": "1i6ttu8js47cs302", "name": "initialized-workspace-1i6ttu8js47cs302"}
26+
2025-07-24T10:26:58+02:00 INFO kcp-initializing-workspaces-provider disengaging non-initializing workspace {"cluster": "1cxhyp0xy8lartoi"}
27+
2025-07-24T10:26:58+02:00 INFO Workspace created successfully {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "0c69fc44-4886-42f8-b620-82a6fe96a165", "cluster": "1i6ttu8js47cs302", "name": "initialized-workspace-1i6ttu8js47cs302"}
28+
2025-07-24T10:26:58+02:00 INFO Reconciling ConfigMap {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "0c69fc44-4886-42f8-b620-82a6fe96a165", "cluster": "1i6ttu8js47cs302", "name": "kcp-initializer-cm", "uuid": ""}
29+
2025-07-24T10:26:58+02:00 INFO ConfigMap created successfully {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "0c69fc44-4886-42f8-b620-82a6fe96a165", "cluster": "1i6ttu8js47cs302", "name": "kcp-initializer-cm", "uuid": "9a8a8d5d-d606-4e08-bb69-679719d94867"}
30+
2025-07-24T10:26:58+02:00 INFO Removed initializer from LogicalCluster status {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "0c69fc44-4886-42f8-b620-82a6fe96a165", "cluster": "1i6ttu8js47cs302", "name": "cluster", "uuid": "4c2fd3cf-512f-45f4-a9d3-6886c6542ccf"}
31+
2025-07-24T10:26:58+02:00 INFO Reconciling LogicalCluster {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl", "logical cluster": {"owner":{"apiVersion":"tenancy.kcp.io/v1alpha1","resource":"workspaces","name":"example1","cluster":"root","uid":"1d79b26f-cfb8-40d5-934d-b4a61eb20f12"},"initializers":["root:universal","root:examples-initializingworkspaces-multicluster","system:apibindings"]}}
32+
2025-07-24T10:26:58+02:00 INFO Starting to initialize cluster {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl"}
33+
2025-07-24T10:26:58+02:00 INFO Creating child workspace {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl", "name": "initialized-workspace-2hwz9858cyir31hl"}
34+
2025-07-24T10:26:58+02:00 INFO kcp-initializing-workspaces-provider disengaging non-initializing workspace {"cluster": "1i6ttu8js47cs302"}
35+
2025-07-24T10:26:58+02:00 INFO Workspace created successfully {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl", "name": "initialized-workspace-2hwz9858cyir31hl"}
36+
2025-07-24T10:26:58+02:00 INFO Reconciling ConfigMap {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl", "name": "kcp-initializer-cm", "uuid": ""}
37+
2025-07-24T10:26:58+02:00 INFO ConfigMap created successfully {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl", "name": "kcp-initializer-cm", "uuid": "87462d41-16b5-4617-9f7c-3894160576b7"}
38+
2025-07-24T10:26:58+02:00 INFO Removed initializer from LogicalCluster status {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "99bd39f0-3ea8-4805-9770-60f95127b5ac", "cluster": "2hwz9858cyir31hl", "name": "cluster", "uuid": "cfeee05f-3cba-4766-b464-ba3ebe41a3fa"}
39+
2025-07-24T10:26:58+02:00 INFO Reconciling LogicalCluster {"controller": "kcp-initializer-controller", "controllerGroup": "core.kcp.io", "controllerKind": "LogicalCluster", "reconcileID": "8ad43574-3862-4452-b1e0-e9daf1e67a54", "cluster": "2hwz9858cyir31hl", "logical cluster": {"owner":{"apiVersion":"tenancy.kcp.io/v1alpha1","resource":"workspaces","name":"example1","cluster":"root","uid":"1d79b26f-cfb8-40d5-934d-b4a61eb20f12"},"initializers":["root:universal","root:examples-initializingworkspaces-multicluster","system:apibindings"]}}
40+
2025-07-24T10:26:59+02:00 INFO kcp-initializing-workspaces-provider disengaging non-initializing workspace {"cluster": "2hwz9858cyir31hl"}
41+
```
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
Copyright 2025 The KCP Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"os"
23+
"slices"
24+
25+
"github.com/spf13/pflag"
26+
"go.uber.org/zap/zapcore"
27+
28+
corev1 "k8s.io/api/core/v1"
29+
apierrors "k8s.io/apimachinery/pkg/api/errors"
30+
"k8s.io/apimachinery/pkg/util/runtime"
31+
"k8s.io/client-go/kubernetes/scheme"
32+
"k8s.io/client-go/rest"
33+
ctrl "sigs.k8s.io/controller-runtime"
34+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
35+
"sigs.k8s.io/controller-runtime/pkg/log"
36+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
37+
"sigs.k8s.io/controller-runtime/pkg/manager"
38+
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
39+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
40+
41+
mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
42+
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
43+
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
44+
45+
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
46+
corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1"
47+
"github.com/kcp-dev/kcp/sdk/apis/tenancy/initialization"
48+
tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1"
49+
50+
"github.com/kcp-dev/multicluster-provider/initializingworkspaces"
51+
)
52+
53+
func init() {
54+
runtime.Must(corev1alpha1.AddToScheme(scheme.Scheme))
55+
runtime.Must(tenancyv1alpha1.AddToScheme(scheme.Scheme))
56+
runtime.Must(apisv1alpha1.AddToScheme(scheme.Scheme))
57+
}
58+
59+
func main() {
60+
var (
61+
server string
62+
initializerName string
63+
provider *initializingworkspaces.Provider
64+
verbosity int
65+
)
66+
67+
pflag.StringVar(&server, "server", "", "Override for kubeconfig server URL")
68+
pflag.StringVar(&initializerName, "initializer", "initializer:example", "Name of the initializer to use")
69+
pflag.IntVar(&verbosity, "v", 1, "Log verbosity level")
70+
pflag.Parse()
71+
72+
logOpts := zap.Options{
73+
Development: true,
74+
Level: zapcore.Level(-verbosity),
75+
}
76+
log.SetLogger(zap.New(zap.UseFlagOptions(&logOpts)))
77+
78+
ctx := signals.SetupSignalHandler()
79+
entryLog := log.Log.WithName("entrypoint")
80+
cfg := ctrl.GetConfigOrDie()
81+
cfg = rest.CopyConfig(cfg)
82+
83+
if server != "" {
84+
cfg.Host = server
85+
}
86+
87+
entryLog.Info("Setting up manager")
88+
opts := manager.Options{}
89+
90+
var err error
91+
provider, err = initializingworkspaces.New(cfg, initializingworkspaces.Options{InitializerName: initializerName})
92+
if err != nil {
93+
entryLog.Error(err, "unable to construct cluster provider")
94+
os.Exit(1)
95+
}
96+
97+
mgr, err := mcmanager.New(cfg, provider, opts)
98+
if err != nil {
99+
entryLog.Error(err, "unable to set up overall controller manager")
100+
os.Exit(1)
101+
}
102+
103+
if err := mcbuilder.ControllerManagedBy(mgr).
104+
Named("kcp-initializer-controller").
105+
For(&corev1alpha1.LogicalCluster{}).
106+
Complete(mcreconcile.Func(
107+
func(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
108+
log := log.FromContext(ctx).WithValues("cluster", req.ClusterName)
109+
cl, err := mgr.GetCluster(ctx, req.ClusterName)
110+
if err != nil {
111+
return reconcile.Result{}, fmt.Errorf("failed to get cluster: %w", err)
112+
}
113+
client := cl.GetClient()
114+
lc := &corev1alpha1.LogicalCluster{}
115+
if err := client.Get(ctx, req.NamespacedName, lc); err != nil {
116+
return reconcile.Result{}, fmt.Errorf("failed to get logical cluster: %w", err)
117+
}
118+
119+
log.Info("Reconciling LogicalCluster", "logical cluster", lc.Spec)
120+
initializer := corev1alpha1.LogicalClusterInitializer(initializerName)
121+
// check if your initializer is still set on the logicalcluster
122+
if slices.Contains(lc.Status.Initializers, initializer) {
123+
log.Info("Starting to initialize cluster")
124+
workspaceName := fmt.Sprintf("initialized-workspace-%s", req.ClusterName)
125+
ws := &tenancyv1alpha1.Workspace{}
126+
err = client.Get(ctx, ctrlclient.ObjectKey{Name: workspaceName}, ws)
127+
if err != nil {
128+
if !apierrors.IsNotFound(err) {
129+
log.Error(err, "Error checking for existing workspace")
130+
return reconcile.Result{}, nil
131+
}
132+
133+
log.Info("Creating child workspace", "name", workspaceName)
134+
ws = &tenancyv1alpha1.Workspace{
135+
ObjectMeta: ctrl.ObjectMeta{
136+
Name: workspaceName,
137+
},
138+
}
139+
140+
if err := client.Create(ctx, ws); err != nil {
141+
log.Error(err, "Failed to create workspace")
142+
return reconcile.Result{}, nil
143+
}
144+
log.Info("Workspace created successfully", "name", workspaceName)
145+
}
146+
147+
if ws.Status.Phase != corev1alpha1.LogicalClusterPhaseReady {
148+
log.Info("Workspace not ready yet", "current-phase", ws.Status.Phase)
149+
return reconcile.Result{Requeue: true}, nil
150+
}
151+
log.Info("Workspace is ready, proceeding to create ConfigMap")
152+
153+
s := &corev1.ConfigMap{
154+
ObjectMeta: ctrl.ObjectMeta{
155+
Name: "kcp-initializer-cm",
156+
Namespace: "default",
157+
},
158+
Data: map[string]string{
159+
"test-data": "example-value",
160+
},
161+
}
162+
log.Info("Reconciling ConfigMap", "name", s.Name, "uuid", s.UID)
163+
if err := client.Create(ctx, s); err != nil {
164+
return reconcile.Result{}, fmt.Errorf("failed to create configmap: %w", err)
165+
}
166+
log.Info("ConfigMap created successfully", "name", s.Name, "uuid", s.UID)
167+
}
168+
// Remove the initializer from the logical cluster status
169+
// so that it won't be processed again.
170+
if !slices.Contains(lc.Status.Initializers, initializer) {
171+
log.Info("Initializer already absent, skipping patch")
172+
return reconcile.Result{}, nil
173+
}
174+
175+
patch := ctrlclient.MergeFrom(lc.DeepCopy())
176+
lc.Status.Initializers = initialization.EnsureInitializerAbsent(initializer, lc.Status.Initializers)
177+
if err := client.Status().Patch(ctx, lc, patch); err != nil {
178+
return reconcile.Result{}, err
179+
}
180+
log.Info("Removed initializer from LogicalCluster status", "name", lc.Name, "uuid", lc.UID)
181+
return reconcile.Result{}, nil
182+
},
183+
)); err != nil {
184+
entryLog.Error(err, "failed to build controller")
185+
os.Exit(1)
186+
}
187+
188+
if provider != nil {
189+
entryLog.Info("Starting provider")
190+
go func() {
191+
if err := provider.Run(ctx, mgr); err != nil {
192+
entryLog.Error(err, "unable to run provider")
193+
os.Exit(1)
194+
}
195+
}()
196+
}
197+
198+
entryLog.Info("Starting manager")
199+
if err := mgr.Start(ctx); err != nil {
200+
entryLog.Error(err, "unable to run manager")
201+
os.Exit(1)
202+
}
203+
}

0 commit comments

Comments
 (0)