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..3348dadf 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,15 @@ 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 namespaces you have, + # on a Strimzi operator a cluster admin installed + 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.`, RunE: runDeploy, } @@ -119,6 +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 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) } @@ -156,12 +166,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(), resolveNamespaces()); 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 +213,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 +270,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 +279,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..b621fa04 --- /dev/null +++ b/cli/cmd/deploy_adminless.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + + "github.com/spf13/cobra" +) + +// The admin-less profile: what kates deploy installs for an account that is +// 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 namespaces. + +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) + } + } + 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 + } + } + + 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 + } + } + 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 namespaces you already have, one per component: + + kates deploy --adminless --kafka-ns --connect-ns --db-ns --app-ns + +or all in one: + + 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 every admin-less component creates in its namespace. +var adminlessCoreGrants = []string{ + "deployments.apps", + "statefulsets.apps", + "services", + "secrets", + "configmaps", + "serviceaccounts", + "networkpolicies.networking.k8s.io", + "roles.rbac.authorization.k8s.io", + "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, 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`, ns.kafka) + } + + grants := adminlessGrants(ns) + var missing []string + 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 what the admin-less stack needs (or the namespace does not exist): + %s + +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 new file mode 100644 index 00000000..87283ce4 --- /dev/null +++ b/cli/cmd/deploy_adminless_test.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "context" + "slices" + "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...) + } +} + +// 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() { + deployKafkaNS, deployConnectNS, deployDbNS, deployAppNS, deployKafkaUINS, deployMM2NS = kafka, connect, db, app, ui, mm2 + }) + deployKafkaNS, deployConnectNS, deployDbNS, deployAppNS = "team-kafka", "team-connect", "team-db", "team-kates" + deployKafkaUINS, deployMM2NS = "team-kafka", "team-mm2" +} + +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 } + + 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(), ns); err != nil { + t.Errorf("everything granted: %v", err) + } + + runExecOutputFn = adminlessClusterRead("kafkamirrormaker2s") + 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(), 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) + 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..8a81ce4f 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 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. **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..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. `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 namespaces instead. The work splits between three accounts, and only the team's is used day to day: @@ -936,6 +936,32 @@ 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 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 --topology single --namespace team +``` + +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 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`. + ### Limits The namespace scope trades these away: