From 929b0733821734429250fd16cee76c46122caaa9 Mon Sep 17 00:00:00 2001 From: Stefan Koelle <50440224+skoelle@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:58:19 +0200 Subject: [PATCH] Redesign config schema: contexts + envs + AWS region + secret/context templates instead of live kubectl discovery and context-pairs --- config.example.yaml | 56 +++++++++++++++++------ internal/config/config.go | 84 ++++++++++++++++++++++++++++++---- internal/config/config_test.go | 76 ++++++++++++++++++++++++++---- 3 files changed, 182 insertions(+), 34 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index edd50bd..794af53 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,21 +1,49 @@ # Example configuration for kctl-tui. # Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your -# own cluster setup. Do NOT commit your real config.yaml with company- or -# project-specific context/namespace/label names to a public repository. +# own AWS/Kubernetes setup. Do NOT commit your real config.yaml with +# company- or project-specific account IDs, contexts, or label names to a +# public repository. -# Groups of kubectl contexts that belong together (e.g. the same -# environment pair, such as staging/production of the same cluster). -# Pressing TAB in the control pane cycles through the contexts listed here -# while keeping the current namespace. -context_pairs: - - name: "example-environment-pair" - contexts: - - "example-context-a" - - "example-context-b" +# Top-level grouping the tool starts from (e.g. network boundary such as +# internal/external-facing clusters). This is the outermost navigation +# level; press Esc from the team-selection screen to get here. +contexts: + - "internal" + - "external" -# The namespace label key used to group namespaces by team/ownership in the -# team-selection screen. Adjust this to whatever label your organization -# actually uses (can contain a domain prefix, e.g. "example.org/team"). +# Pre-selected on startup so the tool can jump straight to team selection +# instead of asking for the context every time. Falls back to the first +# entry of 'contexts' if omitted. +default_context: "internal" + +# Environments switchable from the control panel (e.g. "1) beta" / +# "2) prod"). The first two entries are also used for the two k9s status +# panes shown side by side. +envs: + - "beta" + - "prod" + +# AWS region used for all AWS Secrets Manager calls. +aws_region: "eu-central-1" + +# AWS account ID, used to fill the {account_id} placeholder below. +# 123456789012 is a placeholder, not a real account. +aws_account_id: "123456789012" + +# Builds the AWS Secrets Manager secret ID from the chosen namespace and +# environment. Available placeholders: {namespace}, {env}. +secret_name_template: "tf-{namespace}-{env}-secrets" + +# Builds the actual kubectl context name/ARN from region, account ID, env, +# and context. Available placeholders: {region}, {account_id}, {env}, +# {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN +# shape) to match how your own EKS clusters/contexts are actually named. +context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1" + +# The namespace label key used to group namespaces by team/ownership in +# the team-selection screen. Adjust this to whatever label your +# organization actually uses (can contain a domain prefix, e.g. +# "example.org/team"). team_label_key: "example.org/team" # Command used to (re-)authenticate with AWS before the Secrets workflow, diff --git a/internal/config/config.go b/internal/config/config.go index 56a5701..8fb5e21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,5 +1,5 @@ // Package config loads the user-specific, non-versioned kctl-tui -// configuration (context pairs, team label key) from a YAML file. +// configuration (contexts, envs, templates) from a YAML file. package config import ( @@ -17,15 +17,46 @@ const DefaultAWSSSOLoginCommand = "aws sso login" // Config is the root structure of ~/.kctl-tui/config.yaml type Config struct { - ContextPairs []kctl.ContextPair `yaml:"context_pairs"` - TeamLabelKey string `yaml:"team_label_key"` - AWSSSOLoginCommand string `yaml:"aws_sso_login_command"` + // Contexts are the top-level groupings the tool starts from, e.g. + // "internal"/"external". This is the outermost navigation level. + Contexts []string `yaml:"contexts"` + + // DefaultContext is pre-selected on startup so the tool can jump + // straight to team selection; falls back to the first entry of + // Contexts if empty. + DefaultContext string `yaml:"default_context"` + + // Envs are the environments switchable from the control panel, e.g. + // "beta"/"prod". The first two entries are used for the two k9s + // status panes. + Envs []string `yaml:"envs"` + + // AWSRegion is used for all AWS Secrets Manager calls. + AWSRegion string `yaml:"aws_region"` + + // AWSAccountID fills the {account_id} placeholder in ContextTemplate. + AWSAccountID string `yaml:"aws_account_id"` + + // SecretNameTemplate builds the AWS Secrets Manager secret ID from a + // namespace and env, e.g. "tf-{namespace}-{env}-secrets". + SecretNameTemplate string `yaml:"secret_name_template"` + + // ContextTemplate builds the actual kubectl context name/ARN from + // region, account_id, env, and context, e.g. + // "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1". + ContextTemplate string `yaml:"context_template"` + + // TeamLabelKey is the namespace label used to group namespaces by + // team/ownership in the team-selection screen. + TeamLabelKey string `yaml:"team_label_key"` + + // AWSSSOLoginCommand is run interactively if an AWS auth check fails + // before the secrets workflow (e.g. an expired SSO session). + AWSSSOLoginCommand string `yaml:"aws_sso_login_command"` } // LoginCommand returns the configured AWS SSO login command, falling back -// to DefaultAWSSSOLoginCommand if none is set. Organizations that wrap -// SSO login in a custom script (e.g. to select a specific profile) can -// override this via aws_sso_login_command in config.yaml. +// to DefaultAWSSSOLoginCommand if none is set. func (c Config) LoginCommand() string { if c.AWSSSOLoginCommand == "" { return DefaultAWSSSOLoginCommand @@ -33,6 +64,38 @@ func (c Config) LoginCommand() string { return c.AWSSSOLoginCommand } +// EffectiveDefaultContext returns DefaultContext if set, otherwise the +// first entry of Contexts, otherwise an empty string. +func (c Config) EffectiveDefaultContext() string { + if c.DefaultContext != "" { + return c.DefaultContext + } + if len(c.Contexts) > 0 { + return c.Contexts[0] + } + return "" +} + +// ResolveContext builds the actual kubectl context name/ARN for a given +// env + context (e.g. "beta" + "internal") using ContextTemplate. +func (c Config) ResolveContext(env, context string) string { + return kctl.ResolveTemplate(c.ContextTemplate, map[string]string{ + "region": c.AWSRegion, + "account_id": c.AWSAccountID, + "env": env, + "context": context, + }) +} + +// ResolveSecretName builds the AWS Secrets Manager secret ID for a given +// namespace + env using SecretNameTemplate. +func (c Config) ResolveSecretName(namespace, env string) string { + return kctl.ResolveTemplate(c.SecretNameTemplate, map[string]string{ + "namespace": namespace, + "env": env, + }) +} + // DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml func DefaultPath() (string, error) { home, err := os.UserHomeDir() @@ -43,9 +106,10 @@ func DefaultPath() (string, error) { } // Load reads and parses the config file at path. If the file does not -// exist, it returns a zero-value Config (no pairs, empty label key) and no -// error, so the tool can run with sane defaults before the user has set up -// a config file. +// exist, it returns a zero-value Config and no error, so the tool can +// still start (with an explanatory error surfaced later where a required +// field turns out to be missing) before the user has set up a config +// file. func Load(path string) (Config, error) { cfg := Config{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af72074..51bd652 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,19 +11,24 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(cfg.ContextPairs) != 0 { - t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs) - } - if cfg.TeamLabelKey != "" { - t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey) + if len(cfg.Contexts) != 0 || len(cfg.Envs) != 0 { + t.Fatalf("expected no contexts/envs, got %+v", cfg) } } func TestLoad_ValidFile(t *testing.T) { content := []byte(` -context_pairs: - - name: "env-pair-1" - contexts: ["ctx-a", "ctx-b"] +contexts: + - "internal" + - "external" +default_context: "internal" +envs: + - "beta" + - "prod" +aws_region: "eu-central-1" +aws_account_id: "123456789012" +secret_name_template: "tf-{namespace}-{env}-secrets" +context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1" team_label_key: "example.org/team" `) path := filepath.Join(t.TempDir(), "config.yaml") @@ -38,7 +43,58 @@ team_label_key: "example.org/team" if cfg.TeamLabelKey != "example.org/team" { t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey) } - if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" { - t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs) + if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 { + t.Fatalf("unexpected contexts/envs: %+v", cfg) + } +} + +func TestEffectiveDefaultContext(t *testing.T) { + cfg := Config{Contexts: []string{"internal", "external"}} + if got := cfg.EffectiveDefaultContext(); got != "internal" { + t.Fatalf("expected first context as fallback default, got %q", got) + } + + cfg.DefaultContext = "external" + if got := cfg.EffectiveDefaultContext(); got != "external" { + t.Fatalf("expected explicit default_context to win, got %q", got) + } + + empty := Config{} + if got := empty.EffectiveDefaultContext(); got != "" { + t.Fatalf("expected empty string when no contexts configured, got %q", got) + } +} + +func TestResolveContext(t *testing.T) { + cfg := Config{ + AWSRegion: "eu-central-1", + AWSAccountID: "123456789012", + ContextTemplate: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1", + } + got := cfg.ResolveContext("beta", "internal") + want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestResolveSecretName(t *testing.T) { + cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"} + got := cfg.ResolveSecretName("example-ns", "beta") + want := "tf-example-ns-beta-secrets" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestLoginCommand_DefaultsWhenUnset(t *testing.T) { + cfg := Config{} + if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand { + t.Fatalf("got %q, want default %q", got, DefaultAWSSSOLoginCommand) + } + + cfg.AWSSSOLoginCommand = "aws sso login --profile custom" + if got := cfg.LoginCommand(); got != "aws sso login --profile custom" { + t.Fatalf("expected custom login command to be used, got %q", got) } }