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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/cmd/cluster_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 89 additions & 36 deletions cli/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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 ───────────────────────────────────────────────
Expand All @@ -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)

Expand All @@ -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).
Expand Down
188 changes: 188 additions & 0 deletions cli/cmd/deploy_adminless.go
Original file line number Diff line number Diff line change
@@ -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 = "<your-namespace>"
}
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 <ns> --connect-ns <ns> --db-ns <ns> --app-ns <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 "))
}
Loading
Loading