Redesign config schema: contexts + envs + AWS region + secret/context templates instead of live kubectl discovery and context-pairs

This commit is contained in:
Stefan Koelle
2026-08-09 13:58:19 +02:00
parent 3af468de98
commit 929b073382
3 changed files with 182 additions and 34 deletions
+42 -14
View File
@@ -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,
+74 -10
View File
@@ -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{}
+66 -10
View File
@@ -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)
}
}