From feeee8ddba4f6d653212f945809c6f73155f8943 Mon Sep 17 00:00:00 2001 From: Said Boudjelda Date: Thu, 22 Oct 2026 20:28:34 +0200 Subject: [PATCH 1/2] feat(cli): kates deploy detects cluster admin and offers an admin-less profile kates deploy asks `kubectl auth can-i '*' '*' --all-namespaces` before anything else. A cluster admin gets the full profile, as before. Any other account is told why and, on a terminal, offered the admin-less profile; with --yes or without a terminal it stops and prints the command. `kates deploy --adminless` installs Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, Apicurio, Kafka UI and Kates (rbac.scope=namespace) into one existing namespace (--namespace, default the kube context's). It skips every cluster-scoped step: Strimzi, cert-manager, Kyverno, monitoring, Litmus, node introspection and namespace creation, and refuses the flags that ask for them. Before installing it checks that Strimzi's resources exist and that the account may create what the stack needs in the namespace, naming what is missing. The Kafka version follows the repository's Strimzi pin, since the installed operator can't be read without cluster-wide reads, and the DNS domain is taken to be cluster.local. --- cli/cmd/cluster_gate_test.go | 2 + cli/cmd/deploy.go | 124 +++++++++++++++++------- cli/cmd/deploy_adminless.go | 159 ++++++++++++++++++++++++++++++ cli/cmd/deploy_adminless_test.go | 160 +++++++++++++++++++++++++++++++ cli/cmd/deploy_components.go | 4 + cli/cmd/deploy_test.go | 3 + docs/book/10-cli-reference.md | 3 + docs/book/12-deployment.md | 19 +++- 8 files changed, 437 insertions(+), 37 deletions(-) create mode 100644 cli/cmd/deploy_adminless.go create mode 100644 cli/cmd/deploy_adminless_test.go diff --git a/cli/cmd/cluster_gate_test.go b/cli/cmd/cluster_gate_test.go index 66db8faf..d5b398fa 100644 --- a/cli/cmd/cluster_gate_test.go +++ b/cli/cmd/cluster_gate_test.go @@ -265,7 +265,9 @@ func setDeployFlags(t *testing.T, set func()) { kyverno, strimzi, connect := deployWithKyverno, deployWithStrimzi, deployWithKafkaConnect ui, mm2, dry := deployWithKafkaUI, deployWithMirrorMaker2, deployDryRun namespaceLabels := append([]string(nil), deployNamespaceLabels...) + adminless, dbNS := deployAdminless, deployDbNS t.Cleanup(func() { + deployAdminless, deployDbNS = adminless, dbNS deployTopology, deployNamespace, deployWithSchemaRegistry = topology, namespace, registry deployWithChaos, deployWithMonitoring, deployWithCertManager = chaos, monitoring, certManager deployWithKyverno, deployWithStrimzi, deployWithKafkaConnect = kyverno, strimzi, connect diff --git a/cli/cmd/deploy.go b/cli/cmd/deploy.go index 42c4d43d..ab020958 100644 --- a/cli/cmd/deploy.go +++ b/cli/cmd/deploy.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -40,7 +41,14 @@ Examples: kates deploy --strimzi-version 1.0.1 --kafka-version 4.2.0 # One operator per Kafka namespace, so older lines can run beside the primary - kates deploy --operator-scope namespace`, + kates deploy --operator-scope namespace + + # Without cluster admin: the Kafka stack and Kates in one namespace you have, + # on a Strimzi operator a cluster admin installed + kates deploy --adminless --namespace team + +A cluster admin gets the full profile. For any other account, deploy says so +and offers the admin-less profile instead.`, RunE: runDeploy, } @@ -119,6 +127,7 @@ func init() { deployCmd.Flags().StringVar(&deployStrimziChart, "strimzi-chart", "", "Local strimzi-kafka-operator chart tarball to install from (air-gapped mirrors)") deployCmd.Flags().StringVar(&deployKafkaVersion, "kafka-version", "", "Kafka version for the primary cluster (default: the newest the operator supports; 'latest' spells the default)") deployCmd.Flags().StringVar(&deployKafkaName, "kafka-name", "krafter", "Name of the primary Kafka cluster") + deployCmd.Flags().BoolVar(&deployAdminless, "adminless", false, "Install without cluster admin: Kafka, Kafka Connect, MirrorMaker 2, Apicurio, Kafka UI and Kates in one existing namespace (--namespace, default the kube context's), on a Strimzi operator already watching it") rootCmd.AddCommand(deployCmd) } @@ -156,12 +165,41 @@ func runDeploy(cmd *cobra.Command, args []string) error { defer pinKubeContext(target)() PrintDeployBanner(target) + // ── Profile ────────────────────────────────────────────────────────── + // Full for a cluster admin. Anyone else is offered the admin-less stack + // (deploy_adminless.go) rather than walked into a refusal halfway through. + if !deployAdminless { + if admin, ok := clusterAdminFn(context.Background()); ok && !admin { + suggestion := adminlessSuggestion(target) + if deployYes || !IsInteractive() { + return errors.New(suggestion) + } + output.Warn(suggestion) + yes, err := confirmFn("Install the admin-less stack instead?") + if err != nil { + return err + } + if !yes { + return errors.New("deploy cancelled: the full profile needs cluster admin") + } + deployAdminless = true + } + } + if deployAdminless { + if err := applyAdminlessProfile(context.Background(), cmd); err != nil { + return err + } + if err := checkAdminlessPrerequisites(context.Background(), deployNamespace); err != nil { + return err + } + } + // ── Interactive Forms ──────────────────────────────────────────────── // Guarded by isInteractive: the forms open /dev/tty directly, so without a // terminal they fail with "could not open a new TTY" rather than falling // back to the flag defaults. Piped or scripted runs should just use the // defaults. - if (deployInteractive || cmd.Flags().NFlag() == 0) && IsInteractive() { + if !deployAdminless && (deployInteractive || cmd.Flags().NFlag() == 0) && IsInteractive() { if err := runInteractiveForms(); err != nil { return err } @@ -174,38 +212,47 @@ func runDeploy(cmd *cobra.Command, args []string) error { printComponentSelection() executor := defaultExecutor - PrintPhaseHeader(nextDeployPhase(), "Running Cluster Introspection (Pre-flight)") - collector := detect.NewCollector(executor) - // Introspection is not read-only on its own: it creates a namespace and a - // Secret to audit Secret creation, prober pods to measure inter-AZ - // latency, and a dns-detect pod when no running pod can be read. The - // preview uses none of that, so a dry run collects without it. - collector.ReadOnly = deployDryRun || deployNoWriteProbes - collector.KubeContext = target - collector.NamespaceLabels = namespaceLabels - if err := collector.Preflight(); err != nil { - output.Error(fmt.Sprintf("Preflight failed: %v", err)) - return err - } + var report *detect.DetectReport + if deployAdminless { + // Introspection reads nodes and kube-system, and its probes create + // namespaces and pods: nothing a namespace account may do. The charts' + // own defaults stand in for what it would have sized. + // shortcut: assumes the cluster.local DNS domain; read it from a pod in the namespace if a cluster needs another. + report = &detect.DetectReport{Context: target, Provider: "unknown"} + report.Network.ClusterDomain = "cluster.local" + } else { + PrintPhaseHeader(nextDeployPhase(), "Running Cluster Introspection (Pre-flight)") + collector := detect.NewCollector(executor) + // Introspection is not read-only on its own: it creates a namespace and a + // Secret to audit Secret creation, prober pods to measure inter-AZ + // latency, and a dns-detect pod when no running pod can be read. The + // preview uses none of that, so a dry run collects without it. + collector.ReadOnly = deployDryRun || deployNoWriteProbes + collector.KubeContext = target + collector.NamespaceLabels = namespaceLabels + if err := collector.Preflight(); err != nil { + output.Error(fmt.Sprintf("Preflight failed: %v", err)) + return err + } - // ── Kind storage bootstrap ──────────────────────────────────────────────── - // Create zone-specific StorageClasses (local-storage-alpha, etc.) BEFORE - // collector.Collect() runs. This ensures detect's matchStorageClass() finds - // them and writes the correct storageClass names into values-detected.yaml, - // so no hardcoded pool overrides are needed in values-kind.yaml. - // A dry run must not write StorageClasses into the cluster, so it skips - // this, as it skips the introspection probes that create things. - if quickDetectKind() && !deployDryRun { - PrintPhaseItem("Kind cluster detected — bootstrapping zone StorageClasses...") - if err := setupKindStorageClasses(context.Background()); err != nil { - output.Warn(fmt.Sprintf("StorageClass bootstrap warning: %v", err)) + // ── Kind storage bootstrap ──────────────────────────────────────────────── + // Create zone-specific StorageClasses (local-storage-alpha, etc.) BEFORE + // collector.Collect() runs. This ensures detect's matchStorageClass() finds + // them and writes the correct storageClass names into values-detected.yaml, + // so no hardcoded pool overrides are needed in values-kind.yaml. + // A dry run must not write StorageClasses into the cluster, so it skips + // this, as it skips the introspection probes that create things. + if quickDetectKind() && !deployDryRun { + PrintPhaseItem("Kind cluster detected — bootstrapping zone StorageClasses...") + if err := setupKindStorageClasses(context.Background()); err != nil { + output.Warn(fmt.Sprintf("StorageClass bootstrap warning: %v", err)) + } } - } - report, err := collector.Collect(context.Background()) - if err != nil { - output.Error(fmt.Sprintf("Introspection failed: %v", err)) - return err + if report, err = collector.Collect(context.Background()); err != nil { + output.Error(fmt.Sprintf("Introspection failed: %v", err)) + return err + } } // ── Versions and scope ─────────────────────────────────────────────── @@ -222,10 +269,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { vp.describe(PrintPhaseItem) primary := primaryCluster{Name: deployKafkaName, Namespace: resolveNamespaces().kafka, Version: vp.kafka} - analyzer := detect.NewAnalyzer(executor) - analyzer.Analyze(report, detect.ParsedReqs{}) - - PrintPhaseItem("Generating values-detected.yaml...") valuesFile := ".build/values-detected.yaml" os.MkdirAll(".build", 0755) @@ -235,7 +278,16 @@ func runDeploy(cmd *cobra.Command, args []string) error { } defer f.Close() - detect.RenderValuesWithReserve(report, primary.Name, 0.30, f) + // Admin-less, nothing was introspected, so the file stays empty. + if !deployAdminless { + analyzer := detect.NewAnalyzer(executor) + analyzer.Analyze(report, detect.ParsedReqs{}) + + PrintPhaseItem("Generating values-detected.yaml...") + if err := detect.RenderValuesWithReserve(report, primary.Name, 0.30, f); err != nil { + return fmt.Errorf("failed to write values file: %v", err) + } + } // Detect cluster type once — used by every Helm call below to pick the // right values overlay (values-kind.yaml vs values-generic.yaml). diff --git a/cli/cmd/deploy_adminless.go b/cli/cmd/deploy_adminless.go new file mode 100644 index 00000000..7446c3ea --- /dev/null +++ b/cli/cmd/deploy_adminless.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" +) + +// The admin-less profile: what kates deploy installs for an account that is +// not cluster-admin. Everything goes into one namespace the account already +// has — Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, Apicurio, +// Kafka UI and the Kates backend (rbac.scope=namespace) — and every step that +// needs the cluster (Strimzi, cert-manager, Kyverno, monitoring, Litmus, +// node introspection, StorageClasses, namespace creation) is left out. A +// cluster admin must already run a Strimzi operator that watches the namespace. + +var deployAdminless bool + +// clusterAdminFn says whether the account may do everything everywhere, which +// is what the full profile needs. Tests stub the kubectl read it makes. +var clusterAdminFn = isClusterAdmin + +// isClusterAdmin asks the API server. ok is false when the answer is neither +// yes nor no (the cluster did not answer), so the caller keeps the full +// profile and lets its own preflight report the real problem. +func isClusterAdmin(ctx context.Context) (admin, ok bool) { + // kubectl prints "no" and exits 1 when refused, so the error is not the answer. + out, _ := runExecOutputFn(ctx, "kubectl", "auth", "can-i", "*", "*", "--all-namespaces") + switch strings.TrimSpace(string(out)) { + case "yes": + return true, true + case "no": + return false, true + } + return false, false +} + +// The flags the profile leaves out: each installs something cluster-scoped. +var adminOnlyDeployFlags = []string{"with-strimzi", "with-cert-manager", "with-kyverno", "with-secret-manager", "with-chaos", "with-monitoring"} + +// applyAdminlessProfile sets the deploy flags to the admin-less stack. A flag +// the user set to turn on a cluster-scoped component is refused rather than +// dropped; the stack's own components follow the user's flags when set. +func applyAdminlessProfile(ctx context.Context, cmd *cobra.Command) error { + for _, name := range adminOnlyDeployFlags { + if f := cmd.Flags().Lookup(name); f != nil && f.Changed && f.Value.String() == "true" { + return fmt.Errorf("--%s needs cluster admin, so it cannot go with the admin-less profile", name) + } + } + if f := cmd.Flags().Lookup("topology"); f != nil && f.Changed && f.Value.String() != "single" { + return errors.New("the admin-less profile installs into one namespace; drop --topology or set it to single") + } + + deployAdminless = true + deployWithStrimzi, deployWithCertManager, deployWithKyverno, deployWithSecretManager = false, false, false, false + deployWithChaos, deployWithMonitoring = false, false + for name, v := range map[string]*bool{ + "with-kafka-connect": &deployWithKafkaConnect, + "with-mirror-maker2": &deployWithMirrorMaker2, + "with-kafka-ui": &deployWithKafkaUI, + } { + if !cmd.Flags().Changed(name) { + *v = true + } + } + + deployTopology = "single" + if !cmd.Flags().Changed("namespace") { + if ns := kubeContextNamespace(ctx); ns != "" { + deployNamespace = ns + } + } + // PostgreSQL has its own namespace flag even in single topology. + deployDbNS = deployNamespace + return nil +} + +// kubeContextNamespace is the namespace the kube context names — usually the +// one a team account was given. +func kubeContextNamespace(ctx context.Context) string { + out, _ := runExecOutputFn(ctx, "kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}") + ns := strings.TrimSpace(string(out)) + if ns == "default" { + return "" + } + return ns +} + +// adminlessSuggestion is what kates deploy says to an account that is not +// cluster-admin. +func adminlessSuggestion(kubeContext string) string { + ns := kubeContextNamespace(context.Background()) + if ns == "" { + ns = "" + } + return fmt.Sprintf(`This account is not cluster-admin on %s, so the full profile (Strimzi, cert-manager, +monitoring, Litmus, several namespaces) cannot be installed. + +The admin-less profile installs Kafka, Kafka Connect, MirrorMaker 2, Apicurio, Kafka UI +and the Kates backend into one namespace you already have: + + kates deploy --adminless --namespace %s + +It needs a Strimzi operator, installed by a cluster admin, that watches that namespace.`, kubeContext, ns) +} + +// The resources the admin-less stack creates in its namespace. +var adminlessRequiredGrants = []string{ + "kafkas.kafka.strimzi.io", + "kafkanodepools.kafka.strimzi.io", + "kafkatopics.kafka.strimzi.io", + "kafkausers.kafka.strimzi.io", + "kafkaconnects.kafka.strimzi.io", + "kafkaconnectors.kafka.strimzi.io", + "kafkamirrormaker2s.kafka.strimzi.io", + "deployments.apps", + "statefulsets.apps", + "services", + "secrets", + "configmaps", + "serviceaccounts", + "networkpolicies.networking.k8s.io", + "roles.rbac.authorization.k8s.io", + "rolebindings.rbac.authorization.k8s.io", +} + +// checkAdminlessPrerequisites fails before anything is installed when Strimzi +// is missing or the account may not create what the stack needs. +func checkAdminlessPrerequisites(ctx context.Context, namespace string) error { + out, _ := runExecOutputFn(ctx, "kubectl", "api-resources", "--api-group=kafka.strimzi.io", "-o", "name") + if !slices.Contains(strings.Fields(string(out)), "kafkas.kafka.strimzi.io") { + return fmt.Errorf(`no Strimzi operator is installed on this cluster, and installing it needs cluster admin. +Ask a cluster admin to install the operator watching namespace %s, for example: + + helm upgrade --install strimzi-operator charts/strimzi-operator -n strimzi-operator --create-namespace \ + --set strimzi-kafka-operator.createAggregateRoles=true`, namespace) + } + + var missing []string + for _, resource := range adminlessRequiredGrants { + answer, _ := runExecOutputFn(ctx, "kubectl", "auth", "can-i", "create", resource, "-n", namespace) + if strings.TrimSpace(string(answer)) != "yes" { + missing = append(missing, resource) + } + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf(`this account may not create these in namespace %s: + %s + +The built-in admin role covers the core ones. For Strimzi's, a cluster admin either sets +strimzi-kafka-operator.createAggregateRoles=true on the Strimzi release (it adds them to +admin and edit), or binds a ClusterRole that grants them in %s`, namespace, strings.Join(missing, "\n "), namespace) +} diff --git a/cli/cmd/deploy_adminless_test.go b/cli/cmd/deploy_adminless_test.go new file mode 100644 index 00000000..17b54545 --- /dev/null +++ b/cli/cmd/deploy_adminless_test.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/spf13/pflag" +) + +func TestIsClusterAdmin_ReadsTheAnswer(t *testing.T) { + orig := runExecOutputFn + t.Cleanup(func() { runExecOutputFn = orig }) + + for out, want := range map[string][2]bool{"yes\n": {true, true}, "no\n": {false, true}, "": {false, false}} { + runExecOutputFn = func(context.Context, string, ...string) ([]byte, error) { return []byte(out), nil } + if admin, ok := isClusterAdmin(context.Background()); admin != want[0] || ok != want[1] { + t.Errorf("can-i %q: got admin=%v ok=%v, want %v", out, admin, ok, want) + } + } +} + +// resetDeployFlagsChanged makes the deploy command's flags look unset, since +// other tests Set them and pflag never forgets. +func resetDeployFlagsChanged(t *testing.T) { + t.Helper() + deployCmd.Flags().VisitAll(func(f *pflag.Flag) { f.Changed = false }) +} + +func TestRunDeploy_NonAdminIsToldAboutTheAdminlessProfile(t *testing.T) { + origAdmin, origHelm := clusterAdminFn, runHelmFn + t.Cleanup(func() { clusterAdminFn, runHelmFn = origAdmin, origHelm }) + setDeployFlags(t, func() { deployAdminless = false }) + resetDeployFlagsChanged(t) + + clusterAdminFn = func(context.Context) (bool, bool) { return false, true } + helmCalls := 0 + runHelmFn = func(context.Context, ...string) error { helmCalls++; return nil } + + err := runDeploy(deployCmd, nil) + if err == nil || !strings.Contains(err.Error(), "kates deploy --adminless") { + t.Fatalf("want the admin-less suggestion, got %v", err) + } + if helmCalls != 0 { + t.Errorf("%d helm calls before the profile was settled", helmCalls) + } +} + +// adminlessClusterRead is a team account on a cluster with Strimzi: every +// create in its namespace allowed, nothing cluster-wide. +func adminlessClusterRead(refuse string) func(context.Context, string, ...string) ([]byte, error) { + return func(ctx context.Context, name string, args ...string) ([]byte, error) { + joined := name + " " + strings.Join(args, " ") + switch { + case strings.Contains(joined, "auth can-i * *"): + return []byte("no\n"), nil + case strings.Contains(joined, "auth can-i create"): + if refuse != "" && strings.Contains(joined, refuse) { + return []byte("no\n"), nil + } + return []byte("yes\n"), nil + case strings.Contains(joined, "api-resources --api-group=kafka.strimzi.io"): + return []byte("kafkas.kafka.strimzi.io\nkafkanodepools.kafka.strimzi.io\n"), nil + case strings.Contains(joined, "config view --minify"): + return []byte("team"), nil + } + return stubClusterRead(ctx, name, args...) + } +} + +func TestRunDeploy_AdminlessInstallsTheStackInOneNamespace(t *testing.T) { + origOutput, origCombined, origHelm, origExec := runExecOutputFn, runExecCombinedFn, runHelmFn, runExecFn + origStdin, origDeployed := runExecStdinFn, isHelmReleaseDeployedFn + t.Cleanup(func() { + runExecOutputFn, runExecCombinedFn, runHelmFn, runExecFn = origOutput, origCombined, origHelm, origExec + runExecStdinFn, isHelmReleaseDeployedFn = origStdin, origDeployed + }) + setDeployFlags(t, func() { + deployAdminless, deployWithSchemaRegistry, deployDryRun = true, "apicurio", false + deployWithChaos, deployWithMonitoring, deployWithCertManager, deployWithStrimzi = true, true, true, true + deployWithKafkaConnect, deployWithMirrorMaker2, deployWithKafkaUI = false, false, false + }) + resetDeployFlagsChanged(t) + t.Chdir(t.TempDir()) + + runExecOutputFn, runExecCombinedFn = adminlessClusterRead(""), adminlessClusterRead("") + var mu sync.Mutex + var installs []string + runHelmFn = func(_ context.Context, args ...string) error { + if len(args) > 2 && args[0] == "upgrade" { + mu.Lock() + installs = append(installs, strings.Join(args, " ")) + mu.Unlock() + } + return nil + } + runExecFn = func(context.Context, string, ...string) error { return nil } + runExecStdinFn = func(context.Context, string, []string, string) error { return nil } + isHelmReleaseDeployedFn = func(context.Context, string, string) bool { return false } + + if err := runDeploy(deployCmd, nil); err != nil { + t.Fatalf("runDeploy: %v", err) + } + + installed := map[string]string{} + for _, c := range installs { + installed[strings.Fields(c)[2]] = c + } + for _, release := range []string{"krafter", "postgresql", "connect-cluster", "mm2", "apicurio", "kafka-ui", "kates"} { + c, ok := installed[release] + if !ok { + t.Errorf("release %s not installed; installs: %v", release, installs) + continue + } + if !strings.Contains(c, "-n team") { + t.Errorf("release %s not in namespace team: %s", release, c) + } + } + for _, release := range []string{"strimzi-operator", "cert-manager", "kyverno", "monitoring", "chaos"} { + if _, ok := installed[release]; ok { + t.Errorf("cluster-scoped release %s installed admin-less", release) + } + } + if !strings.Contains(installed["kates"], "--set rbac.scope=namespace") { + t.Errorf("kates without rbac.scope=namespace: %s", installed["kates"]) + } +} + +func TestCheckAdminlessPrerequisites(t *testing.T) { + orig := runExecOutputFn + t.Cleanup(func() { runExecOutputFn = orig }) + + runExecOutputFn = adminlessClusterRead("") + if err := checkAdminlessPrerequisites(context.Background(), "team"); err != nil { + t.Errorf("everything granted: %v", err) + } + + runExecOutputFn = adminlessClusterRead("kafkamirrormaker2s") + err := checkAdminlessPrerequisites(context.Background(), "team") + if err == nil || !strings.Contains(err.Error(), "kafkamirrormaker2s.kafka.strimzi.io") || strings.Contains(err.Error(), "kafkas.kafka") { + t.Errorf("want only the refused grant named, got %v", err) + } + + runExecOutputFn = func(context.Context, string, ...string) ([]byte, error) { return nil, nil } + if err := checkAdminlessPrerequisites(context.Background(), "team"); err == nil || !strings.Contains(err.Error(), "no Strimzi operator is installed") { + t.Errorf("want the missing operator named, got %v", err) + } +} + +func TestApplyAdminlessProfile_RefusesClusterScopedFlags(t *testing.T) { + setDeployFlags(t, func() {}) + resetDeployFlagsChanged(t) + t.Cleanup(func() { resetDeployFlagsChanged(t) }) + + _ = deployCmd.Flags().Set("with-chaos", "true") + if err := applyAdminlessProfile(context.Background(), deployCmd); err == nil || !strings.Contains(err.Error(), "--with-chaos") { + t.Errorf("want --with-chaos refused, got %v", err) + } +} diff --git a/cli/cmd/deploy_components.go b/cli/cmd/deploy_components.go index 3c9dfeaa..47ed8e08 100644 --- a/cli/cmd/deploy_components.go +++ b/cli/cmd/deploy_components.go @@ -1752,6 +1752,10 @@ data: katesArgs = append(katesArgs, dc.prometheusArgs()...) katesArgs = append(katesArgs, dc.cdcDatabaseAccessArgs()...) katesArgs = append(katesArgs, katesChaosArgs(ctx, chaosNS)...) + if deployAdminless { + // No ClusterRole: a Role in the namespace, chaos off. + katesArgs = append(katesArgs, "--set", "rbac.scope=namespace") + } katesArgs = append(katesArgs, "--timeout", "8m") dl.Println(" - Waiting for Kates backend pods to become ready (this may take 2-3 minutes)...") diff --git a/cli/cmd/deploy_test.go b/cli/cmd/deploy_test.go index 4a536084..12cbf112 100644 --- a/cli/cmd/deploy_test.go +++ b/cli/cmd/deploy_test.go @@ -97,6 +97,9 @@ func stubClusterRead(_ context.Context, name string, args ...string) ([]byte, er // The context the stubbed cluster gate returns: already current. case joined == "kubectl config current-context": return []byte("test-context\n"), nil + // A cluster admin: the full profile. + case strings.HasPrefix(joined, "kubectl auth can-i"): + return []byte("yes\n"), nil // Secrets are read for their base64 data field; a non-empty value ends the // wait loops immediately. case strings.Contains(joined, "get secret"): diff --git a/docs/book/10-cli-reference.md b/docs/book/10-cli-reference.md index ada3c627..121375b4 100644 --- a/docs/book/10-cli-reference.md +++ b/docs/book/10-cli-reference.md @@ -1319,6 +1319,9 @@ kates deploy --yes | `--labels-file` | Labels for the namespaces deploy uses, one `key=value` per line (default: `.labels` in the current directory, skipped when missing; a named file must exist) | | `--namespace-label` | One `key=value` label for those namespaces, repeatable; it wins over `KATES_NAMESPACE_LABELS` and the labels file | | `--no-write-probes` | Skip the introspection probes that create a namespace, a Secret or pods, as `--dry-run` does | +| `--adminless` | Install without cluster admin: Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, Apicurio, Kafka UI and Kates in one namespace that exists (`--namespace`, default the kube context's), on a Strimzi operator a cluster admin installed. [Deploying Without Cluster Admin](12-deployment.md#deploying-without-cluster-admin) has the details | + +**Profiles.** Before anything else, `kates deploy` asks the API server whether the account may do everything everywhere (`kubectl auth can-i '*' '*' --all-namespaces`). A cluster admin gets the full profile. For any other account it explains why and, on a terminal, offers the admin-less profile; with `--yes` or without a terminal it stops and prints the `--adminless` command. **Namespace labels.** `KATES_NAMESPACE_LABELS` takes comma-separated `key=value` entries, which win over the labels file and lose to `--namespace-label`. `kates deploy` checks all three before pre-flight, and `--dry-run` lists the merged labels with their sources. [Namespace Labels](12-deployment.md#namespace-labels) gives the rules and what happens to a namespace that exists. diff --git a/docs/book/12-deployment.md b/docs/book/12-deployment.md index d1d6ecd5..aa4c07e9 100644 --- a/docs/book/12-deployment.md +++ b/docs/book/12-deployment.md @@ -863,7 +863,7 @@ Where your account may not create namespaces at all, [Running Without Cluster Ad ## Running Without Cluster Admin -On a shared cluster, a team usually gets a namespace and the built-in `edit` role in it, not cluster-admin. That is enough to run performance tests, INTEGRITY tests and [conformity checks](appendix-a-glossary.md#gl-conformity-check) with Kates, once an admin has installed [Strimzi](appendix-a-glossary.md#gl-strimzi) and the Kafka cluster. Performance and INTEGRITY runs make no Kubernetes API call: the [Kates API](appendix-a-glossary.md#gl-kates-api) talks only to Kafka. Two things need care, and the `kates` chart's `rbac.scope=namespace` handles both. The default install creates a ClusterRole, which `edit` can't do, and a conformity check reads the Kafka namespace, where the team has no rights. `kates deploy` stays an admin's tool, because it installs Strimzi, cert-manager, monitoring and Litmus. +On a shared cluster, a team usually gets a namespace and the built-in `edit` role in it, not cluster-admin. That is enough to run performance tests, INTEGRITY tests and [conformity checks](appendix-a-glossary.md#gl-conformity-check) with Kates, once an admin has installed [Strimzi](appendix-a-glossary.md#gl-strimzi) and the Kafka cluster. Performance and INTEGRITY runs make no Kubernetes API call: the [Kates API](appendix-a-glossary.md#gl-kates-api) talks only to Kafka. Two things need care, and the `kates` chart's `rbac.scope=namespace` handles both. The default install creates a ClusterRole, which `edit` can't do, and a conformity check reads the Kafka namespace, where the team has no rights. The full `kates deploy` stays an admin's tool, because it installs Strimzi, cert-manager, monitoring and Litmus; its [admin-less profile](#deploying-without-cluster-admin) installs a whole Kafka stack into the team's namespace instead. The work splits between three accounts, and only the team's is used day to day: @@ -936,6 +936,23 @@ kates conformity check -f team-reference.yaml On [`panda`](appendix-a-glossary.md#gl-panda), the lab's three-node Kind cluster, both runs end `DONE` and the INTEGRITY verdict is PASS. A reference whose `spec` is `brokers: {count: {min: 3}, perNode: {max: 1}, zones: {min: 3}}` passes there and exits 0. Its `-o json` report gives `brokers.zones` the detail `alpha: 1, gamma: 1, sigma: 1; zones from the EndpointSlices of kafka/krafter-kafka-brokers`, since the Kates API may not list nodes. +### Deploying Without Cluster Admin + +Where the team should run its own Kafka rather than share one, `kates deploy --adminless` installs the stack into one namespace the account already has: a Kafka cluster, Kafka Connect with its PostgreSQL, MirrorMaker 2 as a loopback mirror, Apicurio, Kafka UI, and the Kates API with `rbac.scope=namespace`. `kates deploy` offers it on its own when the account isn't cluster-admin. + +```bash +kates deploy --adminless --namespace team +``` + +Without `--namespace`, it uses the namespace the kube context names. It installs nothing cluster-scoped and skips the steps that need the cluster: Strimzi, cert-manager, Kyverno, monitoring, Litmus, node introspection and namespace creation. `--with-chaos`, `--with-monitoring` and the other cluster-scoped toggles are refused with it. + +Before installing, it checks two things and stops with what is missing: + +- A Strimzi operator is installed (the `kafka.strimzi.io` resources exist). A cluster admin installs it once, watching the team's namespace. +- The account may create everything the stack needs in the namespace: Strimzi's resources, Deployments, StatefulSets, Services, Secrets, ConfigMaps, ServiceAccounts, NetworkPolicies, Roles and RoleBindings. The built-in `admin` role covers all but Strimzi's. A cluster admin adds those by setting `strimzi-kafka-operator.createAggregateRoles=true` on the Strimzi release, which adds them to `admin` and `edit`. + +The operator's version can't be read without cluster-wide reads, so the Kafka version follows the repository's Strimzi pin. Where the admin's operator is another release, pass `--strimzi-version` to match it. The DNS domain is taken to be `cluster.local`. + ### Limits The namespace scope trades these away: From 954a16c755db82b830e4c58a1c145cc3f2892beb Mon Sep 17 00:00:00 2001 From: Said Boudjelda Date: Thu, 22 Oct 2026 20:31:19 +0200 Subject: [PATCH 2/2] feat(cli): the admin-less profile spreads the stack over several namespaces `kates deploy --adminless` no longer forces --topology single. It follows --topology and the --*-ns flags as a full deploy does, so by default each component goes into its own namespace (Kafka, Connect, PostgreSQL, Kates, Kafka UI, MirrorMaker 2), all of which must exist. --topology single still puts everything in --namespace, or the kube context's namespace. The prerequisite check now asks, per namespace, for what is created there: the core resources in every one, and Strimzi's where each is made (the Kafka cluster, topics and users in the Kafka namespace, Connect's in the Connect namespace, MirrorMaker 2's in its own). A refusal names its namespace. --- cli/cmd/deploy.go | 9 +- cli/cmd/deploy_adminless.go | 103 ++++++++++++------- cli/cmd/deploy_adminless_test.go | 170 +++++++++++++++++++++---------- docs/book/10-cli-reference.md | 2 +- docs/book/12-deployment.md | 21 ++-- 5 files changed, 201 insertions(+), 104 deletions(-) diff --git a/cli/cmd/deploy.go b/cli/cmd/deploy.go index ab020958..3348dadf 100644 --- a/cli/cmd/deploy.go +++ b/cli/cmd/deploy.go @@ -43,9 +43,10 @@ Examples: # One operator per Kafka namespace, so older lines can run beside the primary kates deploy --operator-scope namespace - # Without cluster admin: the Kafka stack and Kates in one namespace you have, + # Without cluster admin: the Kafka stack and Kates in namespaces you have, # on a Strimzi operator a cluster admin installed - kates deploy --adminless --namespace team + kates deploy --adminless --kafka-ns team-kafka --connect-ns team-connect --db-ns team-db --app-ns team-kates + kates deploy --adminless --topology single --namespace team A cluster admin gets the full profile. For any other account, deploy says so and offers the admin-less profile instead.`, @@ -127,7 +128,7 @@ func init() { deployCmd.Flags().StringVar(&deployStrimziChart, "strimzi-chart", "", "Local strimzi-kafka-operator chart tarball to install from (air-gapped mirrors)") deployCmd.Flags().StringVar(&deployKafkaVersion, "kafka-version", "", "Kafka version for the primary cluster (default: the newest the operator supports; 'latest' spells the default)") deployCmd.Flags().StringVar(&deployKafkaName, "kafka-name", "krafter", "Name of the primary Kafka cluster") - deployCmd.Flags().BoolVar(&deployAdminless, "adminless", false, "Install without cluster admin: Kafka, Kafka Connect, MirrorMaker 2, Apicurio, Kafka UI and Kates in one existing namespace (--namespace, default the kube context's), on a Strimzi operator already watching it") + deployCmd.Flags().BoolVar(&deployAdminless, "adminless", false, "Install without cluster admin: Kafka, Kafka Connect, MirrorMaker 2, Apicurio, Kafka UI and Kates into existing namespaces placed by --topology and the --*-ns flags (single: --namespace, default the kube context's), on a Strimzi operator already watching them") rootCmd.AddCommand(deployCmd) } @@ -189,7 +190,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { if err := applyAdminlessProfile(context.Background(), cmd); err != nil { return err } - if err := checkAdminlessPrerequisites(context.Background(), deployNamespace); err != nil { + if err := checkAdminlessPrerequisites(context.Background(), resolveNamespaces()); err != nil { return err } } diff --git a/cli/cmd/deploy_adminless.go b/cli/cmd/deploy_adminless.go index 7446c3ea..b621fa04 100644 --- a/cli/cmd/deploy_adminless.go +++ b/cli/cmd/deploy_adminless.go @@ -2,8 +2,8 @@ package cmd import ( "context" - "errors" "fmt" + "maps" "slices" "strings" @@ -11,12 +11,13 @@ import ( ) // The admin-less profile: what kates deploy installs for an account that is -// not cluster-admin. Everything goes into one namespace the account already -// has — Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, Apicurio, -// Kafka UI and the Kates backend (rbac.scope=namespace) — and every step that +// not cluster-admin. Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, +// Apicurio, Kafka UI and the Kates backend (rbac.scope=namespace) go into +// namespaces the account already has, as --topology and the --*-ns flags place +// them — one per component by default, or one for all — and every step that // needs the cluster (Strimzi, cert-manager, Kyverno, monitoring, Litmus, // node introspection, StorageClasses, namespace creation) is left out. A -// cluster admin must already run a Strimzi operator that watches the namespace. +// cluster admin must already run a Strimzi operator that watches the namespaces. var deployAdminless bool @@ -51,10 +52,6 @@ func applyAdminlessProfile(ctx context.Context, cmd *cobra.Command) error { return fmt.Errorf("--%s needs cluster admin, so it cannot go with the admin-less profile", name) } } - if f := cmd.Flags().Lookup("topology"); f != nil && f.Changed && f.Value.String() != "single" { - return errors.New("the admin-less profile installs into one namespace; drop --topology or set it to single") - } - deployAdminless = true deployWithStrimzi, deployWithCertManager, deployWithKyverno, deployWithSecretManager = false, false, false, false deployWithChaos, deployWithMonitoring = false, false @@ -68,14 +65,17 @@ func applyAdminlessProfile(ctx context.Context, cmd *cobra.Command) error { } } - deployTopology = "single" - if !cmd.Flags().Changed("namespace") { - if ns := kubeContextNamespace(ctx); ns != "" { - deployNamespace = ns + if deployTopology == "single" { + if !cmd.Flags().Changed("namespace") { + if ns := kubeContextNamespace(ctx); ns != "" { + deployNamespace = ns + } + } + // PostgreSQL has its own namespace flag even in single topology. + if !cmd.Flags().Changed("db-ns") { + deployDbNS = deployNamespace } } - // PostgreSQL has its own namespace flag even in single topology. - deployDbNS = deployNamespace return nil } @@ -101,22 +101,20 @@ func adminlessSuggestion(kubeContext string) string { monitoring, Litmus, several namespaces) cannot be installed. The admin-less profile installs Kafka, Kafka Connect, MirrorMaker 2, Apicurio, Kafka UI -and the Kates backend into one namespace you already have: +and the Kates backend into namespaces you already have, one per component: + + kates deploy --adminless --kafka-ns --connect-ns --db-ns --app-ns - kates deploy --adminless --namespace %s +or all in one: -It needs a Strimzi operator, installed by a cluster admin, that watches that namespace.`, kubeContext, ns) + kates deploy --adminless --topology single --namespace %s + +It needs a Strimzi operator, installed by a cluster admin, that watches the Kafka, +Connect and MirrorMaker 2 namespaces.`, kubeContext, ns) } -// The resources the admin-less stack creates in its namespace. -var adminlessRequiredGrants = []string{ - "kafkas.kafka.strimzi.io", - "kafkanodepools.kafka.strimzi.io", - "kafkatopics.kafka.strimzi.io", - "kafkausers.kafka.strimzi.io", - "kafkaconnects.kafka.strimzi.io", - "kafkaconnectors.kafka.strimzi.io", - "kafkamirrormaker2s.kafka.strimzi.io", +// The resources every admin-less component creates in its namespace. +var adminlessCoreGrants = []string{ "deployments.apps", "statefulsets.apps", "services", @@ -128,32 +126,63 @@ var adminlessRequiredGrants = []string{ "rolebindings.rbac.authorization.k8s.io", } +// adminlessGrants is what the account must be able to create, per namespace: +// the core resources everywhere, and Strimzi's where each one is made. +func adminlessGrants(ns resolvedNamespaces) map[string][]string { + grants := map[string][]string{} + add := func(namespace string, resources ...string) { + grants[namespace] = append(grants[namespace], resources...) + } + for _, namespace := range selectedDeployNamespaces(&deployContext{ns: ns}) { + add(namespace, adminlessCoreGrants...) + } + add(ns.kafka, "kafkas.kafka.strimzi.io", "kafkanodepools.kafka.strimzi.io", "kafkatopics.kafka.strimzi.io", "kafkausers.kafka.strimzi.io") + if deployWithKafkaConnect { + add(ns.connect, "kafkaconnects.kafka.strimzi.io", "kafkaconnectors.kafka.strimzi.io", "kafkatopics.kafka.strimzi.io") + } + if deployWithMirrorMaker2 { + add(ns.mm2, "kafkamirrormaker2s.kafka.strimzi.io") + } + for namespace, resources := range grants { + slices.Sort(resources) + grants[namespace] = slices.Compact(resources) + } + return grants +} + // checkAdminlessPrerequisites fails before anything is installed when Strimzi // is missing or the account may not create what the stack needs. -func checkAdminlessPrerequisites(ctx context.Context, namespace string) error { +func checkAdminlessPrerequisites(ctx context.Context, ns resolvedNamespaces) error { out, _ := runExecOutputFn(ctx, "kubectl", "api-resources", "--api-group=kafka.strimzi.io", "-o", "name") if !slices.Contains(strings.Fields(string(out)), "kafkas.kafka.strimzi.io") { return fmt.Errorf(`no Strimzi operator is installed on this cluster, and installing it needs cluster admin. Ask a cluster admin to install the operator watching namespace %s, for example: helm upgrade --install strimzi-operator charts/strimzi-operator -n strimzi-operator --create-namespace \ - --set strimzi-kafka-operator.createAggregateRoles=true`, namespace) + --set strimzi-kafka-operator.createAggregateRoles=true`, ns.kafka) } + grants := adminlessGrants(ns) var missing []string - for _, resource := range adminlessRequiredGrants { - answer, _ := runExecOutputFn(ctx, "kubectl", "auth", "can-i", "create", resource, "-n", namespace) - if strings.TrimSpace(string(answer)) != "yes" { - missing = append(missing, resource) + for _, namespace := range slices.Sorted(maps.Keys(grants)) { + var refused []string + for _, resource := range grants[namespace] { + answer, _ := runExecOutputFn(ctx, "kubectl", "auth", "can-i", "create", resource, "-n", namespace) + if strings.TrimSpace(string(answer)) != "yes" { + refused = append(refused, resource) + } + } + if len(refused) > 0 { + missing = append(missing, namespace+": "+strings.Join(refused, ", ")) } } if len(missing) == 0 { return nil } - return fmt.Errorf(`this account may not create these in namespace %s: + return fmt.Errorf(`this account may not create what the admin-less stack needs (or the namespace does not exist): %s -The built-in admin role covers the core ones. For Strimzi's, a cluster admin either sets -strimzi-kafka-operator.createAggregateRoles=true on the Strimzi release (it adds them to -admin and edit), or binds a ClusterRole that grants them in %s`, namespace, strings.Join(missing, "\n "), namespace) +The built-in admin role in a namespace covers the core resources. For Strimzi's, a cluster +admin either sets strimzi-kafka-operator.createAggregateRoles=true on the Strimzi release +(it adds them to admin and edit), or binds a ClusterRole that grants them there`, strings.Join(missing, "\n ")) } diff --git a/cli/cmd/deploy_adminless_test.go b/cli/cmd/deploy_adminless_test.go index 17b54545..87283ce4 100644 --- a/cli/cmd/deploy_adminless_test.go +++ b/cli/cmd/deploy_adminless_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "slices" "strings" "sync" "testing" @@ -69,85 +70,142 @@ func adminlessClusterRead(refuse string) func(context.Context, string, ...string } } -func TestRunDeploy_AdminlessInstallsTheStackInOneNamespace(t *testing.T) { - origOutput, origCombined, origHelm, origExec := runExecOutputFn, runExecCombinedFn, runHelmFn, runExecFn - origStdin, origDeployed := runExecStdinFn, isHelmReleaseDeployedFn +// setAdminlessNamespaces points the isolated topology at a team's namespaces. +func setAdminlessNamespaces(t *testing.T) { + t.Helper() + kafka, connect, db, app, ui, mm2 := deployKafkaNS, deployConnectNS, deployDbNS, deployAppNS, deployKafkaUINS, deployMM2NS t.Cleanup(func() { - runExecOutputFn, runExecCombinedFn, runHelmFn, runExecFn = origOutput, origCombined, origHelm, origExec - runExecStdinFn, isHelmReleaseDeployedFn = origStdin, origDeployed - }) - setDeployFlags(t, func() { - deployAdminless, deployWithSchemaRegistry, deployDryRun = true, "apicurio", false - deployWithChaos, deployWithMonitoring, deployWithCertManager, deployWithStrimzi = true, true, true, true - deployWithKafkaConnect, deployWithMirrorMaker2, deployWithKafkaUI = false, false, false + deployKafkaNS, deployConnectNS, deployDbNS, deployAppNS, deployKafkaUINS, deployMM2NS = kafka, connect, db, app, ui, mm2 }) - resetDeployFlagsChanged(t) - t.Chdir(t.TempDir()) - - runExecOutputFn, runExecCombinedFn = adminlessClusterRead(""), adminlessClusterRead("") - var mu sync.Mutex - var installs []string - runHelmFn = func(_ context.Context, args ...string) error { - if len(args) > 2 && args[0] == "upgrade" { - mu.Lock() - installs = append(installs, strings.Join(args, " ")) - mu.Unlock() - } - return nil - } - runExecFn = func(context.Context, string, ...string) error { return nil } - runExecStdinFn = func(context.Context, string, []string, string) error { return nil } - isHelmReleaseDeployedFn = func(context.Context, string, string) bool { return false } + deployKafkaNS, deployConnectNS, deployDbNS, deployAppNS = "team-kafka", "team-connect", "team-db", "team-kates" + deployKafkaUINS, deployMM2NS = "team-kafka", "team-mm2" +} - if err := runDeploy(deployCmd, nil); err != nil { - t.Fatalf("runDeploy: %v", err) - } +func TestRunDeploy_AdminlessInstallsTheStack(t *testing.T) { + for _, tc := range []struct { + topology string + want map[string]string // release → namespace + }{ + {"isolated", map[string]string{ + "krafter": "team-kafka", "apicurio": "team-kafka", "kafka-ui": "team-kafka", + "postgresql": "team-db", "connect-cluster": "team-connect", "mm2": "team-mm2", "kates": "team-kates", + }}, + // One namespace, the kube context's. + {"single", map[string]string{ + "krafter": "team", "apicurio": "team", "kafka-ui": "team", + "postgresql": "team", "connect-cluster": "team", "mm2": "team", "kates": "team", + }}, + } { + t.Run(tc.topology, func(t *testing.T) { + origOutput, origCombined, origHelm, origExec := runExecOutputFn, runExecCombinedFn, runHelmFn, runExecFn + origStdin, origDeployed := runExecStdinFn, isHelmReleaseDeployedFn + t.Cleanup(func() { + runExecOutputFn, runExecCombinedFn, runHelmFn, runExecFn = origOutput, origCombined, origHelm, origExec + runExecStdinFn, isHelmReleaseDeployedFn = origStdin, origDeployed + }) + setDeployFlags(t, func() { + deployAdminless, deployWithSchemaRegistry, deployDryRun = true, "apicurio", false + deployWithChaos, deployWithMonitoring, deployWithCertManager, deployWithStrimzi = true, true, true, true + deployWithKafkaConnect, deployWithMirrorMaker2, deployWithKafkaUI = false, false, false + deployTopology = tc.topology + }) + setAdminlessNamespaces(t) + resetDeployFlagsChanged(t) + t.Chdir(t.TempDir()) + + runExecOutputFn, runExecCombinedFn = adminlessClusterRead(""), adminlessClusterRead("") + var mu sync.Mutex + var installs []string + runHelmFn = func(_ context.Context, args ...string) error { + if len(args) > 2 && args[0] == "upgrade" { + mu.Lock() + installs = append(installs, strings.Join(args, " ")) + mu.Unlock() + } + return nil + } + runExecFn = func(context.Context, string, ...string) error { return nil } + runExecStdinFn = func(context.Context, string, []string, string) error { return nil } + isHelmReleaseDeployedFn = func(context.Context, string, string) bool { return false } - installed := map[string]string{} - for _, c := range installs { - installed[strings.Fields(c)[2]] = c - } - for _, release := range []string{"krafter", "postgresql", "connect-cluster", "mm2", "apicurio", "kafka-ui", "kates"} { - c, ok := installed[release] - if !ok { - t.Errorf("release %s not installed; installs: %v", release, installs) - continue - } - if !strings.Contains(c, "-n team") { - t.Errorf("release %s not in namespace team: %s", release, c) - } - } - for _, release := range []string{"strimzi-operator", "cert-manager", "kyverno", "monitoring", "chaos"} { - if _, ok := installed[release]; ok { - t.Errorf("cluster-scoped release %s installed admin-less", release) - } - } - if !strings.Contains(installed["kates"], "--set rbac.scope=namespace") { - t.Errorf("kates without rbac.scope=namespace: %s", installed["kates"]) + if err := runDeploy(deployCmd, nil); err != nil { + t.Fatalf("runDeploy: %v", err) + } + + installed := map[string]string{} + for _, c := range installs { + installed[strings.Fields(c)[2]] = c + } + for release, namespace := range tc.want { + c, ok := installed[release] + if !ok { + t.Errorf("release %s not installed; installs: %v", release, installs) + continue + } + if !strings.Contains(c, "-n "+namespace+" ") { + t.Errorf("release %s not in namespace %s: %s", release, namespace, c) + } + } + for _, release := range []string{"strimzi-operator", "cert-manager", "kyverno", "monitoring", "chaos"} { + if _, ok := installed[release]; ok { + t.Errorf("cluster-scoped release %s installed admin-less", release) + } + } + if !strings.Contains(installed["kates"], "--set rbac.scope=namespace") { + t.Errorf("kates without rbac.scope=namespace: %s", installed["kates"]) + } + }) } } func TestCheckAdminlessPrerequisites(t *testing.T) { orig := runExecOutputFn t.Cleanup(func() { runExecOutputFn = orig }) + setDeployFlags(t, func() { + deployTopology, deployWithKafkaConnect, deployWithMirrorMaker2, deployWithKafkaUI = "isolated", true, true, true + deployWithMonitoring, deployWithChaos = false, false + }) + setAdminlessNamespaces(t) + ns := resolveNamespaces() runExecOutputFn = adminlessClusterRead("") - if err := checkAdminlessPrerequisites(context.Background(), "team"); err != nil { + if err := checkAdminlessPrerequisites(context.Background(), ns); err != nil { t.Errorf("everything granted: %v", err) } runExecOutputFn = adminlessClusterRead("kafkamirrormaker2s") - err := checkAdminlessPrerequisites(context.Background(), "team") - if err == nil || !strings.Contains(err.Error(), "kafkamirrormaker2s.kafka.strimzi.io") || strings.Contains(err.Error(), "kafkas.kafka") { - t.Errorf("want only the refused grant named, got %v", err) + err := checkAdminlessPrerequisites(context.Background(), ns) + if err == nil || !strings.Contains(err.Error(), "team-mm2: kafkamirrormaker2s.kafka.strimzi.io") || strings.Contains(err.Error(), "team-kafka") { + t.Errorf("want only the refused grant named, in its namespace, got %v", err) } runExecOutputFn = func(context.Context, string, ...string) ([]byte, error) { return nil, nil } - if err := checkAdminlessPrerequisites(context.Background(), "team"); err == nil || !strings.Contains(err.Error(), "no Strimzi operator is installed") { + if err := checkAdminlessPrerequisites(context.Background(), ns); err == nil || !strings.Contains(err.Error(), "no Strimzi operator is installed") { t.Errorf("want the missing operator named, got %v", err) } } +func TestAdminlessGrants_StrimziWhereEachIsMade(t *testing.T) { + setDeployFlags(t, func() { + deployTopology, deployWithKafkaConnect, deployWithMirrorMaker2, deployWithKafkaUI = "isolated", true, true, true + deployWithMonitoring, deployWithChaos = false, false + }) + setAdminlessNamespaces(t) + grants := adminlessGrants(resolveNamespaces()) + + for namespace, want := range map[string]string{ + "team-kafka": "kafkas.kafka.strimzi.io", "team-connect": "kafkaconnects.kafka.strimzi.io", + "team-mm2": "kafkamirrormaker2s.kafka.strimzi.io", "team-db": "statefulsets.apps", "team-kates": "roles.rbac.authorization.k8s.io", + } { + if !slices.Contains(grants[namespace], want) { + t.Errorf("%s: want %s in %v", namespace, want, grants[namespace]) + } + } + if slices.Contains(grants["team-kates"], "kafkas.kafka.strimzi.io") { + t.Errorf("team-kates asked for Kafka resources it never creates: %v", grants["team-kates"]) + } +} + func TestApplyAdminlessProfile_RefusesClusterScopedFlags(t *testing.T) { setDeployFlags(t, func() {}) resetDeployFlagsChanged(t) diff --git a/docs/book/10-cli-reference.md b/docs/book/10-cli-reference.md index 121375b4..8a81ce4f 100644 --- a/docs/book/10-cli-reference.md +++ b/docs/book/10-cli-reference.md @@ -1319,7 +1319,7 @@ kates deploy --yes | `--labels-file` | Labels for the namespaces deploy uses, one `key=value` per line (default: `.labels` in the current directory, skipped when missing; a named file must exist) | | `--namespace-label` | One `key=value` label for those namespaces, repeatable; it wins over `KATES_NAMESPACE_LABELS` and the labels file | | `--no-write-probes` | Skip the introspection probes that create a namespace, a Secret or pods, as `--dry-run` does | -| `--adminless` | Install without cluster admin: Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, Apicurio, Kafka UI and Kates in one namespace that exists (`--namespace`, default the kube context's), on a Strimzi operator a cluster admin installed. [Deploying Without Cluster Admin](12-deployment.md#deploying-without-cluster-admin) has the details | +| `--adminless` | Install without cluster admin: Kafka, Kafka Connect with its PostgreSQL, MirrorMaker 2, Apicurio, Kafka UI and Kates into namespaces that exist, placed by `--topology` and the `--*-ns` flags as in a full deploy (`--topology single` takes `--namespace`, default the kube context's), on a Strimzi operator a cluster admin installed. [Deploying Without Cluster Admin](12-deployment.md#deploying-without-cluster-admin) has the details | **Profiles.** Before anything else, `kates deploy` asks the API server whether the account may do everything everywhere (`kubectl auth can-i '*' '*' --all-namespaces`). A cluster admin gets the full profile. For any other account it explains why and, on a terminal, offers the admin-less profile; with `--yes` or without a terminal it stops and prints the `--adminless` command. diff --git a/docs/book/12-deployment.md b/docs/book/12-deployment.md index aa4c07e9..7e79729a 100644 --- a/docs/book/12-deployment.md +++ b/docs/book/12-deployment.md @@ -863,7 +863,7 @@ Where your account may not create namespaces at all, [Running Without Cluster Ad ## Running Without Cluster Admin -On a shared cluster, a team usually gets a namespace and the built-in `edit` role in it, not cluster-admin. That is enough to run performance tests, INTEGRITY tests and [conformity checks](appendix-a-glossary.md#gl-conformity-check) with Kates, once an admin has installed [Strimzi](appendix-a-glossary.md#gl-strimzi) and the Kafka cluster. Performance and INTEGRITY runs make no Kubernetes API call: the [Kates API](appendix-a-glossary.md#gl-kates-api) talks only to Kafka. Two things need care, and the `kates` chart's `rbac.scope=namespace` handles both. The default install creates a ClusterRole, which `edit` can't do, and a conformity check reads the Kafka namespace, where the team has no rights. The full `kates deploy` stays an admin's tool, because it installs Strimzi, cert-manager, monitoring and Litmus; its [admin-less profile](#deploying-without-cluster-admin) installs a whole Kafka stack into the team's namespace instead. +On a shared cluster, a team usually gets a namespace and the built-in `edit` role in it, not cluster-admin. That is enough to run performance tests, INTEGRITY tests and [conformity checks](appendix-a-glossary.md#gl-conformity-check) with Kates, once an admin has installed [Strimzi](appendix-a-glossary.md#gl-strimzi) and the Kafka cluster. Performance and INTEGRITY runs make no Kubernetes API call: the [Kates API](appendix-a-glossary.md#gl-kates-api) talks only to Kafka. Two things need care, and the `kates` chart's `rbac.scope=namespace` handles both. The default install creates a ClusterRole, which `edit` can't do, and a conformity check reads the Kafka namespace, where the team has no rights. The full `kates deploy` stays an admin's tool, because it installs Strimzi, cert-manager, monitoring and Litmus; its [admin-less profile](#deploying-without-cluster-admin) installs a whole Kafka stack into the team's namespaces instead. The work splits between three accounts, and only the team's is used day to day: @@ -938,18 +938,27 @@ On [`panda`](appendix-a-glossary.md#gl-panda), the lab's three-node Kind cluster ### Deploying Without Cluster Admin -Where the team should run its own Kafka rather than share one, `kates deploy --adminless` installs the stack into one namespace the account already has: a Kafka cluster, Kafka Connect with its PostgreSQL, MirrorMaker 2 as a loopback mirror, Apicurio, Kafka UI, and the Kates API with `rbac.scope=namespace`. `kates deploy` offers it on its own when the account isn't cluster-admin. +Where the team should run its own Kafka rather than share one, `kates deploy --adminless` installs the stack into namespaces the account already has: a Kafka cluster, Kafka Connect with its PostgreSQL, MirrorMaker 2 as a loopback mirror, Apicurio, Kafka UI, and the Kates API with `rbac.scope=namespace`. `kates deploy` offers it on its own when the account isn't cluster-admin. + +The namespaces follow `--topology` and the `--*-ns` flags, as in a full deploy. By default each component gets its own: + +```bash +kates deploy --adminless --kafka-ns team-kafka --connect-ns team-connect \ + --db-ns team-db --app-ns team-kates --ui-ns team-kafka --mm2-ns team-kafka +``` + +With `--topology single`, everything goes into `--namespace`, or the namespace the kube context names when it isn't set: ```bash -kates deploy --adminless --namespace team +kates deploy --adminless --topology single --namespace team ``` -Without `--namespace`, it uses the namespace the kube context names. It installs nothing cluster-scoped and skips the steps that need the cluster: Strimzi, cert-manager, Kyverno, monitoring, Litmus, node introspection and namespace creation. `--with-chaos`, `--with-monitoring` and the other cluster-scoped toggles are refused with it. +It creates no namespace: each must exist, and the checks below name one the account can't use. It installs nothing cluster-scoped and skips the steps that need the cluster: Strimzi, cert-manager, Kyverno, monitoring, Litmus, node introspection and namespace creation. `--with-chaos`, `--with-monitoring` and the other cluster-scoped toggles are refused with it. Before installing, it checks two things and stops with what is missing: -- A Strimzi operator is installed (the `kafka.strimzi.io` resources exist). A cluster admin installs it once, watching the team's namespace. -- The account may create everything the stack needs in the namespace: Strimzi's resources, Deployments, StatefulSets, Services, Secrets, ConfigMaps, ServiceAccounts, NetworkPolicies, Roles and RoleBindings. The built-in `admin` role covers all but Strimzi's. A cluster admin adds those by setting `strimzi-kafka-operator.createAggregateRoles=true` on the Strimzi release, which adds them to `admin` and `edit`. +- A Strimzi operator is installed (the `kafka.strimzi.io` resources exist). A cluster admin installs it once, watching the Kafka, Connect and MirrorMaker 2 namespaces. +- The account may create what the stack needs in each namespace: Strimzi's resources where each is made (the Kafka cluster, its topics and users in the Kafka namespace, Connect's in the Connect namespace, MirrorMaker 2's in its own), and everywhere Deployments, StatefulSets, Services, Secrets, ConfigMaps, ServiceAccounts, NetworkPolicies, Roles and RoleBindings. The built-in `admin` role covers all but Strimzi's. A cluster admin adds those by setting `strimzi-kafka-operator.createAggregateRoles=true` on the Strimzi release, which adds them to `admin` and `edit`. The operator's version can't be read without cluster-wide reads, so the Kafka version follows the repository's Strimzi pin. Where the admin's operator is another release, pass `--strimzi-version` to match it. The DNS domain is taken to be `cluster.local`.