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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ secrets/
.env
.env.*
!.env.example
.labels
credentials.json
service-account.json

Expand Down
27 changes: 27 additions & 0 deletions .labels.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Labels for the namespaces Kates creates or deploys into.
#
# Copy this file to .labels (git-ignored) in the directory you run kates in,
# and uncomment or add lines. kates deploy, kates auto, kates detect, kates
# kyverno apply and the kates migrate commands that create lab namespaces read
# ./.labels, or the file --labels-file names; scripts/common.sh's
# ensure_namespace reads ./.labels.
#
# Format: one key=value per line. Spaces around the key and the value are
# trimmed; blank lines and lines whose first non-space character is # are
# skipped. There are no inline comments and no quotes: a # or a quote inside
# a value is invalid. A repeated key is an error.
#
# Precedence, per key, later wins: .labels, then KATES_NAMESPACE_LABELS
# (comma-separated key=value), then each --namespace-label flag.
#
# Keys: an optional DNS-subdomain prefix and a /, then a name of 1-63
# characters [A-Za-z0-9-_.] that starts and ends alphanumeric. Values: empty,
# or 1-63 characters under the same rule.
# Reserved prefixes are refused: kubernetes.io, k8s.io, helm.sh, strimzi.io,
# kates.io and their subdomains (app.kubernetes.io/... too), except
# pod-security.kubernetes.io exactly.

# team=payments
# segment=payments
# cost-center=4711
# pod-security.kubernetes.io/enforce=restricted
14 changes: 14 additions & 0 deletions cli/cmd/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,24 @@ func init() {
autoCmd.Flags().BoolVar(&autoDryRun, "dry-run", false, "Preview the Helm installation without executing it")
autoCmd.Flags().StringVarP(&autoBaseValues, "values", "f", "", "Path to base values.yaml to merge with detected values")
autoCmd.Flags().BoolVar(&autoSkipMonitoring, "skip-monitoring", false, "Skip deploying the monitoring stack (Prometheus + Grafana)")
addLabelsFileFlag(autoCmd)
rootCmd.AddCommand(autoCmd)
}

func runAuto(cmd *cobra.Command, args []string) error {
output.Header("Kates Auto-Deploy")

namespaceLabels, _, err := namespaceUserLabels()
if err != nil {
return err
}

executor := defaultExecutor
collector := detect.NewCollector(executor)
// Introspection creates a namespace, a Secret and prober pods. A dry run
// collects without them, as kates deploy --dry-run does.
collector.ReadOnly = autoDryRun
collector.NamespaceLabels = namespaceLabels

if err := collector.Preflight(); err != nil {
return cmdErr(fmt.Sprintf("Preflight failed: %v", err))
Expand Down Expand Up @@ -118,6 +125,13 @@ func runAuto(cmd *cobra.Command, args []string) error {

// 4. Helm Deploy Phase
output.Hint(fmt.Sprintf("📌 Target namespace: %s", autoNamespace))
// Created with the namespace labels before Helm's --create-namespace
// could make it bare; the monitoring release shares it.
if !autoDryRun {
if err := ensureConfiguredDeployNamespace(context.Background(), autoNamespace); err != nil {
return err
}
}
helmArgs := []string{
"upgrade", "--install", autoReleaseName, autoChartDir,
"--namespace", autoNamespace, "--create-namespace",
Expand Down
30 changes: 25 additions & 5 deletions cli/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
Expand Down Expand Up @@ -70,6 +71,7 @@ var (
deployWithMirrorMaker2 bool
deployMM2NS string
deployNamespaceLabels []string
deployNoWriteProbes bool
deployYes bool

// Versions and scope (multi-version plan §3.1–3.3).
Expand Down Expand Up @@ -104,7 +106,9 @@ func init() {
deployCmd.Flags().BoolVar(&deployWithKafkaUI, "with-kafka-ui", true, "Deploy Kafka UI for browser-based cluster monitoring")
deployCmd.Flags().BoolVar(&deployWithMirrorMaker2, "with-mirror-maker2", false, "Deploy MirrorMaker 2 as a loopback mirror of the primary (replication lab; kates migrate for real sources)")
deployCmd.Flags().StringVar(&deployMM2NS, "mm2-ns", "kafka", "Namespace for MirrorMaker 2 when topology is 'isolated'")
deployCmd.Flags().StringArrayVar(&deployNamespaceLabels, "namespace-label", nil, "Label to apply to namespaces created by deploy (key=value, repeatable; KATES_NAMESPACE_LABELS also accepts comma-separated key=value entries)")
deployCmd.Flags().StringArrayVar(&deployNamespaceLabels, "namespace-label", nil, "Label to apply to the namespaces deploy ensures (key=value, repeatable; overrides KATES_NAMESPACE_LABELS, which overrides the labels file)")
addLabelsFileFlag(deployCmd)
deployCmd.Flags().BoolVar(&deployNoWriteProbes, "no-write-probes", false, "Skip the introspection probes that create a namespace, a Secret or pods (as --dry-run does)")
deployCmd.Flags().BoolVarP(&deployInteractive, "interactive", "i", false, "Use interactive UI to configure deployment")
deployCmd.Flags().BoolVar(&deployVerbose, "verbose", false, "Show every kubectl/helm command as it runs")
deployCmd.Flags().BoolVarP(&deployPortForward, "port-forward", "P", false, "After deploy, run kates ports: forward every service in the background and make the ports context current")
Expand All @@ -125,6 +129,12 @@ func runDeploy(cmd *cobra.Command, args []string) error {

dl = &DashboardController{}

// A bad namespace label fails before anything asks the cluster.
namespaceLabels, namespaceLabelSources, err := namespaceUserLabels()
if err != nil {
return err
}

// ── Target cluster ───────────────────────────────────────────────────
// This runs FIRST, before the banner and the wizard. There is no point
// asking eight questions about a deployment and only then discovering
Expand Down Expand Up @@ -170,8 +180,9 @@ func runDeploy(cmd *cobra.Command, args []string) error {
// 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
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
Expand Down Expand Up @@ -253,7 +264,7 @@ func runDeploy(cmd *cobra.Command, args []string) error {

// ── Dry-run: show preview and exit ──
if deployDryRun {
return runDeployDryRun(sharedEntries)
return runDeployDryRun(sharedEntries, namespaceLabels, namespaceLabelSources)
}

// An operator upgrade or a scope change rolls things; say so and ask.
Expand Down Expand Up @@ -477,9 +488,18 @@ func runExecStdinDefault(ctx context.Context, name string, args []string, stdinD

if deployVerbose {
dl.Printf(" %s\n", output.DimStyle.Render("$ "+name+" "+strings.Join(args, " ")))
// stderr is also kept for the error, so a caller can still tell why
// kubectl failed (ensureDeployNamespace looks for an RBAC refusal).
var errBuf bytes.Buffer
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
cmd.Stderr = io.MultiWriter(os.Stderr, &errBuf)
if runErr := cmd.Run(); runErr != nil {
if errMsg := strings.TrimSpace(errBuf.String()); errMsg != "" {
return fmt.Errorf("%w: %s", runErr, errMsg)
}
return runErr
}
return nil
}

var outBuf, errBuf bytes.Buffer
Expand Down
168 changes: 106 additions & 62 deletions cli/cmd/deploy_components.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net"
"net/url"
"os"
"path/filepath"
"sort"
"slices"
"strconv"
"strings"
"time"

"github.com/bmscomp/kates/cli/output"
"github.com/bmscomp/kates/cli/pkg/detect"
"github.com/bmscomp/kates/cli/pkg/strimzi"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -198,20 +200,15 @@ func ensureKubernetesNoProxy(noProxy, clusterDomain string) string {
)
}

// deployNamespaceLabelMap is the user's namespace labels plus the built-in
// managed-by label, which ensureDeployNamespace sets only on a namespace it
// creates.
func deployNamespaceLabelMap() (map[string]string, error) {
labels := map[string]string{
"app.kubernetes.io/managed-by": "kates",
}
if raw := strings.TrimSpace(os.Getenv("KATES_NAMESPACE_LABELS")); raw != "" {
if err := addDeployNamespaceLabelSpecs(labels, raw); err != nil {
return nil, err
}
}
for _, raw := range deployNamespaceLabels {
if err := addDeployNamespaceLabelSpecs(labels, raw); err != nil {
return nil, err
}
labels, _, err := namespaceUserLabels()
if err != nil {
return nil, err
}
labels[managedByLabel] = "kates"
return labels, nil
}

Expand Down Expand Up @@ -250,81 +247,120 @@ func monitoringNodeExporterDaemonSetScheduled(ctx context.Context, namespace str
return err == nil && desired > 0
}

func addDeployNamespaceLabelSpecs(labels map[string]string, raw string) error {
for _, spec := range strings.Split(raw, ",") {
spec = strings.TrimSpace(spec)
if spec == "" {
continue
}
key, value, ok := strings.Cut(spec, "=")
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if !ok || key == "" || value == "" {
return fmt.Errorf("invalid namespace label %q: expected key=value", spec)
}
labels[key] = value
// deployNamespaceManifest is a Namespace carrying labels, and the annotation
// that records which of them are the user's (every key but managed-by).
func deployNamespaceManifest(name string, labels map[string]string) string {
var annotations map[string]string
if user := userNamespaceLabels(labels); len(user) > 0 {
annotations = map[string]string{namespaceLabelsAnnotation: strings.Join(slices.Sorted(maps.Keys(user)), ",")}
}
return nil
return detect.NamespaceManifest(name, labels, annotations)
}

func deployNamespaceManifest(name string, labels map[string]string) string {
var b strings.Builder
fmt.Fprintf(&b, "apiVersion: v1\nkind: Namespace\nmetadata:\n name: %s\n", name)
if len(labels) > 0 {
b.WriteString(" labels:\n")
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
fmt.Fprintf(&b, " %s: %s\n", key, strconv.Quote(labels[key]))
}
}
return b.String()
func userNamespaceLabels(labels map[string]string) map[string]string {
user := maps.Clone(labels)
delete(user, managedByLabel)
return user
}

// ensureDeployNamespace creates a missing namespace with labels, or brings an
// existing one's user labels in line with them. On an existing namespace it
// never touches managed-by (the namespace is not Kates'), and removes only
// keys its annotation says Kates set.
func ensureDeployNamespace(ctx context.Context, namespace string, labels map[string]string) error {
namespace = strings.TrimSpace(namespace)
if namespace == "" {
return nil
}
exists, err := deployNamespaceExists(ctx, namespace)
out, err := runExecOutputFn(ctx, "kubectl", "get", "namespace", namespace, "--ignore-not-found", "-o", "json")
if err != nil {
return fmt.Errorf("check namespace %s: %w", namespace, err)
}
if !exists {
if err := runExecStdinFn(ctx, "kubectl", []string{"create", "-f", "-"}, deployNamespaceManifest(namespace, labels)); err != nil {
if strings.TrimSpace(string(out)) == "" {
manifest := deployNamespaceManifest(namespace, labels)
if err := runExecStdinFn(ctx, "kubectl", []string{"create", "-f", "-"}, manifest); err != nil {
if rbacRefused(err, "create") {
return fmt.Errorf("create namespace %s: %w\n\nThis account may not create namespaces. Ask a cluster admin to apply this manifest (kubectl create -f <file>), then run again:\n\n%s", namespace, err, manifest)
}
return fmt.Errorf("create namespace %s: %w", namespace, err)
}
return nil
}
if len(labels) == 0 {

var current struct {
Metadata struct{ Labels, Annotations map[string]string }
}
if err := json.Unmarshal(out, &current); err != nil {
return fmt.Errorf("check namespace %s: %w", namespace, err)
}
user, have := userNamespaceLabels(labels), current.Metadata.Labels
set := map[string]string{}
for key, value := range user {
if old, ok := have[key]; !ok || old != value {
set[key] = value
}
}
var remove, tracked []string
for _, key := range strings.Split(current.Metadata.Annotations[namespaceLabelsAnnotation], ",") {
key = strings.TrimSpace(key)
if _, wanted := user[key]; wanted {
tracked = append(tracked, key)
} else if _, present := have[key]; key != "" && present {
remove = append(remove, key)
}
}
// No call when nothing changes, so an account that may not patch
// namespaces still passes once the labels are right.
if len(set) == 0 && len(remove) == 0 {
return nil
}

labelArgs := []string{"label", "namespace", namespace}
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
labelPatch := map[string]any{}
for key, value := range set {
labelPatch[key] = value
if !slices.Contains(tracked, key) {
tracked = append(tracked, key)
}
}
sort.Strings(keys)
for _, key := range keys {
labelArgs = append(labelArgs, key+"="+labels[key])
for _, key := range remove {
labelPatch[key] = nil
}
labelArgs = append(labelArgs, "--overwrite")
if out, err := runExecCombinedFn(ctx, "kubectl", labelArgs...); err != nil {
return commandErrorWithOutput("label namespace "+namespace, err, out)
var annotation any // null removes it
if len(tracked) > 0 {
slices.Sort(tracked)
annotation = strings.Join(tracked, ",")
}
patch, err := json.Marshal(map[string]any{"metadata": map[string]any{
"labels": labelPatch,
"annotations": map[string]any{namespaceLabelsAnnotation: annotation},
}})
if err != nil {
return err
}
if out, err := runExecCombinedFn(ctx, "kubectl", "patch", "namespace", namespace, "--type", "merge", "-p", string(patch)); err != nil {
err = commandErrorWithOutput("patch namespace "+namespace, err, out)
if !rbacRefused(err, "patch") {
return err
}
args := []string{"kubectl", "label", "namespace", namespace}
for _, key := range slices.Sorted(maps.Keys(set)) {
args = append(args, key+"="+set[key])
}
slices.Sort(remove)
for _, key := range remove {
args = append(args, key+"-")
}
return fmt.Errorf("%w\n\nThis account may not change the labels of namespace %s. Ask a cluster admin to run this, then run again:\n\n %s", err, namespace, strings.Join(append(args, "--overwrite"), " "))
}
return nil
}

func deployNamespaceExists(ctx context.Context, namespace string) (bool, error) {
out, err := runExecOutputFn(ctx, "kubectl", "get", "namespace", namespace, "--ignore-not-found", "-o", "jsonpath={.metadata.name}")
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) == namespace, nil
// rbacRefused says whether RBAC refused verb on a resource, as the API
// server words it ("User ... cannot create resource ..."). An admission
// webhook or policy denial also says Forbidden, but an admin running the same
// request is refused the same way, so it gets no admin instruction.
func rbacRefused(err error, verb string) bool {
return strings.Contains(err.Error(), "cannot "+verb+" resource")
}

func ensureConfiguredDeployNamespace(ctx context.Context, namespace string) error {
Expand Down Expand Up @@ -772,6 +808,11 @@ func deployGroupA(dc *deployContext) error {
dl.Printf("\n📦 Deploying Cert-Manager (Namespace: %s)...\n", "cert-manager")
runHelmFn(gCtx, "repo", "add", "jetstack", "https://charts.jetstack.io")
runHelmFn(gCtx, "repo", "update", "jetstack")
// Created here with the namespace labels; --create-namespace
// would make it bare.
if err := ensureConfiguredDeployNamespace(gCtx, "cert-manager"); err != nil {
return err
}
// global.clusterDomain ensures cert-manager generates webhook TLS certificates
// and service references using the actual cluster DNS domain (not always cluster.local).
// report.Network.ClusterDomain is detected from the live cluster by kates detect.
Expand Down Expand Up @@ -914,6 +955,9 @@ spec:
dl.Println("\n📦 Deploying Kyverno (Namespace: kyverno)...")
runHelmFn(gCtx, "repo", "add", "kyverno", "https://kyverno.github.io/kyverno/")
runHelmFn(gCtx, "repo", "update", "kyverno")
if err := ensureConfiguredDeployNamespace(gCtx, "kyverno"); err != nil {
return err
}

// Kyverno v3.x splits into 4 controllers; replicaCount=1 is a v2.x flag.
// global.clusterDomain ensures Kyverno webhook certificates use the
Expand Down
Loading
Loading