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..d67e2db --- /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`