From ad56bbdf50a4ed8c2f09582f3ffc4776911db9d3 Mon Sep 17 00:00:00 2001 From: Filipe Galo Date: Mon, 31 Aug 2026 16:54:38 +0100 Subject: [PATCH 1/2] feat: guard bootstrap against an existing App of Apps and announce the target context Bootstrap previously ran regardless of whether the cluster had already been bootstrapped, silently overwriting the app-of-apps root Application. It also never named the cluster it was about to modify unless --context was passed explicitly. Add two checks that run after the Kubernetes client connects but before anything is written, so aborting leaves the cluster untouched: - Client.GetAppOfApps reads the app-of-apps Application. When one exists, bootstrap reports its repository, revision, path and sync/health and stops. --force overwrites it instead, still reporting what was found. - The resolved kubeconfig context is announced with a 10 second countdown so a run against the wrong cluster can be aborted with Ctrl+C. Skipped by --yes and automatically when stdout is not a terminal, so CI is not delayed. k8s.ResolveContext resolves the context via client-go rather than shelling out to kubectl, so the Configuration stage and the JSON report now name the real target context even when --context is omitted. Also fix two output problems the early abort exposed: - The report rendered zero-valued resource entries, claiming the App of Apps was "updated" on a run that never reached it. Resource lines are now printed only for resources bootstrap actually touched. - Runtime errors dumped the full usage text and printed the error twice. SilenceUsage is set inside runBootstrap, so flag parse errors still show usage, and SilenceErrors is set on the root command since Execute prints errors itself. Note for existing users: re-running bootstrap on an already bootstrapped cluster now requires --force. The underlying operations remain idempotent. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +- cluster-bootstrap-cli/cmd/bootstrap.go | 39 +++++- cluster-bootstrap-cli/cmd/bootstrap_guard.go | 99 +++++++++++++++ .../cmd/bootstrap_guard_test.go | 99 +++++++++++++++ cluster-bootstrap-cli/cmd/bootstrap_report.go | 63 ++++++---- cluster-bootstrap-cli/cmd/info.go | 20 +-- cluster-bootstrap-cli/cmd/root.go | 2 + .../internal/k8s/appofapps.go | 35 ++++-- .../internal/k8s/appofapps_test.go | 119 ++++++++++++++++++ cluster-bootstrap-cli/internal/k8s/client.go | 47 +++++-- .../internal/k8s/client_mock.go | 18 ++- docs/cli/bootstrap.md | 61 +++++++-- docs/guides/subfolder-setup.md | 4 +- docs/guides/troubleshooting.md | 21 ++++ 14 files changed, 565 insertions(+), 68 deletions(-) create mode 100644 cluster-bootstrap-cli/cmd/bootstrap_guard.go create mode 100644 cluster-bootstrap-cli/cmd/bootstrap_guard_test.go create mode 100644 cluster-bootstrap-cli/internal/k8s/appofapps_test.go diff --git a/README.md b/README.md index a89e03f..fdbc114 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ This will: 5. Install ArgoCD via Helm 6. Deploy the root **App of Apps** Application, enabling its Cilium child Application when requested -> **💡 Idempotent by design**: The bootstrap command can be safely run multiple times. It automatically detects existing resources and updates them instead of failing. Perfect for configuration updates or GitOps workflows. +Before touching the cluster, bootstrap announces the target Kubernetes context with a 10 second countdown (skip with `--yes`), and stops if the cluster already has an App of Apps. + +> **🛡️ Safe by default**: A cluster that already has an `app-of-apps` Application is reported and left untouched. Re-run with `--force` to bootstrap it again — the operations themselves are idempotent, detecting existing resources and updating them instead of failing. #### Bootstrap Reports @@ -217,7 +219,7 @@ The `apps/` chart uses a **single dynamic template** that iterates over a `compo | Command | Description | |---------|-------------| -| `bootstrap ` | Full cluster bootstrap (decrypt secrets, install ArgoCD, deploy App of Apps). Generates comprehensive reports with timing metrics and resource operations. Fully idempotent. | +| `bootstrap ` | Full cluster bootstrap (decrypt secrets, install ArgoCD, deploy App of Apps). Generates comprehensive reports with timing metrics and resource operations. Stops on an already bootstrapped cluster unless `--force` is passed; idempotent when forced. | | `template customize` | Customize the template with your organization and repository (replaces placeholders in configs, docs, and code) | | `doctor` | Run prerequisite checks for tooling and cluster access | | `status ` | Show cluster status and component information | diff --git a/cluster-bootstrap-cli/cmd/bootstrap.go b/cluster-bootstrap-cli/cmd/bootstrap.go index 5bd0f12..e363f6a 100644 --- a/cluster-bootstrap-cli/cmd/bootstrap.go +++ b/cluster-bootstrap-cli/cmd/bootstrap.go @@ -34,6 +34,8 @@ var ( healthTimeout int reportFormat string reportOutput string + bootstrapForce bool + bootstrapYes bool ) type sopsAgeKeySecretCreator interface { @@ -68,11 +70,17 @@ func init() { bootstrapCmd.Flags().IntVar(&healthTimeout, "health-timeout", 180, "timeout in seconds for health checks (default 180)") bootstrapCmd.Flags().StringVar(&reportFormat, "report-format", "summary", "report format: summary, json, none") bootstrapCmd.Flags().StringVar(&reportOutput, "report-output", "", "write JSON report to file") + bootstrapCmd.Flags().BoolVar(&bootstrapForce, "force", false, "bootstrap even if the cluster already has an App of Apps, overwriting it") + bootstrapCmd.Flags().BoolVarP(&bootstrapYes, "yes", "y", false, "skip the countdown before the cluster is modified") rootCmd.AddCommand(bootstrapCmd) } func runBootstrap(cmd *cobra.Command, args []string) error { + // Flags parsed successfully, so any error from here is a runtime failure: + // print it on its own instead of burying it under the usage text. + cmd.SilenceUsage = true + env := args[0] // Validate report format @@ -132,6 +140,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { DryRun: dryRun, SkipArgoCDInstall: skipArgoCDInstall, EnableCilium: enableCilium, + Force: bootstrapForce, WaitForHealth: waitForHealth, } @@ -194,6 +203,20 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } report.AddStage(validationTimer.complete(true, nil)) + // Resolve the kubeconfig context up front so every message names the cluster + // that is actually targeted, not just an explicit --context override. + targetContext, contextErr := k8s.ResolveContext(kubeconfig, kubeContext) + if contextErr != nil { + // Not fatal: dry runs need no cluster, and client creation reports real + // connection problems with a better message. + targetContext = kubeContext + if targetContext == "" { + targetContext = "(current kubeconfig context)" + } + } else { + report.Configuration.Context = targetContext + } + // Log configuration configStage := logger.Stage("Configuration") configStage.Detail("Environment: %s", env) @@ -209,8 +232,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error { if kubeconfig != "" { configStage.Detail("Kubeconfig: %s", kubeconfig) } - if kubeContext != "" { - configStage.Detail("Context: %s", kubeContext) + if targetContext != "" { + configStage.Detail("Context: %s", targetContext) } if dryRun { configStage.Detail("⚠ DRY RUN mode - no changes will be applied") @@ -309,6 +332,18 @@ func runBootstrap(cmd *cobra.Command, args []string) error { ctx := context.Background() + // Safeguard: an existing App of Apps means the cluster is already bootstrapped. + // Check before anything is mutated so an abort leaves the cluster untouched. + guardTimer := startStage("App of Apps Safeguard") + if err := guardExistingAppOfApps(ctx, client, targetContext, bootstrapForce); err != nil { + bootstrapErr = err + report.AddStage(guardTimer.complete(false, err)) + return err + } + report.AddStage(guardTimer.complete(true, nil)) + + announceTargetContext(os.Stdout, targetContext, bootstrapCountdownSeconds, !bootstrapYes && isInteractiveTerminal()) + // Create Kubernetes secrets (before Helm install, as the chart may reference them) secretsK8sTimer := startStage("Creating K8s Resources") secretsK8sStage := logger.Stage("Creating K8s Secrets") diff --git a/cluster-bootstrap-cli/cmd/bootstrap_guard.go b/cluster-bootstrap-cli/cmd/bootstrap_guard.go new file mode 100644 index 0000000..4270f9e --- /dev/null +++ b/cluster-bootstrap-cli/cmd/bootstrap_guard.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "golang.org/x/term" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// bootstrapCountdownSeconds is the grace period given to abort a bootstrap once +// the target cluster context has been announced. +const bootstrapCountdownSeconds = 10 + +// countdownInterval is the delay between countdown ticks. Overridden in tests. +var countdownInterval = time.Second + +// appOfAppsGetter reads the App of Apps root Application from a cluster. +type appOfAppsGetter interface { + GetAppOfApps(ctx context.Context) (*unstructured.Unstructured, error) +} + +// announceTargetContext tells the operator which cluster is about to be modified. +// When interactive, it counts down so the bootstrap can still be aborted with Ctrl+C. +func announceTargetContext(out io.Writer, kubeContext string, seconds int, interactive bool) { + fmt.Fprintf(out, "\n%s Bootstrap will modify the cluster on Kubernetes context: %s\n", + warningColor("⚠ "), stepColor(kubeContext)) + + if !interactive || seconds <= 0 { + return + } + + for remaining := seconds; remaining > 0; remaining-- { + fmt.Fprintf(out, "\r Starting in %2ds... press Ctrl+C to abort", remaining) + time.Sleep(countdownInterval) + } + fmt.Fprintf(out, "\r Starting now... \n") +} + +// guardExistingAppOfApps refuses to bootstrap a cluster that already has an App +// of Apps root Application, unless force is set. Returns the existing +// Application when one was found so callers can report it. +func guardExistingAppOfApps(ctx context.Context, client appOfAppsGetter, kubeContext string, force bool) error { + existing, err := client.GetAppOfApps(ctx) + if err != nil { + return err + } + if existing == nil { + return nil + } + + app := parseArgoCDApplication(existing) + if force { + warnf("An App of Apps already exists on context %s and will be overwritten (--force).", kubeContext) + printExistingAppOfApps(os.Stdout, app) + return nil + } + + printExistingAppOfApps(os.Stdout, app) + return fmt.Errorf("cluster already bootstrapped: App of Apps %q exists in namespace %s on context %s\n"+ + " hint: inspect the existing installation with: cluster-bootstrap-cli info \n"+ + " tip: re-run with --force to overwrite the existing App of Apps", + app.Name, app.Namespace, kubeContext) +} + +func printExistingAppOfApps(out io.Writer, app ArgoCDAppInfo) { + fmt.Fprintf(out, "\n Existing App of Apps:\n") + fmt.Fprintf(out, " Application: %s (namespace %s)\n", app.Name, app.Namespace) + if app.RepoURL != "" { + fmt.Fprintf(out, " Repository: %s\n", app.RepoURL) + } + if app.TargetRevision != "" { + fmt.Fprintf(out, " Revision: %s\n", app.TargetRevision) + } + if app.Path != "" { + fmt.Fprintf(out, " Path: %s\n", app.Path) + } + if app.SyncStatus != "" || app.HealthStatus != "" { + fmt.Fprintf(out, " Sync/Health: %s / %s\n", + orUnknown(app.SyncStatus), orUnknown(app.HealthStatus)) + } + fmt.Fprintln(out) +} + +func orUnknown(value string) string { + if value == "" { + return "Unknown" + } + return value +} + +// isInteractiveTerminal reports whether stdout is attached to a terminal, so +// non-interactive runs (CI, piped output) are not delayed by the countdown. +func isInteractiveTerminal() bool { + return term.IsTerminal(int(os.Stdout.Fd())) // #nosec G115 +} diff --git a/cluster-bootstrap-cli/cmd/bootstrap_guard_test.go b/cluster-bootstrap-cli/cmd/bootstrap_guard_test.go new file mode 100644 index 0000000..6025456 --- /dev/null +++ b/cluster-bootstrap-cli/cmd/bootstrap_guard_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/user-cube/cluster-bootstrap/cluster-bootstrap-cli/internal/k8s" +) + +func TestGuardExistingAppOfApps_NoExistingApp(t *testing.T) { + mockClient := k8s.NewMockClient() + + err := guardExistingAppOfApps(context.Background(), mockClient, "kind-dev", false) + require.NoError(t, err) +} + +func TestGuardExistingAppOfApps_AbortsWhenAppExists(t *testing.T) { + mockClient := k8s.NewMockClient() + _, _, err := mockClient.ApplyAppOfApps(context.Background(), "git@github.com:acme/repo.git", "main", "dev", "apps", false, false) + require.NoError(t, err) + + err = guardExistingAppOfApps(context.Background(), mockClient, "kind-dev", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "cluster already bootstrapped") + assert.Contains(t, err.Error(), "app-of-apps") + assert.Contains(t, err.Error(), "kind-dev") + assert.Contains(t, err.Error(), "--force") +} + +func TestGuardExistingAppOfApps_ForceOverwrites(t *testing.T) { + mockClient := k8s.NewMockClient() + _, _, err := mockClient.ApplyAppOfApps(context.Background(), "git@github.com:acme/repo.git", "main", "dev", "apps", false, false) + require.NoError(t, err) + + err = guardExistingAppOfApps(context.Background(), mockClient, "kind-dev", true) + require.NoError(t, err, "--force must allow bootstrap to continue") +} + +func TestGuardExistingAppOfApps_PropagatesLookupError(t *testing.T) { + mockClient := k8s.NewMockClient() + mockClient.GetAppOfAppsErr = fmt.Errorf("permission denied: cannot read applications") + + err := guardExistingAppOfApps(context.Background(), mockClient, "kind-dev", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestPrintExistingAppOfApps_ShowsSourceDetails(t *testing.T) { + var out bytes.Buffer + printExistingAppOfApps(&out, ArgoCDAppInfo{ + Name: "app-of-apps", + Namespace: "argocd", + RepoURL: "git@github.com:acme/repo.git", + TargetRevision: "main", + Path: "apps", + SyncStatus: "Synced", + }) + + output := out.String() + assert.Contains(t, output, "app-of-apps (namespace argocd)") + assert.Contains(t, output, "git@github.com:acme/repo.git") + assert.Contains(t, output, "main") + assert.Contains(t, output, "apps") + assert.Contains(t, output, "Synced / Unknown", "missing health status should render as Unknown") +} + +func TestAnnounceTargetContext_NonInteractiveSkipsCountdown(t *testing.T) { + var out bytes.Buffer + + start := time.Now() + announceTargetContext(&out, "kind-dev", 10, false) + elapsed := time.Since(start) + + assert.Less(t, elapsed, time.Second, "non-interactive runs must not wait") + assert.Contains(t, out.String(), "kind-dev") + assert.NotContains(t, out.String(), "Starting in") +} + +func TestAnnounceTargetContext_InteractiveCountsDown(t *testing.T) { + original := countdownInterval + countdownInterval = time.Millisecond + defer func() { countdownInterval = original }() + + var out bytes.Buffer + announceTargetContext(&out, "kind-dev", 3, true) + + output := out.String() + assert.Contains(t, output, "kind-dev") + assert.Contains(t, output, "Starting in 3s") + assert.Contains(t, output, "Starting in 1s") + assert.Contains(t, output, "Ctrl+C to abort") + assert.Contains(t, output, "Starting now") +} diff --git a/cluster-bootstrap-cli/cmd/bootstrap_report.go b/cluster-bootstrap-cli/cmd/bootstrap_report.go index 97c669f..5dc9574 100644 --- a/cluster-bootstrap-cli/cmd/bootstrap_report.go +++ b/cluster-bootstrap-cli/cmd/bootstrap_report.go @@ -98,9 +98,21 @@ type ConfigReport struct { DryRun bool `json:"dry_run"` SkipArgoCDInstall bool `json:"skip_argocd_install"` EnableCilium bool `json:"enable_cilium,omitempty"` + Force bool `json:"force,omitempty"` WaitForHealth bool `json:"wait_for_health"` } +// anyReported reports whether bootstrap reached any resource, so an early abort +// prints no resource section instead of zero-valued entries. +func (r ResourceReport) anyReported() bool { + return r.Namespace.Name != "" || + len(r.Secrets) > 0 || + r.CiliumRelease != nil || + r.ArgoCDRelease.Name != "" || + r.CiliumApplication != nil || + r.AppOfApps.Name != "" +} + // NewBootstrapReport creates a new bootstrap report. func NewBootstrapReport(env string) *BootstrapReport { return &BootstrapReport{ @@ -179,32 +191,41 @@ func (r *BootstrapReport) PrintSummary() { fmt.Printf(" %s %-30s %8s\n", stageStatus, stage.Name, stage.Duration) } - // Resources - fmt.Println() - fmt.Println("📦 Resources:") - fmt.Printf(" Namespace: %s (%s)\n", r.Resources.Namespace.Name, statusText(r.Resources.Namespace.Created, "created", "verified")) + // Resources. A resource is only reported once bootstrap actually reached it, + // so a run that aborted early does not claim resources were touched. + if r.Resources.anyReported() { + fmt.Println() + fmt.Println("📦 Resources:") + if r.Resources.Namespace.Name != "" { + fmt.Printf(" Namespace: %s (%s)\n", r.Resources.Namespace.Name, statusText(r.Resources.Namespace.Created, "created", "verified")) + } - for _, secret := range r.Resources.Secrets { - fmt.Printf(" Secret: %s/%s (%s)\n", secret.Namespace, secret.Name, statusText(secret.Created, "created", "updated")) - } - if r.Resources.CiliumRelease != nil { - fmt.Printf(" Helm Release: %s (%s)\n", r.Resources.CiliumRelease.Name, statusText(r.Resources.CiliumRelease.Installed, "installed", "upgraded")) - } + for _, secret := range r.Resources.Secrets { + fmt.Printf(" Secret: %s/%s (%s)\n", secret.Namespace, secret.Name, statusText(secret.Created, "created", "updated")) + } + if r.Resources.CiliumRelease != nil { + fmt.Printf(" Helm Release: %s (%s)\n", r.Resources.CiliumRelease.Name, statusText(r.Resources.CiliumRelease.Installed, "installed", "upgraded")) + } - if !r.Resources.ArgoCDRelease.Skipped { - fmt.Printf(" Helm Release: %s (%s)\n", r.Resources.ArgoCDRelease.Name, statusText(r.Resources.ArgoCDRelease.Installed, "installed", "upgraded")) - } else { - fmt.Printf(" Helm Release: %s (skipped)\n", r.Resources.ArgoCDRelease.Name) - } + if r.Resources.ArgoCDRelease.Name != "" { + if !r.Resources.ArgoCDRelease.Skipped { + fmt.Printf(" Helm Release: %s (%s)\n", r.Resources.ArgoCDRelease.Name, statusText(r.Resources.ArgoCDRelease.Installed, "installed", "upgraded")) + } else { + fmt.Printf(" Helm Release: %s (skipped)\n", r.Resources.ArgoCDRelease.Name) + } + } - if r.Resources.CiliumApplication != nil { - if r.Resources.CiliumApplication.ManagedBy != "" { - fmt.Printf(" Application: %s (managed by %s)\n", r.Resources.CiliumApplication.Name, r.Resources.CiliumApplication.ManagedBy) - } else { - fmt.Printf(" Application: %s (%s)\n", r.Resources.CiliumApplication.Name, statusText(r.Resources.CiliumApplication.Created, "created", "updated")) + if r.Resources.CiliumApplication != nil { + if r.Resources.CiliumApplication.ManagedBy != "" { + fmt.Printf(" Application: %s (managed by %s)\n", r.Resources.CiliumApplication.Name, r.Resources.CiliumApplication.ManagedBy) + } else { + fmt.Printf(" Application: %s (%s)\n", r.Resources.CiliumApplication.Name, statusText(r.Resources.CiliumApplication.Created, "created", "updated")) + } + } + if r.Resources.AppOfApps.Name != "" { + fmt.Printf(" Application: %s (%s)\n", r.Resources.AppOfApps.Name, statusText(r.Resources.AppOfApps.Created, "created", "updated")) } } - fmt.Printf(" Application: %s (%s)\n", r.Resources.AppOfApps.Name, statusText(r.Resources.AppOfApps.Created, "created", "updated")) // Health checks if r.Health != nil && r.Health.Checked { diff --git a/cluster-bootstrap-cli/cmd/info.go b/cluster-bootstrap-cli/cmd/info.go index 8b1a819..5f0d95a 100644 --- a/cluster-bootstrap-cli/cmd/info.go +++ b/cluster-bootstrap-cli/cmd/info.go @@ -26,14 +26,15 @@ type InfoResult struct { // ArgoCDAppInfo holds ArgoCD Application information type ArgoCDAppInfo struct { - Name string - Namespace string - SyncStatus string - HealthStatus string - Destination string - RepoURL string - Path string - SyncWave string + Name string + Namespace string + SyncStatus string + HealthStatus string + Destination string + RepoURL string + TargetRevision string + Path string + SyncWave string } // ComponentInfo holds information about a component @@ -395,6 +396,9 @@ func parseArgoCDApplication(obj *unstructured.Unstructured) ArgoCDAppInfo { if repoURL, ok := source["repoURL"].(string); ok { app.RepoURL = repoURL } + if targetRevision, ok := source["targetRevision"].(string); ok { + app.TargetRevision = targetRevision + } if path, ok := source["path"].(string); ok { app.Path = path } diff --git a/cluster-bootstrap-cli/cmd/root.go b/cluster-bootstrap-cli/cmd/root.go index 9199223..e832359 100644 --- a/cluster-bootstrap-cli/cmd/root.go +++ b/cluster-bootstrap-cli/cmd/root.go @@ -26,6 +26,8 @@ var rootCmd = &cobra.Command{ Long: `cluster-bootstrap is a CLI tool that replaces the manual bootstrap process. It uses SOPS-encrypted secrets to configure ArgoCD, create Kubernetes secrets, and deploy the App of Apps pattern.`, + // Execute prints errors itself; without this cobra prints them a second time. + SilenceErrors: true, } func Execute() { diff --git a/cluster-bootstrap-cli/internal/k8s/appofapps.go b/cluster-bootstrap-cli/internal/k8s/appofapps.go index d6401f8..e7b182e 100644 --- a/cluster-bootstrap-cli/internal/k8s/appofapps.go +++ b/cluster-bootstrap-cli/internal/k8s/appofapps.go @@ -13,6 +13,29 @@ import ( const argoCDNamespace = "argocd" +// AppOfAppsName is the name of the root Application deployed by bootstrap. +const AppOfAppsName = "app-of-apps" + +var applicationGVR = schema.GroupVersionResource{ + Group: "argoproj.io", + Version: "v1alpha1", + Resource: "applications", +} + +// GetAppOfApps returns the App of Apps root Application already present in the +// cluster, or nil when it does not exist. A missing ArgoCD Application CRD is +// also reported as absent, since no App of Apps can exist without it. +func (c *Client) GetAppOfApps(ctx context.Context) (*unstructured.Unstructured, error) { + app, err := c.DynamicClient.Resource(applicationGVR).Namespace(argoCDNamespace).Get(ctx, AppOfAppsName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to look up the existing App of Apps: %w\n hint: verify your role can read applications.argoproj.io in the argocd namespace\n tip: try: kubectl -n argocd get application app-of-apps", err) + } + return app, nil +} + // ApplyAppOfApps creates or updates the App of Apps root Application CR. // Returns a boolean indicating if it was created (true) or updated (false) when not in dry-run mode. func (c *Client) ApplyAppOfApps(ctx context.Context, repoURL, targetRevision, env, appPath string, enableCilium, dryRun bool) (string, bool, error) { @@ -40,7 +63,7 @@ func buildAppOfApps(repoURL, targetRevision, env, appPath string, enableCilium b "apiVersion": "argoproj.io/v1alpha1", "kind": "Application", "metadata": map[string]interface{}{ - "name": "app-of-apps", + "name": AppOfAppsName, "namespace": argoCDNamespace, }, "spec": map[string]interface{}{ @@ -77,17 +100,11 @@ func (c *Client) applyApplication(ctx context.Context, app *unstructured.Unstruc return string(data), true, nil } - gvr := schema.GroupVersionResource{ - Group: "argoproj.io", - Version: "v1alpha1", - Resource: "applications", - } - // Check if Application already exists - _, err := c.DynamicClient.Resource(gvr).Namespace(argoCDNamespace).Get(ctx, name, metav1.GetOptions{}) + _, err := c.DynamicClient.Resource(applicationGVR).Namespace(argoCDNamespace).Get(ctx, name, metav1.GetOptions{}) exists := err == nil - _, err = c.DynamicClient.Resource(gvr).Namespace(argoCDNamespace).Apply( + _, err = c.DynamicClient.Resource(applicationGVR).Namespace(argoCDNamespace).Apply( ctx, name, app, metav1.ApplyOptions{FieldManager: "cluster-bootstrap"}, ) if err != nil { diff --git a/cluster-bootstrap-cli/internal/k8s/appofapps_test.go b/cluster-bootstrap-cli/internal/k8s/appofapps_test.go new file mode 100644 index 0000000..62bb4c1 --- /dev/null +++ b/cluster-bootstrap-cli/internal/k8s/appofapps_test.go @@ -0,0 +1,119 @@ +package k8s + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +func newFakeAppClient(objects ...runtime.Object) *Client { + scheme := runtime.NewScheme() + listKinds := map[schema.GroupVersionResource]string{ + applicationGVR: "ApplicationList", + } + return &Client{ + DynamicClient: dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...), + } +} + +func TestGetAppOfApps_AbsentReportsNil(t *testing.T) { + client := newFakeAppClient() + + app, err := client.GetAppOfApps(context.Background()) + require.NoError(t, err) + assert.Nil(t, app, "a cluster without an App of Apps must not report one") +} + +func TestGetAppOfApps_ReturnsExistingApplication(t *testing.T) { + existing := buildAppOfApps("ssh://git@example.com/repo.git", "main", "dev", "apps", false) + client := newFakeAppClient(existing) + + app, err := client.GetAppOfApps(context.Background()) + require.NoError(t, err) + require.NotNil(t, app) + assert.Equal(t, AppOfAppsName, app.GetName()) + assert.Equal(t, argoCDNamespace, app.GetNamespace()) + + repoURL, found, err := unstructured.NestedString(app.Object, "spec", "source", "repoURL") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "ssh://git@example.com/repo.git", repoURL) +} + +func TestGetAppOfApps_IgnoresOtherApplications(t *testing.T) { + other := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Application", + "metadata": map[string]interface{}{ + "name": "vault", + "namespace": argoCDNamespace, + }, + }, + } + client := newFakeAppClient(other) + + app, err := client.GetAppOfApps(context.Background()) + require.NoError(t, err) + assert.Nil(t, app) +} + +func TestResolveContext(t *testing.T) { + dir := t.TempDir() + kubeconfigPath := filepath.Join(dir, "config") + kubeconfig := `apiVersion: v1 +kind: Config +current-context: kind-dev +clusters: +- name: dev + cluster: + server: https://dev.example.com +- name: prod + cluster: + server: https://prod.example.com +contexts: +- name: kind-dev + context: + cluster: dev + user: dev +- name: kind-prod + context: + cluster: prod + user: prod +users: +- name: dev + user: {} +- name: prod + user: {} +` + require.NoError(t, os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0o600)) + + t.Run("falls back to the kubeconfig current context", func(t *testing.T) { + resolved, err := ResolveContext(kubeconfigPath, "") + require.NoError(t, err) + assert.Equal(t, "kind-dev", resolved) + }) + + t.Run("prefers the explicit override", func(t *testing.T) { + resolved, err := ResolveContext(kubeconfigPath, "kind-prod") + require.NoError(t, err) + assert.Equal(t, "kind-prod", resolved) + }) + + t.Run("errors when no current context is set", func(t *testing.T) { + emptyPath := filepath.Join(dir, "empty") + require.NoError(t, os.WriteFile(emptyPath, []byte("apiVersion: v1\nkind: Config\n"), 0o600)) + + _, err := ResolveContext(emptyPath, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "no current context set") + }) +} diff --git a/cluster-bootstrap-cli/internal/k8s/client.go b/cluster-bootstrap-cli/internal/k8s/client.go index 711187d..b0fc28b 100644 --- a/cluster-bootstrap-cli/internal/k8s/client.go +++ b/cluster-bootstrap-cli/internal/k8s/client.go @@ -18,18 +18,7 @@ type Client struct { // If kubeconfig is empty, it uses the default loading rules. // If context is empty, it uses the current context. func NewClient(kubeconfig, context string) (*Client, error) { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - if kubeconfig != "" { - loadingRules.ExplicitPath = kubeconfig - } - - configOverrides := &clientcmd.ConfigOverrides{} - if context != "" { - configOverrides.CurrentContext = context - } - - config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - loadingRules, configOverrides).ClientConfig() + config, err := clientConfig(kubeconfig, context).ClientConfig() if err != nil { return nil, wrapKubeconfigError(err, kubeconfig, context) } @@ -50,6 +39,40 @@ func NewClient(kubeconfig, context string) (*Client, error) { }, nil } +// clientConfig builds the kubeconfig loader shared by NewClient and ResolveContext, +// so both resolve the same context for the same arguments. +func clientConfig(kubeconfig, context string) clientcmd.ClientConfig { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfig != "" { + loadingRules.ExplicitPath = kubeconfig + } + + configOverrides := &clientcmd.ConfigOverrides{} + if context != "" { + configOverrides.CurrentContext = context + } + + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) +} + +// ResolveContext returns the kubeconfig context that NewClient would connect to +// for the same arguments: the explicit override when given, otherwise the +// current context of the resolved kubeconfig. +func ResolveContext(kubeconfig, context string) (string, error) { + if context != "" { + return context, nil + } + + raw, err := clientConfig(kubeconfig, context).RawConfig() + if err != nil { + return "", wrapKubeconfigError(err, kubeconfig, context) + } + if raw.CurrentContext == "" { + return "", fmt.Errorf("no current context set in kubeconfig\n hint: select a context with: kubectl config use-context \n tip: or pass --context explicitly") + } + return raw.CurrentContext, nil +} + // wrapKubeconfigError enhances error messages for kubeconfig issues. func wrapKubeconfigError(err error, kubeconfig, context string) error { if kubeconfig != "" { diff --git a/cluster-bootstrap-cli/internal/k8s/client_mock.go b/cluster-bootstrap-cli/internal/k8s/client_mock.go index 4ab4fc7..cb8dbef 100644 --- a/cluster-bootstrap-cli/internal/k8s/client_mock.go +++ b/cluster-bootstrap-cli/internal/k8s/client_mock.go @@ -23,6 +23,7 @@ type MockClient struct { CreateGitCryptKeyErr error CreateSopsAgeKeyErr error ApplyAppOfAppsErr error + GetAppOfAppsErr error EnsureNamespaceForbidden bool CreateSecretForbidden bool } @@ -154,6 +155,18 @@ func (m *MockClient) CreateSopsAgeKeySecret(ctx context.Context, keyData []byte) return created, nil } +// GetAppOfApps returns the stored App of Apps Application, or nil when absent. +func (m *MockClient) GetAppOfApps(ctx context.Context) (*unstructured.Unstructured, error) { + if m.GetAppOfAppsErr != nil { + return nil, m.GetAppOfAppsErr + } + app, exists := m.Applications[AppOfAppsName] + if !exists { + return nil, nil + } + return app, nil +} + // ApplyAppOfApps simulates Application CR creation. func (m *MockClient) ApplyAppOfApps(ctx context.Context, repoURL, targetRevision, env, appPath string, enableCilium, dryRun bool) (string, bool, error) { if m.ApplyAppOfAppsErr != nil { @@ -167,12 +180,12 @@ func (m *MockClient) ApplyAppOfApps(ctx context.Context, repoURL, targetRevision // Check if application already exists to determine created vs updated created := true - if _, exists := m.Applications["app-of-apps"]; exists { + if _, exists := m.Applications[AppOfAppsName]; exists { created = false } if !dryRun { - m.Applications["app-of-apps"] = app + m.Applications[AppOfAppsName] = app } return "", created, nil @@ -198,5 +211,6 @@ type ClientInterface interface { CreateRepoSSHSecret(ctx context.Context, repoURL, sshPrivateKey string, dryRun bool) (*corev1.Secret, bool, error) CreateGitCryptKeySecret(ctx context.Context, keyData []byte) (bool, error) CreateSopsAgeKeySecret(ctx context.Context, keyData []byte) (bool, error) + GetAppOfApps(ctx context.Context) (*unstructured.Unstructured, error) ApplyAppOfApps(ctx context.Context, repoURL, targetRevision, env, appPath string, enableCilium, dryRun bool) (string, bool, error) } diff --git a/docs/cli/bootstrap.md b/docs/cli/bootstrap.md index 666911a..d8915be 100644 --- a/docs/cli/bootstrap.md +++ b/docs/cli/bootstrap.md @@ -13,18 +13,54 @@ cluster-bootstrap-cli bootstrap dev ## What it does 1. Loads secrets — decrypts via SOPS (default) or reads plaintext git-crypt files -2. Creates the `argocd` namespace -3. Creates the `repo-ssh-key` Secret with Git SSH credentials -4. Optionally creates `git-crypt-key` when `--gitcrypt-key-file` is provided, or `sops-age-key` when `--store-sops-age-key` is set -5. When `--enable-cilium` is set, installs or upgrades Cilium and waits for Helm's workload readiness checks to pass -6. Installs ArgoCD via Helm (from `components/argocd/`) -7. Deploys the App of Apps root Application, enabling its repository-backed Cilium Application when requested -8. Optionally waits for cluster components to be ready (if `--wait-for-health` provided) -9. Prints ArgoCD access instructions +2. Checks the target cluster for an existing App of Apps and stops unless `--force` is set +3. Announces the target Kubernetes context and counts down for 10 seconds so the run can be aborted +4. Creates the `argocd` namespace +5. Creates the `repo-ssh-key` Secret with Git SSH credentials +6. Optionally creates `git-crypt-key` when `--gitcrypt-key-file` is provided, or `sops-age-key` when `--store-sops-age-key` is set +7. When `--enable-cilium` is set, installs or upgrades Cilium and waits for Helm's workload readiness checks to pass +8. Installs ArgoCD via Helm (from `components/argocd/`) +9. Deploys the App of Apps root Application, enabling its repository-backed Cilium Application when requested +10. Optionally waits for cluster components to be ready (if `--wait-for-health` provided) +11. Prints ArgoCD access instructions + +## Safeguards + +Before anything is written to the cluster, bootstrap performs two checks. + +### Existing App of Apps + +An `app-of-apps` Application in the `argocd` namespace means the cluster has already been bootstrapped. Bootstrap stops and reports what it found, without changing the cluster: + +``` + Existing App of Apps: + Application: app-of-apps (namespace argocd) + Repository: git@github.com:acme/repo.git + Revision: main + Path: apps + Sync/Health: Synced / Healthy + +ERROR cluster already bootstrapped: App of Apps "app-of-apps" exists in namespace argocd on context kind-dev + hint: inspect the existing installation with: cluster-bootstrap-cli info + tip: re-run with --force to overwrite the existing App of Apps +``` + +Pass `--force` to re-run against an already bootstrapped cluster. The existing App of Apps is still reported, then overwritten with the current configuration. + +### Target context countdown + +Bootstrap prints the Kubernetes context it is about to modify and waits 10 seconds so a run against the wrong cluster can be aborted with `Ctrl+C`: + +``` +⚠ Bootstrap will modify the cluster on Kubernetes context: kind-dev + Starting in 7s... press Ctrl+C to abort +``` + +The context shown is the resolved one: the `--context` override when given, otherwise the current context of the kubeconfig in use. The countdown is skipped with `--yes`, and automatically when stdout is not a terminal, so CI pipelines are not delayed. ## Idempotent Behavior -The bootstrap command is **fully idempotent** and can be safely run multiple times without causing errors or conflicts: +Bootstrap is idempotent, so re-running it with `--force` after configuration changes or secret updates converges the cluster instead of failing: - **Namespace**: Verified and created only if it doesn't exist - **Secrets**: Automatically updated if they already exist, created otherwise @@ -42,7 +78,7 @@ When running the command multiple times, you'll see clear feedback indicating wh ✓ App of Apps updated successfully ``` -This makes bootstrap safe to re-run after configuration changes, secret updates, or as part of GitOps workflows. +Without `--force`, the first run is the only one that reaches these steps — the safeguard above stops later runs. ## Flags @@ -64,6 +100,8 @@ This makes bootstrap safe to re-run after configuration changes, secret updates, | `--health-timeout` | `180` | Timeout in seconds for health checks (default 180 = 3 minutes) | | `--report-format` | `summary` | Report format: `summary`, `json`, or `none` | | `--report-output` | — | Write JSON report to file | +| `--force` | `false` | Bootstrap even if the cluster already has an App of Apps, overwriting it. Without this flag, bootstrap stops on an already bootstrapped cluster | +| `--yes`, `-y` | `false` | Skip the 10 second countdown before the cluster is modified. Already skipped when stdout is not a terminal | When `--enable-cilium` and `--skip-argocd-install` are combined, the CLI waits for the existing `argocd-server` deployment before applying the App of Apps with Cilium enabled. @@ -93,6 +131,9 @@ cluster-bootstrap-cli bootstrap dev \ # Dry run to a file cluster-bootstrap-cli bootstrap dev --dry-run --dry-run-output /tmp/bootstrap.json +# Re-run against an already bootstrapped cluster, without the countdown +cluster-bootstrap-cli bootstrap dev --force --yes + # Repo content in a subdirectory # First, update apps/values.yaml to set repo.basePath: "k8s" diff --git a/docs/guides/subfolder-setup.md b/docs/guides/subfolder-setup.md index 21b1bf4..afc7e7e 100644 --- a/docs/guides/subfolder-setup.md +++ b/docs/guides/subfolder-setup.md @@ -293,8 +293,8 @@ The app-of-apps needs to be refreshed/synced: kubectl patch application app-of-apps -n argocd --type merge \ -p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"hard"}}}' -# Option 2: Re-run bootstrap (idempotent) -./cluster-bootstrap-cli/cluster-bootstrap-cli --base-dir ./k8s bootstrap dev --app-path k8s/apps +# Option 2: Re-run bootstrap (--force is required once the cluster has an App of Apps) +./cluster-bootstrap-cli/cluster-bootstrap-cli --base-dir ./k8s bootstrap dev --app-path k8s/apps --force # Option 3: Use ArgoCD UI # Navigate to app-of-apps → Click "Refresh" → Select "Hard Refresh" diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 77a3487..a7501c9 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -213,6 +213,27 @@ Common issues and solutions when using the cluster-bootstrap CLI. ## ArgoCD & Application +### Cluster already bootstrapped + +**Error:** `cluster already bootstrapped: App of Apps "app-of-apps" exists in namespace argocd on context ` + +The cluster already has an App of Apps root Application, so bootstrap stopped without changing anything. Its repository, revision, path and sync status are printed above the error. + +**Solution:** +1. Confirm you targeted the intended cluster — the context is named in the error: + ```bash + kubectl config current-context + ``` +2. Inspect the existing installation: + ```bash + ./cluster-bootstrap-cli/cluster-bootstrap-cli info dev + kubectl -n argocd get application app-of-apps -o yaml + ``` +3. If re-bootstrapping is intended, overwrite the existing App of Apps: + ```bash + ./cluster-bootstrap-cli/cluster-bootstrap-cli bootstrap dev --force + ``` + ### Application CRD not found **Error:** `ArgoCD CRD not found: ApplicationCRD not found` From 47304ff9e51d7ef31ff436e9f9bbf9cb38d3301e Mon Sep 17 00:00:00 2001 From: Filipe Galo Date: Mon, 31 Aug 2026 17:01:22 +0100 Subject: [PATCH 2/2] chore: check Fprint return values in the bootstrap guard errcheck does not exclude fmt.Fprint* calls that write to a generic io.Writer, only those targeting os.Stdout, os.Stderr or a bytes.Buffer. The guard's output helpers take an io.Writer so they can be tested against a buffer, so discard the return values explicitly. golangci-lint's default max-same-issues of 3 meant CI reported only 3 of the 9 Fprintf call sites; all of them are handled here. Co-Authored-By: Claude Opus 5 (1M context) --- cluster-bootstrap-cli/cmd/bootstrap_guard.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cluster-bootstrap-cli/cmd/bootstrap_guard.go b/cluster-bootstrap-cli/cmd/bootstrap_guard.go index 4270f9e..d67e2db 100644 --- a/cluster-bootstrap-cli/cmd/bootstrap_guard.go +++ b/cluster-bootstrap-cli/cmd/bootstrap_guard.go @@ -26,7 +26,7 @@ type appOfAppsGetter interface { // announceTargetContext tells the operator which cluster is about to be modified. // When interactive, it counts down so the bootstrap can still be aborted with Ctrl+C. func announceTargetContext(out io.Writer, kubeContext string, seconds int, interactive bool) { - fmt.Fprintf(out, "\n%s Bootstrap will modify the cluster on Kubernetes context: %s\n", + _, _ = fmt.Fprintf(out, "\n%s Bootstrap will modify the cluster on Kubernetes context: %s\n", warningColor("⚠ "), stepColor(kubeContext)) if !interactive || seconds <= 0 { @@ -34,10 +34,10 @@ func announceTargetContext(out io.Writer, kubeContext string, seconds int, inter } for remaining := seconds; remaining > 0; remaining-- { - fmt.Fprintf(out, "\r Starting in %2ds... press Ctrl+C to abort", remaining) + _, _ = fmt.Fprintf(out, "\r Starting in %2ds... press Ctrl+C to abort", remaining) time.Sleep(countdownInterval) } - fmt.Fprintf(out, "\r Starting now... \n") + _, _ = fmt.Fprintf(out, "\r Starting now... \n") } // guardExistingAppOfApps refuses to bootstrap a cluster that already has an App @@ -67,22 +67,22 @@ func guardExistingAppOfApps(ctx context.Context, client appOfAppsGetter, kubeCon } func printExistingAppOfApps(out io.Writer, app ArgoCDAppInfo) { - fmt.Fprintf(out, "\n Existing App of Apps:\n") - fmt.Fprintf(out, " Application: %s (namespace %s)\n", app.Name, app.Namespace) + _, _ = fmt.Fprintf(out, "\n Existing App of Apps:\n") + _, _ = fmt.Fprintf(out, " Application: %s (namespace %s)\n", app.Name, app.Namespace) if app.RepoURL != "" { - fmt.Fprintf(out, " Repository: %s\n", app.RepoURL) + _, _ = fmt.Fprintf(out, " Repository: %s\n", app.RepoURL) } if app.TargetRevision != "" { - fmt.Fprintf(out, " Revision: %s\n", app.TargetRevision) + _, _ = fmt.Fprintf(out, " Revision: %s\n", app.TargetRevision) } if app.Path != "" { - fmt.Fprintf(out, " Path: %s\n", app.Path) + _, _ = fmt.Fprintf(out, " Path: %s\n", app.Path) } if app.SyncStatus != "" || app.HealthStatus != "" { - fmt.Fprintf(out, " Sync/Health: %s / %s\n", + _, _ = fmt.Fprintf(out, " Sync/Health: %s / %s\n", orUnknown(app.SyncStatus), orUnknown(app.HealthStatus)) } - fmt.Fprintln(out) + _, _ = fmt.Fprintln(out) } func orUnknown(value string) string {