Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -217,7 +219,7 @@ The `apps/` chart uses a **single dynamic template** that iterates over a `compo

| Command | Description |
|---------|-------------|
| `bootstrap <env>` | Full cluster bootstrap (decrypt secrets, install ArgoCD, deploy App of Apps). Generates comprehensive reports with timing metrics and resource operations. Fully idempotent. |
| `bootstrap <env>` | 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 <env>` | Show cluster status and component information |
Expand Down
39 changes: 37 additions & 2 deletions cluster-bootstrap-cli/cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ var (
healthTimeout int
reportFormat string
reportOutput string
bootstrapForce bool
bootstrapYes bool
)

type sopsAgeKeySecretCreator interface {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -132,6 +140,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
DryRun: dryRun,
SkipArgoCDInstall: skipArgoCDInstall,
EnableCilium: enableCilium,
Force: bootstrapForce,
WaitForHealth: waitForHealth,
}

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
99 changes: 99 additions & 0 deletions cluster-bootstrap-cli/cmd/bootstrap_guard.go
Original file line number Diff line number Diff line change
@@ -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 <environment>\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
}
99 changes: 99 additions & 0 deletions cluster-bootstrap-cli/cmd/bootstrap_guard_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
63 changes: 42 additions & 21 deletions cluster-bootstrap-cli/cmd/bootstrap_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading