diff --git a/Makefile b/Makefile index cdd0c44b..b1e138db 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,35 @@ build-installer: manifests generate kustomize ## Generate a consolidated YAML wi cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default > dist/install.yaml +PLUGIN_NAME ?= kubectl-documentdb +PLUGIN_DIST_DIR ?= dist/$(PLUGIN_NAME) +PLUGIN_PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 + +##@ kubectl Plugin + +.PHONY: build-kubectl-plugin +build-kubectl-plugin: ## Build the kubectl-documentdb plugin for the host platform. + mkdir -p bin + cd plugins/documentdb-kubectl-plugin && go build -o $(CURDIR)/bin/$(PLUGIN_NAME) . + +.PHONY: package-kubectl-plugin +package-kubectl-plugin: ## Build cross-platform archives for the kubectl-documentdb plugin. + rm -rf $(PLUGIN_DIST_DIR) + mkdir -p $(PLUGIN_DIST_DIR) + @set -e; for platform in $(PLUGIN_PLATFORMS); do \ + os=$${platform%/*}; \ + arch=$${platform#*/}; \ + ext=""; \ + if [ "$$os" = "windows" ]; then ext=".exe"; fi; \ + tmpdir=$$(mktemp -d); \ + echo "Building $(PLUGIN_NAME) for $$os/$$arch"; \ + ( cd plugins/documentdb-kubectl-plugin && GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 go build -o $$tmpdir/$(PLUGIN_NAME)$$ext . ); \ + cp LICENSE $$tmpdir/; \ + printf "kubectl-documentdb plugin bundle\n\nInstall: place $(PLUGIN_NAME)%s on your PATH (for example ~/.local/bin) and ensure it is executable.\nUsage: run 'kubectl documentdb --help'.\nDocumentation: https://github.com/microsoft/documentdb-kubernetes-operator/blob/main/docs/kubectl-plugin.md\n" "$$ext" > $$tmpdir/README.txt; \ + tar -C $$tmpdir -czf $(PLUGIN_DIST_DIR)/$(PLUGIN_NAME)-$$os-$$arch.tar.gz $(PLUGIN_NAME)$$ext LICENSE README.txt; \ + rm -rf $$tmpdir; \ + done + ##@ Deployment ifndef ignore-not-found diff --git a/docs/kubectl-plugin.md b/docs/kubectl-plugin.md new file mode 100644 index 00000000..5a9fc194 --- /dev/null +++ b/docs/kubectl-plugin.md @@ -0,0 +1,61 @@ +# kubectl-documentdb Plugin + +The `kubectl documentdb` plugin provides operational tooling for Azure Cosmos DB for MongoDB (DocumentDB) deployments managed by this operator. It targets day-two operations such as status inspection, event triage, and primary promotion workflows. + +## Installation + +Prebuilt archives are produced by the release workflow under `dist/kubectl-documentdb/` (GitHub Actions download). Each archive contains a platform-specific binary plus this project's MIT license. To install: + +1. Download the archive that matches your operating system and CPU architecture. +2. Extract the archive and place the `kubectl-documentdb` binary somewhere on your `PATH` (for example `~/.local/bin`). +3. Ensure the binary is executable (`chmod +x ~/.local/bin/kubectl-documentdb` on Linux and macOS). + +To build from source: + +```bash +make build-kubectl-plugin # builds bin/kubectl-documentdb for the host platform +make package-kubectl-plugin # creates release archives for all supported platforms +``` + +Copy `bin/kubectl-documentdb` onto your `PATH` (renaming is not required). Verify installation with `kubectl documentdb --help`. + +## Supported Commands + +| Command | Purpose | +| --- | --- | +| `kubectl documentdb status` | Collects cluster-wide health information for a DocumentDB CR across all member clusters. | +| `kubectl documentdb events` | Streams Kubernetes events scoped to a DocumentDB CR, optionally following new events. | +| `kubectl documentdb promote` | Switches the primary cluster in a fleet by patching `spec.clusterReplication.primary` and waiting for convergence. | + +Run `kubectl documentdb --help` to review all flags. Key options include: + +- `--documentdb`: (required) name of the `DocumentDB` custom resource. +- `--namespace/-n`: namespace containing the resource. Defaults to `documentdb-preview-ns` for all commands. +- `--context`: kubeconfig context to use for hub-level operations (defaults to the current context). +- `--show-connections`: include connection strings in `status` output. +- `--follow/-f`: follow mode for `events` (enabled by default). +- `--since`: limit historical events to a relative duration (for example `--since=1h`). +- `--target-cluster`: target cluster name for `promote` (required). +- `--hub-context` and `--cluster-context`: override hub and target kubeconfig contexts when promoting. + +## Kubeconfig Expectations + +`status` gathers information from every cluster listed in `spec.clusterReplication.clusterList`. For each entry the plugin attempts to load a kubeconfig context with the same name. Create or rename contexts accordingly so that `kubectl documentdb status` can authenticate to each member cluster. + +The plugin never modifies kubeconfig files; it only reads them through `client-go`. + +## Output Highlights + +- **Status** prints a table containing cluster role, phase, pod readiness, service endpoints, and any retrieval errors per member cluster. Pass `--show-connections` to include the hub-reported primary connection string. +- **Events** prints the latest matching events immediately and switches to watch mode while `--follow` remains true. +- **Promote** patches the DocumentDB resource in the fleet hub, then (unless `--skip-wait` is used) polls both the hub and the target cluster until the reconciliation reports the desired primary cluster. + +## Troubleshooting + +- Ensure the operator has already synchronized status for the target resource; otherwise `status` may report unknown phases. +- If you see context lookup errors, verify the context name exists via `kubectl config get-contexts` and matches the cluster list entry. +- Promotion waits until `status.status` reports a healthy phase on both hub and target contexts. Use `--poll-interval` and `--wait-timeout` to tune. + +## Contributing + +The plugin is a standalone Go module located in `plugins/documentdb-kubectl-plugin`. Use the Makefile targets above to rebuild after code changes. Unit tests for the plugin should live alongside the command implementations under `plugins/documentdb-kubectl-plugin/cmd`. diff --git a/mkdocs.yml b/mkdocs.yml index 2ebdc5a7..fa5d7586 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,8 @@ theme: nav: - Version 1.0: - Get Started: v1/index.md + - Tools: + - Kubectl Plugin: kubectl-plugin.md plugins: - search diff --git a/plugins/documentdb-kubectl-plugin/cmd/config.go b/plugins/documentdb-kubectl-plugin/cmd/config.go new file mode 100644 index 00000000..30f96604 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/config.go @@ -0,0 +1,5 @@ +package cmd + +const ( + defaultDocumentDBNamespace = "documentdb-preview-ns" +) diff --git a/plugins/documentdb-kubectl-plugin/cmd/document_health.go b/plugins/documentdb-kubectl-plugin/cmd/document_health.go new file mode 100644 index 00000000..e1f698d5 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/document_health.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func isDocumentReady(doc *unstructured.Unstructured, targetCluster string) bool { + if doc == nil { + return false + } + + primary, _, err := unstructured.NestedString(doc.Object, "spec", "clusterReplication", "primary") + if err != nil || primary != targetCluster { + return false + } + + healthy, _ := isDocumentHealthy(doc) + return healthy +} + +func isDocumentHealthy(doc *unstructured.Unstructured) (bool, string) { + if doc == nil { + return false, "" + } + + phase, found, err := unstructured.NestedString(doc.Object, "status", "status") + if err != nil { + return false, "" + } + phase = strings.TrimSpace(phase) + if !found || phase == "" { + return true, "" + } + + return isHealthyPhase(phase), phase +} + +func isHealthyPhase(phase string) bool { + phase = strings.ToLower(strings.TrimSpace(phase)) + if phase == "" { + return true + } + + switch phase { + case "healthy", "ready", "running", "succeeded": + return true + } + + if strings.Contains(phase, "healthy") || strings.Contains(phase, "ready") { + return true + } + + return false +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/document_health_test.go b/plugins/documentdb-kubectl-plugin/cmd/document_health_test.go new file mode 100644 index 00000000..230b9d35 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/document_health_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestIsDocumentHealthy(t *testing.T) { + doc := newDocument("test", defaultDocumentDBNamespace, "cluster-a", "Ready") + + healthy, phase := isDocumentHealthy(doc) + if !healthy { + t.Fatal("expected document to be healthy") + } + if phase != "Ready" { + t.Fatalf("expected phase to be 'Ready', got %q", phase) + } + + healthy, _ = isDocumentHealthy(nil) + if healthy { + t.Fatal("expected nil document to be unhealthy") + } +} + +func TestIsDocumentReady(t *testing.T) { + doc := newDocument("test", defaultDocumentDBNamespace, "cluster-a", "Healthy") + + if !isDocumentReady(doc, "cluster-a") { + t.Fatal("expected document to be ready for cluster-a") + } + + if isDocumentReady(doc, "cluster-b") { + t.Fatal("expected document to be not ready for cluster-b") + } + + unstructured.SetNestedField(doc.Object, "failed", "status", "status") + if isDocumentReady(doc, "cluster-a") { + t.Fatal("expected document to be not ready when status indicates failure") + } +} + +func newDocument(name, namespace, primary, phase string) *unstructured.Unstructured { + doc := &unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{ + "clusterReplication": map[string]any{ + "primary": primary, + }, + }, + "status": map[string]any{ + "status": phase, + }, + }} + gvk := schema.GroupVersionKind{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Kind: "DocumentDB"} + doc.SetGroupVersionKind(gvk) + doc.SetName(name) + doc.SetNamespace(namespace) + return doc +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/events.go b/plugins/documentdb-kubectl-plugin/cmd/events.go new file mode 100644 index 00000000..2234710d --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/events.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" +) + +type eventsOptions struct { + documentDBName string + namespace string + kubeContext string + follow bool + since time.Duration +} + +func newEventsCommand() *cobra.Command { + opts := &eventsOptions{namespace: defaultDocumentDBNamespace} + + cmd := &cobra.Command{ + Use: "events", + Short: "Stream events associated with a DocumentDB resource", + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.complete(); err != nil { + return err + } + return opts.run(cmd.Context(), cmd) + }, + } + + cmd.Flags().StringVar(&opts.documentDBName, "documentdb", opts.documentDBName, "Name of the DocumentDB resource to inspect") + cmd.Flags().StringVarP(&opts.namespace, "namespace", "n", opts.namespace, "Namespace containing the DocumentDB resource") + cmd.Flags().StringVar(&opts.kubeContext, "context", opts.kubeContext, "Kubeconfig context to use (defaults to current context)") + cmd.Flags().BoolVarP(&opts.follow, "follow", "f", true, "Stream events until interrupted") + cmd.Flags().DurationVar(&opts.since, "since", 0, "Only show events newer than this duration (e.g. 1h); 0 shows all available") + + _ = cmd.MarkFlagRequired("documentdb") + + return cmd +} + +func (o *eventsOptions) complete() error { + o.documentDBName = strings.TrimSpace(o.documentDBName) + if o.documentDBName == "" { + return fmt.Errorf("--documentdb is required") + } + o.namespace = strings.TrimSpace(o.namespace) + if o.namespace == "" { + o.namespace = defaultDocumentDBNamespace + } + return nil +} + +func (o *eventsOptions) run(ctx context.Context, cmd *cobra.Command) error { + config, contextName, err := loadConfigFunc(o.kubeContext) + if err != nil { + return fmt.Errorf("failed to load kubeconfig: %w", err) + } + if contextName == "" { + contextName = "(current)" + } + + clientset, err := kubernetesClientForConfig(config) + if err != nil { + return fmt.Errorf("failed to create kubernetes clientset: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Watching events for DocumentDB %s/%s (context %s)\n", o.namespace, o.documentDBName, contextName) + + listOptions := metav1.ListOptions{ + FieldSelector: fields.AndSelectors( + fields.OneTermEqualSelector("involvedObject.kind", "DocumentDB"), + fields.OneTermEqualSelector("involvedObject.name", o.documentDBName), + ).String(), + } + + evtClient := clientset.CoreV1().Events(o.namespace) + evtList, err := evtClient.List(ctx, listOptions) + if err != nil { + return fmt.Errorf("failed to list events: %w", err) + } + + filterSince := time.Time{} + if o.since > 0 { + filterSince = time.Now().Add(-o.since) + } + + printedEvents := 0 + for idx := range evtList.Items { + if !eventAfter(&evtList.Items[idx], filterSince) { + continue + } + printEvent(cmd.OutOrStdout(), &evtList.Items[idx]) + printedEvents++ + } + + if !o.follow { + if printedEvents == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No events found for DocumentDB %s/%s.\n", o.namespace, o.documentDBName) + } + return nil + } + + if printedEvents == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No events found yet; watching for new events...") + } + + listOptions.ResourceVersion = evtList.ResourceVersion + watcher, err := evtClient.Watch(ctx, listOptions) + if err != nil { + return fmt.Errorf("failed to watch events: %w", err) + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case evt, ok := <-watcher.ResultChan(): + if !ok { + return io.EOF + } + k8sEvent, ok := evt.Object.(*corev1.Event) + if !ok { + continue + } + if !eventAfter(k8sEvent, filterSince) { + continue + } + printEvent(cmd.OutOrStdout(), k8sEvent) + } + } +} + +func eventAfter(evt *corev1.Event, threshold time.Time) bool { + if threshold.IsZero() { + return true + } + timestamp := mostRecentEventTime(evt) + return timestamp.After(threshold) +} + +func printEvent(out io.Writer, evt *corev1.Event) { + timestamp := mostRecentEventTime(evt) + fmt.Fprintf(out, "%s\t%s/%s\t%s\t%s\n", + timestamp.Format(time.RFC3339), + evt.InvolvedObject.Kind, + evt.InvolvedObject.Name, + evt.Type, + evt.Message, + ) +} + +func mostRecentEventTime(evt *corev1.Event) time.Time { + if !evt.EventTime.IsZero() { + return evt.EventTime.Time + } + if !evt.LastTimestamp.IsZero() { + return evt.LastTimestamp.Time + } + if evt.Series != nil && !evt.Series.LastObservedTime.IsZero() { + return evt.Series.LastObservedTime.Time + } + if !evt.CreationTimestamp.IsZero() { + return evt.CreationTimestamp.Time + } + return time.Now() +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/events_run_test.go b/plugins/documentdb-kubectl-plugin/cmd/events_run_test.go new file mode 100644 index 00000000..bb5b960a --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/events_run_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + kubefake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" +) + +func TestEventsRunPrintsExistingEvents(t *testing.T) { + t.Parallel() + + prevLoad := loadConfigFunc + prevKube := kubernetesClientForConfig + defer func() { + loadConfigFunc = prevLoad + kubernetesClientForConfig = prevKube + }() + + namespace := defaultDocumentDBNamespace + docName := "documentdb-sample" + + evt := &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "documentdb-event", + Namespace: namespace, + }, + InvolvedObject: corev1.ObjectReference{ + Kind: "DocumentDB", + Name: docName, + Namespace: namespace, + }, + Message: "Document promoted", + Type: corev1.EventTypeNormal, + } + evt.EventTime = metav1.MicroTime{Time: time.Now()} + evt.FirstTimestamp = metav1.NewTime(time.Now()) + evt.LastTimestamp = metav1.NewTime(time.Now()) + + kubeClient := kubefake.NewSimpleClientset(evt) + + loadConfigFunc = func(string) (*rest.Config, string, error) { + return &rest.Config{Host: "events"}, "events-context", nil + } + + kubernetesClientForConfig = func(cfg *rest.Config) (kubernetes.Interface, error) { + if cfg.Host != "events" { + return nil, fmt.Errorf("unexpected host %s", cfg.Host) + } + return kubeClient, nil + } + + cmd := &cobra.Command{} + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + opts := &eventsOptions{ + documentDBName: docName, + namespace: namespace, + follow: false, + } + + if err := opts.run(context.Background(), cmd); err != nil { + t.Fatalf("run returned error: %v", err) + } + + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %s", stderr.String()) + } + + output := stdout.String() + + checks := []string{ + "Watching events for DocumentDB", + docName, + "Document promoted", + } + + for _, expected := range checks { + if !strings.Contains(output, expected) { + t.Fatalf("expected output to contain %q, got: %s", expected, output) + } + } +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/factories.go b/plugins/documentdb-kubectl-plugin/cmd/factories.go new file mode 100644 index 00000000..389b6ff3 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/factories.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +var ( + loadConfigFunc = loadConfig + dynamicClientForConfig = func(cfg *rest.Config) (dynamic.Interface, error) { + return dynamic.NewForConfig(cfg) + } + kubernetesClientForConfig = func(cfg *rest.Config) (kubernetes.Interface, error) { + return kubernetes.NewForConfig(cfg) + } +) diff --git a/plugins/documentdb-kubectl-plugin/cmd/fake_dynamic_test.go b/plugins/documentdb-kubectl-plugin/cmd/fake_dynamic_test.go new file mode 100644 index 00000000..cd309fd1 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/fake_dynamic_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func documentDBGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} +} + +func documentDBGK() schema.GroupVersionKind { + return schema.GroupVersionKind{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Kind: "DocumentDB"} +} + +func newDocumentScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + gk := documentDBGK() + scheme.AddKnownTypeWithName(gk, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(gk.GroupVersion().WithKind("DocumentDBList"), &unstructured.UnstructuredList{}) + return scheme +} + +func documentListKinds() map[schema.GroupVersionResource]string { + return map[schema.GroupVersionResource]string{documentDBGVR(): "DocumentDBList"} +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/options_test.go b/plugins/documentdb-kubectl-plugin/cmd/options_test.go new file mode 100644 index 00000000..3deb702a --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/options_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "testing" + "time" +) + +func TestStatusOptionsCompleteDefaults(t *testing.T) { + t.Parallel() + + o := &statusOptions{documentDBName: " sample ", namespace: " "} + if err := o.complete(); err != nil { + t.Fatalf("complete returned error: %v", err) + } + if o.documentDBName != "sample" { + t.Fatalf("expected documentDBName trimmed to 'sample', got %q", o.documentDBName) + } + if o.namespace != defaultDocumentDBNamespace { + t.Fatalf("expected namespace default %q, got %q", defaultDocumentDBNamespace, o.namespace) + } +} + +func TestStatusOptionsCompleteRequiresDocument(t *testing.T) { + t.Parallel() + + o := &statusOptions{} + if err := o.complete(); err == nil { + t.Fatal("expected error for missing documentDBName") + } +} + +func TestEventsOptionsCompleteDefaults(t *testing.T) { + t.Parallel() + + o := &eventsOptions{documentDBName: " sample ", namespace: "\t"} + if err := o.complete(); err != nil { + t.Fatalf("complete returned error: %v", err) + } + if o.documentDBName != "sample" { + t.Fatalf("expected document name trimmed, got %q", o.documentDBName) + } + if o.namespace != defaultDocumentDBNamespace { + t.Fatalf("expected namespace default %q, got %q", defaultDocumentDBNamespace, o.namespace) + } +} + +func TestEventsOptionsCompleteRequiresDocument(t *testing.T) { + t.Parallel() + + o := &eventsOptions{} + if err := o.complete(); err == nil { + t.Fatal("expected error for missing documentDBName") + } +} + +func TestPromoteOptionsCompleteDefaults(t *testing.T) { + t.Parallel() + + o := &promoteOptions{ + documentDBName: " sample ", + namespace: "", + targetCluster: " target ", + waitTimeout: 0, + pollInterval: 0, + } + if err := o.complete(); err != nil { + t.Fatalf("complete returned error: %v", err) + } + if o.documentDBName != "sample" { + t.Fatalf("expected document name trimmed, got %q", o.documentDBName) + } + if o.namespace != defaultDocumentDBNamespace { + t.Fatalf("expected namespace default %q, got %q", defaultDocumentDBNamespace, o.namespace) + } + if o.targetCluster != "target" { + t.Fatalf("expected targetCluster trimmed, got %q", o.targetCluster) + } + if o.waitTimeout <= 0 { + t.Fatalf("expected waitTimeout to be positive, got %v", o.waitTimeout) + } + if o.pollInterval <= 0 { + t.Fatalf("expected pollInterval to be positive, got %v", o.pollInterval) + } +} + +func TestPromoteOptionsCompleteRequiresFields(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + opt promoteOptions + }{ + { + name: "missing document", + opt: promoteOptions{}, + }, + { + name: "missing target", + opt: promoteOptions{documentDBName: "sample"}, + }, + } + + for _, tc := range testCases { + if err := tc.opt.complete(); err == nil { + t.Fatalf("expected error for case %q", tc.name) + } + } +} + +func TestPromoteOptionsCompleteTrimsContexts(t *testing.T) { + t.Parallel() + + o := &promoteOptions{ + documentDBName: "sample", + targetCluster: "target", + hubContext: " ctx \n", + targetContext: " other \t", + waitTimeout: time.Second, + pollInterval: time.Millisecond, + } + if err := o.complete(); err != nil { + t.Fatalf("complete returned error: %v", err) + } + if o.hubContext != "ctx" { + t.Fatalf("expected hubContext trimmed to 'ctx', got %q", o.hubContext) + } + if o.targetContext != "other" { + t.Fatalf("expected targetContext trimmed to 'other', got %q", o.targetContext) + } +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/promote.go b/plugins/documentdb-kubectl-plugin/cmd/promote.go new file mode 100644 index 00000000..b417331b --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/promote.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + documentDBGVRGroup = "db.microsoft.com" + documentDBGVRVersion = "preview" + documentDBGVRResource = "documentdbs" +) + +type promoteOptions struct { + documentDBName string + namespace string + hubContext string + targetCluster string + targetContext string + skipWait bool + waitTimeout time.Duration + pollInterval time.Duration +} + +func newPromoteCommand() *cobra.Command { + opts := &promoteOptions{} + + cmd := &cobra.Command{ + Use: "promote", + Short: "Promote a DocumentDB deployment to a new primary cluster", + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.complete(); err != nil { + return err + } + return opts.run(cmd.Context(), cmd) + }, + } + + cmd.Flags().StringVar(&opts.documentDBName, "documentdb", opts.documentDBName, "Name of the DocumentDB resource to promote") + cmd.Flags().StringVarP(&opts.namespace, "namespace", "n", defaultDocumentDBNamespace, "Namespace containing the DocumentDB resource") + cmd.Flags().StringVar(&opts.hubContext, "hub-context", opts.hubContext, "Kubeconfig context for the fleet hub (defaults to current context)") + cmd.Flags().StringVar(&opts.targetCluster, "target-cluster", opts.targetCluster, "Name of the cluster that should become primary (required)") + cmd.Flags().StringVar(&opts.targetContext, "cluster-context", opts.targetContext, "Kubeconfig context for verifying member status (defaults to current context)") + cmd.Flags().BoolVar(&opts.skipWait, "skip-wait", opts.skipWait, "Return immediately after submitting the promotion request") + cmd.Flags().DurationVar(&opts.waitTimeout, "wait-timeout", 10*time.Minute, "Maximum time to wait for the promotion to complete") + cmd.Flags().DurationVar(&opts.pollInterval, "poll-interval", 10*time.Second, "Polling interval while waiting for the promotion to complete") + + _ = cmd.MarkFlagRequired("documentdb") + _ = cmd.MarkFlagRequired("target-cluster") + + return cmd +} + +func (o *promoteOptions) complete() error { + o.documentDBName = strings.TrimSpace(o.documentDBName) + if o.documentDBName == "" { + return errors.New("--documentdb is required") + } + + if strings.TrimSpace(o.namespace) == "" { + o.namespace = defaultDocumentDBNamespace + } else { + o.namespace = strings.TrimSpace(o.namespace) + } + + o.hubContext = strings.TrimSpace(o.hubContext) + o.targetCluster = strings.TrimSpace(o.targetCluster) + if o.targetCluster == "" { + return errors.New("--target-cluster is required") + } + + o.targetContext = strings.TrimSpace(o.targetContext) + + if o.waitTimeout <= 0 { + o.waitTimeout = 10 * time.Minute + } + if o.pollInterval <= 0 { + o.pollInterval = 10 * time.Second + } + + return nil +} + +func (o *promoteOptions) run(ctx context.Context, cmd *cobra.Command) error { + cmd.PrintErrln("Starting DocumentDB promotion workflow...") + + hubConfig, hubContextName, err := loadConfigFunc(o.hubContext) + if err != nil { + return fmt.Errorf("failed to load hub kubeconfig: %w", err) + } + if o.targetContext == "" { + o.targetContext = hubContextName + } + if hubContextName == "" { + hubContextName = "(current)" + } + + dynHub, err := dynamicClientForConfig(hubConfig) + if err != nil { + return fmt.Errorf("failed to create hub dynamic client: %w", err) + } + + if err := o.patchDocumentDB(ctx, dynHub); err != nil { + return err + } + + if o.skipWait { + fmt.Fprintln(cmd.OutOrStdout(), "Promotion request submitted. Skipping wait as requested.") + return nil + } + + targetConfig, targetContextName, err := loadConfigFunc(o.targetContext) + if err != nil { + return fmt.Errorf("failed to load target kubeconfig: %w", err) + } + if targetContextName == "" { + targetContextName = o.targetContext + } + + dynTarget, err := dynamicClientForConfig(targetConfig) + if err != nil { + return fmt.Errorf("failed to create target dynamic client: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Waiting for DocumentDB replication to converge (hub context %q, target context %q)...\n", hubContextName, targetContextName) + + if err := o.waitForPromotion(ctx, dynHub, dynTarget); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), "Promotion completed successfully.") + return nil +} + +func (o *promoteOptions) patchDocumentDB(ctx context.Context, dyn dynamic.Interface) error { + gvr := schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} + + patch := map[string]any{ + "spec": map[string]any{ + "clusterReplication": map[string]any{ + "primary": o.targetCluster, + }, + }, + } + + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("failed to marshal patch: %w", err) + } + + _, err = dyn.Resource(gvr).Namespace(o.namespace).Patch(ctx, o.documentDBName, types.MergePatchType, patchBytes, metav1.PatchOptions{}) + if err != nil { + return fmt.Errorf("failed to patch DocumentDB %q: %w", o.documentDBName, err) + } + + return nil +} + +func (o *promoteOptions) waitForPromotion(ctx context.Context, dynHub, dynTarget dynamic.Interface) error { + ctx, cancel := context.WithTimeout(ctx, o.waitTimeout) + defer cancel() + + ticker := time.NewTicker(o.pollInterval) + defer ticker.Stop() + + gvr := schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for promotion to complete after %s", o.waitTimeout) + case <-ticker.C: + docHub, err := dynHub.Resource(gvr).Namespace(o.namespace).Get(ctx, o.documentDBName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get DocumentDB %q from hub context: %w", o.documentDBName, err) + } + if !isDocumentReady(docHub, o.targetCluster) { + continue + } + + if dynTarget != nil { + docTarget, err := dynTarget.Resource(gvr).Namespace(o.namespace).Get(ctx, o.documentDBName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + continue + } + return fmt.Errorf("failed to get DocumentDB %q from target context: %w", o.documentDBName, err) + } + if !isDocumentReady(docTarget, o.targetCluster) { + continue + } + } + + return nil + } + } +} + +func loadConfig(contextName string) (*rest.Config, string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + overrides := &clientcmd.ConfigOverrides{} + if contextName != "" { + overrides.CurrentContext = contextName + } + + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, "", err + } + + rawConfig, err := clientConfig.RawConfig() + if err != nil { + return restConfig, "", err + } + + if contextName != "" { + if _, ok := rawConfig.Contexts[contextName]; !ok { + return nil, "", fmt.Errorf("kubeconfig context %q not found", contextName) + } + return restConfig, contextName, nil + } + + return restConfig, rawConfig.CurrentContext, nil +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/promote_test.go b/plugins/documentdb-kubectl-plugin/cmd/promote_test.go new file mode 100644 index 00000000..05a93c67 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/promote_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamic "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +func TestWaitForPromotion(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + gvk := schema.GroupVersionKind{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Kind: "DocumentDB"} + scheme.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(gvk.GroupVersion().WithKind("DocumentDBList"), &unstructured.UnstructuredList{}) + + namespace := defaultDocumentDBNamespace + docName := "sample" + targetCluster := "cluster-b" + + hubDoc := newDocument(docName, namespace, "cluster-a", "Creating") + targetDoc := newDocument(docName, namespace, "cluster-a", "Creating") + + hubClient := dynamicfake.NewSimpleDynamicClient(scheme, hubDoc.DeepCopy()) + targetClient := dynamicfake.NewSimpleDynamicClient(scheme, targetDoc.DeepCopy()) + + opts := &promoteOptions{ + documentDBName: docName, + namespace: namespace, + targetCluster: targetCluster, + waitTimeout: 500 * time.Millisecond, + pollInterval: 20 * time.Millisecond, + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + gvr := schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} + + errCh := make(chan error, 1) + go func() { + time.Sleep(60 * time.Millisecond) + if err := setDocumentState(ctx, hubClient, gvr, namespace, docName, targetCluster, "Ready"); err != nil { + errCh <- err + return + } + if err := setDocumentState(ctx, targetClient, gvr, namespace, docName, targetCluster, "Ready"); err != nil { + errCh <- err + return + } + errCh <- nil + }() + + if err := opts.waitForPromotion(ctx, hubClient, targetClient); err != nil { + t.Fatalf("waitForPromotion returned error: %v", err) + } + + if err := <-errCh; err != nil { + t.Fatalf("failed to update documents: %v", err) + } +} + +func TestPatchDocumentDB(t *testing.T) { + t.Parallel() + + scheme := newDocumentScheme() + gvr := documentDBGVR() + + namespace := defaultDocumentDBNamespace + docName := "sample" + + doc := newDocument(docName, namespace, "cluster-a", "Ready") + + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, documentListKinds(), doc.DeepCopy()) + + opts := &promoteOptions{ + documentDBName: docName, + namespace: namespace, + targetCluster: "cluster-b", + } + + if err := opts.patchDocumentDB(context.Background(), client); err != nil { + t.Fatalf("patchDocumentDB returned error: %v", err) + } + + patched, err := client.Resource(gvr).Namespace(namespace).Get(context.Background(), docName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to fetch patched document: %v", err) + } + + primary, _, err := unstructured.NestedString(patched.Object, "spec", "clusterReplication", "primary") + if err != nil { + t.Fatalf("failed to read patched primary: %v", err) + } + if primary != "cluster-b" { + t.Fatalf("expected primary cluster-b, got %q", primary) + } +} + +func setDocumentState(ctx context.Context, client dynamic.Interface, gvr schema.GroupVersionResource, namespace, name, primary, phase string) error { + for { + obj, err := client.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if err := unstructured.SetNestedField(obj.Object, primary, "spec", "clusterReplication", "primary"); err != nil { + return err + } + if err := unstructured.SetNestedField(obj.Object, phase, "status", "status"); err != nil { + return err + } + _, err = client.Resource(gvr).Namespace(namespace).Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + if apierrors.IsConflict(err) { + continue + } + return err + } + return nil + } +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/root.go b/plugins/documentdb-kubectl-plugin/cmd/root.go new file mode 100644 index 00000000..548d211e --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/root.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "documentdb", + Short: "kubectl plugin for Azure Cosmos DB for MongoDB (DocumentDB)", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.AddCommand(newPromoteCommand()) + rootCmd.AddCommand(newStatusCommand()) + rootCmd.AddCommand(newEventsCommand()) +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/status.go b/plugins/documentdb-kubectl-plugin/cmd/status.go new file mode 100644 index 00000000..2fcf506b --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/status.go @@ -0,0 +1,306 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const documentdbServicePrefix = "documentdb-service-" + +type statusOptions struct { + documentDBName string + namespace string + kubeContext string + showConnections bool +} + +type clusterStatus struct { + Cluster string + ContextName string + Role string + Phase string + PodsReady int + PodsTotal int + ServiceIP string + Connection string + Err error +} + +func newStatusCommand() *cobra.Command { + opts := &statusOptions{ + namespace: defaultDocumentDBNamespace, + } + + cmd := &cobra.Command{ + Use: "status", + Short: "Show fleet-wide status for a DocumentDB deployment", + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.complete(); err != nil { + return err + } + return opts.run(cmd.Context(), cmd) + }, + } + + cmd.Flags().StringVar(&opts.documentDBName, "documentdb", opts.documentDBName, "Name of the DocumentDB resource") + cmd.Flags().StringVarP(&opts.namespace, "namespace", "n", opts.namespace, "Namespace containing the DocumentDB resource") + cmd.Flags().StringVar(&opts.kubeContext, "context", opts.kubeContext, "Kubeconfig context to use (defaults to current context)") + cmd.Flags().BoolVar(&opts.showConnections, "show-connections", false, "Include connection strings in the output") + + _ = cmd.MarkFlagRequired("documentdb") + + return cmd +} + +func (o *statusOptions) complete() error { + o.documentDBName = strings.TrimSpace(o.documentDBName) + if o.documentDBName == "" { + return errors.New("--documentdb is required") + } + o.namespace = strings.TrimSpace(o.namespace) + if o.namespace == "" { + o.namespace = defaultDocumentDBNamespace + } + return nil +} + +func (o *statusOptions) run(ctx context.Context, cmd *cobra.Command) error { + mainConfig, contextName, err := loadConfigFunc(o.kubeContext) + if err != nil { + return fmt.Errorf("failed to load kubeconfig: %w", err) + } + if contextName == "" { + contextName = "(current)" + } + + dynHub, err := dynamicClientForConfig(mainConfig) + if err != nil { + return fmt.Errorf("failed to create hub dynamic client: %w", err) + } + + gvr := schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} + + document, err := dynHub.Resource(gvr).Namespace(o.namespace).Get(ctx, o.documentDBName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get DocumentDB %q in namespace %q: %w", o.documentDBName, o.namespace, err) + } + + primaryCluster, _, err := unstructured.NestedString(document.Object, "spec", "clusterReplication", "primary") + if err != nil { + return fmt.Errorf("failed to read spec.clusterReplication.primary: %w", err) + } + clusterNames, found, err := unstructured.NestedStringSlice(document.Object, "spec", "clusterReplication", "clusterList") + if err != nil { + return fmt.Errorf("failed to read spec.clusterReplication.clusterList: %w", err) + } + if !found || len(clusterNames) == 0 { + return errors.New("DocumentDB spec.clusterReplication.clusterList is empty") + } + + overallPhase, _, _ := unstructured.NestedString(document.Object, "status", "status") + overallConnection, _, _ := unstructured.NestedString(document.Object, "status", "connectionString") + + fmt.Fprintf(cmd.OutOrStdout(), "DocumentDB: %s/%s\n", o.namespace, o.documentDBName) + fmt.Fprintf(cmd.OutOrStdout(), "Context: %s\n", contextName) + fmt.Fprintf(cmd.OutOrStdout(), "Primary cluster: %s\n", primaryCluster) + if overallPhase != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Overall status: %s\n", overallPhase) + } + fmt.Fprintln(cmd.OutOrStdout()) + + statuses := make([]clusterStatus, 0, len(clusterNames)) + for _, cluster := range clusterNames { + role := "Replica" + if cluster == primaryCluster { + role = "Primary" + } + + st := clusterStatus{ + Cluster: cluster, + Role: role, + Phase: "Unknown", + ServiceIP: "-", + } + + clusterConfig, clusterContextName, err := loadConfigFunc(cluster) + if err != nil { + st.Err = fmt.Errorf("load kubeconfig: %w", err) + statuses = append(statuses, st) + continue + } + if clusterContextName == "" { + clusterContextName = cluster + } + st.ContextName = clusterContextName + + if err := o.populateClusterStatus(ctx, &st, clusterConfig); err != nil { + st.Err = err + } + + statuses = append(statuses, st) + } + + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "CLUSTER\tROLE\tPHASE\tPODS\tSERVICE IP\tCONTEXT\tERROR") + for _, st := range statuses { + errorText := "-" + if st.Err != nil { + errorText = truncateString(st.Err.Error(), 80) + } + podsDisplay := fmt.Sprintf("%d/%d", st.PodsReady, st.PodsTotal) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + st.Cluster, + strings.ToUpper(st.Role), + safeValue(st.Phase), + podsDisplay, + safeValue(st.ServiceIP), + safeValue(st.ContextName), + errorText, + ) + } + _ = tw.Flush() + + if o.showConnections && overallConnection != "" { + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Primary connection string (from hub status):") + fmt.Fprintln(cmd.OutOrStdout(), overallConnection) + } + + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Tip: ensure 'kubectl config get-contexts' lists each member cluster so the plugin can query them.") + + return nil +} + +func (o *statusOptions) populateClusterStatus(ctx context.Context, st *clusterStatus, config *rest.Config) error { + dynClient, err := dynamicClientForConfig(config) + if err != nil { + return fmt.Errorf("dynamic client: %w", err) + } + + gvr := schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} + + document, err := dynClient.Resource(gvr).Namespace(o.namespace).Get(ctx, o.documentDBName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("fetch documentdb: %w", err) + } + + if phase, _, err := unstructured.NestedString(document.Object, "status", "status"); err == nil && phase != "" { + st.Phase = phase + } + if conn, _, err := unstructured.NestedString(document.Object, "status", "connectionString"); err == nil { + st.Connection = conn + } + + clientset, err := kubernetesClientForConfig(config) + if err != nil { + return fmt.Errorf("clientset: %w", err) + } + + pods, err := clientset.CoreV1().Pods(o.namespace).List(ctx, metav1.ListOptions{LabelSelector: fmt.Sprintf("app=%s", o.documentDBName)}) + if err != nil { + return fmt.Errorf("list pods: %w", err) + } + st.PodsTotal = len(pods.Items) + for idx := range pods.Items { + if isPodReady(&pods.Items[idx]) { + st.PodsReady++ + } + } + + serviceIP, err := findDocumentDBServiceEndpoint(ctx, clientset, o.namespace, st.Cluster, o.documentDBName) + if err == nil { + st.ServiceIP = serviceIP + } + + return nil +} + +func findDocumentDBServiceEndpoint(ctx context.Context, clientset kubernetes.Interface, namespace, clusterName, documentName string) (string, error) { + candidateNames := []string{ + documentdbServicePrefix + clusterName, + documentdbServicePrefix + documentName, + } + for _, name := range candidateNames { + svc, err := clientset.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + continue + } + return "", fmt.Errorf("get service %s: %w", name, err) + } + return renderServiceEndpoint(svc), nil + } + + services, err := clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("list services: %w", err) + } + for _, svc := range services.Items { + if strings.HasPrefix(svc.Name, documentdbServicePrefix) && (strings.Contains(svc.Name, clusterName) || strings.Contains(svc.Name, documentName)) { + return renderServiceEndpoint(&svc), nil + } + } + + return "", fmt.Errorf("service with prefix %s not found", documentdbServicePrefix) +} + +func renderServiceEndpoint(svc *corev1.Service) string { + if svc == nil { + return "-" + } + if len(svc.Status.LoadBalancer.Ingress) > 0 { + ingress := svc.Status.LoadBalancer.Ingress[0] + if ingress.IP != "" { + return ingress.IP + } + if ingress.Hostname != "" { + return ingress.Hostname + } + } + if svc.Spec.ClusterIP != "" && svc.Spec.ClusterIP != "None" { + return svc.Spec.ClusterIP + } + return "-" +} + +func isPodReady(pod *corev1.Pod) bool { + if pod == nil { + return false + } + for _, cond := range pod.Status.Conditions { + if cond.Type == corev1.PodReady { + return cond.Status == corev1.ConditionTrue + } + } + return false +} + +func safeValue(val string) string { + if strings.TrimSpace(val) == "" { + return "-" + } + return val +} + +func truncateString(val string, max int) string { + if len(val) <= max { + return val + } + if max <= 3 { + return val[:max] + } + return val[:max-3] + "..." +} diff --git a/plugins/documentdb-kubectl-plugin/cmd/status_run_test.go b/plugins/documentdb-kubectl-plugin/cmd/status_run_test.go new file mode 100644 index 00000000..7e180d49 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/cmd/status_run_test.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" + kubefake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" +) + +func TestStatusRunRendersClusterTable(t *testing.T) { + t.Parallel() + + prevLoad := loadConfigFunc + prevDynamic := dynamicClientForConfig + prevKube := kubernetesClientForConfig + defer func() { + loadConfigFunc = prevLoad + dynamicClientForConfig = prevDynamic + kubernetesClientForConfig = prevKube + }() + + scheme := runtime.NewScheme() + gvk := schema.GroupVersionKind{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Kind: "DocumentDB"} + scheme.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(gvk.GroupVersion().WithKind("DocumentDBList"), &unstructured.UnstructuredList{}) + gvr := schema.GroupVersionResource{Group: documentDBGVRGroup, Version: documentDBGVRVersion, Resource: documentDBGVRResource} + + namespace := defaultDocumentDBNamespace + docName := "documentdb-sample" + + hubDoc := newDocument(docName, namespace, "cluster-a", "Ready") + if err := unstructured.SetNestedStringSlice(hubDoc.Object, []string{"cluster-a", "cluster-b"}, "spec", "clusterReplication", "clusterList"); err != nil { + t.Fatalf("failed to set clusterList: %v", err) + } + if err := unstructured.SetNestedField(hubDoc.Object, "PrimaryConn", "status", "connectionString"); err != nil { + t.Fatalf("failed to set connection string: %v", err) + } + + clusterADoc := newDocument(docName, namespace, "cluster-a", "Ready") + if err := unstructured.SetNestedField(clusterADoc.Object, "PrimaryConn", "status", "connectionString"); err != nil { + t.Fatalf("failed to set cluster A connection: %v", err) + } + + clusterBDoc := newDocument(docName, namespace, "cluster-a", "Syncing") + + hubClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{gvr: "DocumentDBList"}, hubDoc.DeepCopy()) + clusterAClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{gvr: "DocumentDBList"}, clusterADoc.DeepCopy()) + clusterBClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{gvr: "DocumentDBList"}, clusterBDoc.DeepCopy()) + + dynamicClients := map[string]dynamic.Interface{ + "hub": hubClient, + "cluster-a": clusterAClient, + "cluster-b": clusterBClient, + } + + loadConfigFunc = func(contextName string) (*rest.Config, string, error) { + if contextName == "" { + return &rest.Config{Host: "hub"}, "hub-context", nil + } + if _, ok := dynamicClients[contextName]; ok { + return &rest.Config{Host: contextName}, contextName, nil + } + return nil, "", fmt.Errorf("unknown context %q", contextName) + } + + dynamicClientForConfig = func(cfg *rest.Config) (dynamic.Interface, error) { + client, ok := dynamicClients[cfg.Host] + if !ok { + return nil, fmt.Errorf("no dynamic client for host %s", cfg.Host) + } + return client, nil + } + + clusterAPodReady := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "a-ready", + Namespace: namespace, + Labels: map[string]string{"app": docName}, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}, + }, + } + clusterAPodReadyTwo := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "a-ready-2", + Namespace: namespace, + Labels: map[string]string{"app": docName}, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}, + }, + } + clusterBPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b-pod", + Namespace: namespace, + Labels: map[string]string{"app": docName}, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionFalse}}, + }, + } + + svcA := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbServicePrefix + "cluster-a", + Namespace: namespace, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{Ingress: []corev1.LoadBalancerIngress{{IP: "1.2.3.4"}}}, + }, + } + svcB := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbServicePrefix + "cluster-b", + Namespace: namespace, + }, + Spec: corev1.ServiceSpec{ClusterIP: "10.0.0.2"}, + } + + kubeClients := map[string]kubernetes.Interface{ + "cluster-a": kubefake.NewSimpleClientset(clusterAPodReady, clusterAPodReadyTwo, svcA), + "cluster-b": kubefake.NewSimpleClientset(clusterBPod, svcB), + } + + kubernetesClientForConfig = func(cfg *rest.Config) (kubernetes.Interface, error) { + client, ok := kubeClients[cfg.Host] + if !ok { + return nil, fmt.Errorf("no kubernetes client for host %s", cfg.Host) + } + return client, nil + } + + cmd := &cobra.Command{} + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + opts := &statusOptions{ + documentDBName: docName, + namespace: namespace, + showConnections: true, + } + + if err := opts.run(context.Background(), cmd); err != nil { + t.Fatalf("run returned error: %v", err) + } + + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %s", stderr.String()) + } + + output := stdout.String() + + checks := []struct { + description string + substring string + }{ + {"primary cluster", "Primary cluster: cluster-a"}, + {"cluster a row", "cluster-a"}, + {"cluster a readiness", "2/2"}, + {"service ip", "1.2.3.4"}, + {"cluster b row", "cluster-b"}, + {"cluster b readiness", "0/1"}, + {"connection string", "Primary connection string"}, + {"tip", "Tip: ensure 'kubectl config get-contexts'"}, + } + + for _, check := range checks { + if !strings.Contains(output, check.substring) { + t.Fatalf("expected output to contain %s (%q), got: %s", check.description, check.substring, output) + } + } +} diff --git a/plugins/documentdb-kubectl-plugin/go.mod b/plugins/documentdb-kubectl-plugin/go.mod new file mode 100644 index 00000000..e9be5ed4 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/go.mod @@ -0,0 +1,52 @@ +module github.com/microsoft/documentdb-operator/plugins/documentdb-kubectl-plugin + +go 1.23.5 + +require ( + github.com/spf13/cobra v1.9.1 + k8s.io/api v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.7.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/plugins/documentdb-kubectl-plugin/go.sum b/plugins/documentdb-kubectl-plugin/go.sum new file mode 100644 index 00000000..64cf7b81 --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/go.sum @@ -0,0 +1,160 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= +k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= +k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= +k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/plugins/documentdb-kubectl-plugin/main.go b/plugins/documentdb-kubectl-plugin/main.go new file mode 100644 index 00000000..da91d2df --- /dev/null +++ b/plugins/documentdb-kubectl-plugin/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/microsoft/documentdb-operator/plugins/documentdb-kubectl-plugin/cmd" + +func main() { + cmd.Execute() +} diff --git a/scripts/aks-fleet-deployment/multi-region.yaml b/scripts/aks-fleet-deployment/multi-region.yaml index f2e81b9f..a1eef56c 100644 --- a/scripts/aks-fleet-deployment/multi-region.yaml +++ b/scripts/aks-fleet-deployment/multi-region.yaml @@ -29,7 +29,8 @@ spec: documentDBImage: ghcr.io/microsoft/documentdb/documentdb-local:16 gatewayImage: ghcr.io/microsoft/documentdb/documentdb-local:16 resource: - pvcSize: 10Gi + storage: + pvcSize: 10Gi clusterReplication: highAvailability: true enableFleetForCrossCloud: true