From 52ebbf5aa1bd0fd886c52507fc137757ef40357d Mon Sep 17 00:00:00 2001 From: soypete Date: Fri, 27 Mar 2026 08:54:36 -0600 Subject: [PATCH 1/3] feat: add OpenBao agent injector component Adds openbao-injector as a new foundry component that installs the OpenBao agent injector via Helm. The injector registers a MutatingWebhookConfiguration so pods annotated with vault.hashicorp.com/agent-inject can receive secrets from OpenBao at runtime without storing them in k8s Secrets. Also adds docs/pod-secrets.md covering how to annotate pods, source injected files in dash-compatible containers, and configure Kubernetes auth in OpenBao. Co-Authored-By: Claude Sonnet 4.6 --- csil/v1/components/openbao-injector.csil | 17 ++ docs/pod-secrets.md | 179 +++++++++++ v1/cmd/foundry/commands/cluster/init.go | 3 +- v1/cmd/foundry/commands/cluster/node_add.go | 3 +- .../foundry/commands/cluster/node_remove.go | 3 +- v1/cmd/foundry/commands/component/install.go | 78 ++--- v1/cmd/foundry/commands/component/status.go | 10 +- v1/cmd/foundry/commands/dns/zone.go | 4 +- v1/cmd/foundry/commands/grafana/commands.go | 3 +- v1/cmd/foundry/commands/openbao/unseal.go | 3 +- v1/cmd/foundry/commands/stack/install.go | 16 +- v1/cmd/foundry/registry/init.go | 11 +- v1/internal/component/openbao/types.go | 2 + .../component/openbaoinjector/install.go | 121 ++++++++ .../component/openbaoinjector/install_test.go | 279 ++++++++++++++++++ .../component/openbaoinjector/types.gen.go | 11 + .../component/openbaoinjector/types.go | 143 +++++++++ .../component/openbaoinjector/types_test.go | 172 +++++++++++ v1/internal/config/helpers.go | 49 +++ 19 files changed, 1047 insertions(+), 60 deletions(-) create mode 100644 csil/v1/components/openbao-injector.csil create mode 100644 docs/pod-secrets.md create mode 100644 v1/internal/component/openbaoinjector/install.go create mode 100644 v1/internal/component/openbaoinjector/install_test.go create mode 100644 v1/internal/component/openbaoinjector/types.gen.go create mode 100644 v1/internal/component/openbaoinjector/types.go create mode 100644 v1/internal/component/openbaoinjector/types_test.go diff --git a/csil/v1/components/openbao-injector.csil b/csil/v1/components/openbao-injector.csil new file mode 100644 index 0000000..f4c21ba --- /dev/null +++ b/csil/v1/components/openbao-injector.csil @@ -0,0 +1,17 @@ +; OpenBao Agent Injector component configuration +; +; Configuration for the OpenBao Agent Injector which installs a +; MutatingWebhookConfiguration to inject secrets into pods. + +options { + go_module: "github.com/catalystcommunity/foundry/v1", + go_package: "github.com/catalystcommunity/foundry/v1/internal/component/openbaoinjector" +} + +; OpenBao Agent Injector component configuration +; Default values will be set in Go code DefaultConfig() function +Config = { + version: text, ; Default: "0.26.2" + namespace: text, ; Default: "openbao" + external_vault_addr: text, ; Required: OpenBao address (e.g. http://100.81.89.62:8200) +} \ No newline at end of file diff --git a/docs/pod-secrets.md b/docs/pod-secrets.md new file mode 100644 index 0000000..cb6e9ec --- /dev/null +++ b/docs/pod-secrets.md @@ -0,0 +1,179 @@ +# Injecting OpenBao Secrets into Pods + +This guide explains how to inject secrets from OpenBao into Kubernetes pods using the OpenBao agent injector. + +## Prerequisites + +The `openbao-injector` component must be installed: + +```bash +foundry component install openbao-injector +``` + +This deploys the OpenBao agent injector and registers a `MutatingWebhookConfiguration` that intercepts pod creation. Pods annotated with `vault.hashicorp.com/agent-inject: "true"` automatically receive a sidecar that mounts secrets from OpenBao before the main container starts. + +## How It Works + +1. You annotate a pod/deployment/cronjob with the secrets it needs +2. The injector sidecar fetches those secrets from OpenBao at pod startup +3. Secrets are written as files to `/vault/secrets/` +4. Your container sources those files to load them as environment variables + +## OpenBao Setup + +### Enable Kubernetes Auth + +The injector authenticates pods against OpenBao using the Kubernetes service account JWT. Enable this once: + +```bash +vault auth enable kubernetes + +vault write auth/kubernetes/config \ + kubernetes_host="https://$(kubectl get svc kubernetes -o jsonpath='{.spec.clusterIP}'):443" +``` + +### Create a Policy + +Define what secrets a pod is allowed to read: + +```bash +vault policy write my-app - <`. + +### Basic Example + +```yaml +spec: + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "my-app" + + # Inject secret/data/apps/my-app as /vault/secrets/my-app + vault.hashicorp.com/agent-inject-secret-my-app: "secret/data/apps/my-app" + + # Template the file as shell exports so the container can source it + vault.hashicorp.com/agent-inject-template-my-app: | + {{- with secret "secret/data/apps/my-app" -}} + export DB_PASSWORD="{{ .Data.data.password }}" + export API_KEY="{{ .Data.data.api_key }}" + {{- end }} +``` + +### Container Entrypoint + +Because the secrets are shell files (not environment variables), the container must source them before starting: + +```yaml +containers: + - name: my-app + command: ["/bin/sh", "-c"] + args: [". /vault/secrets/my-app && exec my-binary"] +``` + +> **Note**: Use `.` (dot) not `source` — slim/alpine images use `dash` as `/bin/sh` which does not support `source`. + +## Helm Chart Pattern + +For a Helm chart with multiple secrets (e.g. reddit-watcher): + +```yaml +# In your cronjob/deployment template +metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "{{ .Values.vault.role }}" + + vault.hashicorp.com/agent-inject-secret-db: "{{ .Values.vault.dbPath }}" + vault.hashicorp.com/agent-inject-template-db: | + {{`{{- with secret "`}}{{ .Values.vault.dbPath }}{{`" -}}`}} + {{`export POSTGRES_URL="{{ .Data.data.postgres_url }}"`}} + {{`{{- end }}`}} + + vault.hashicorp.com/agent-inject-secret-api: "{{ .Values.vault.apiPath }}" + vault.hashicorp.com/agent-inject-template-api: | + {{`{{- with secret "`}}{{ .Values.vault.apiPath }}{{`" -}}`}} + {{`export API_KEY="{{ .Data.data.key }}"`}} + {{`{{- end }}`}} + +spec: + containers: + - name: my-app + command: ["/bin/sh", "-c"] + args: [". /vault/secrets/db && . /vault/secrets/api && exec python -m main"] +``` + +## Storing Secrets in OpenBao + +```bash +# Store secrets (run from your local machine with VAULT_ADDR set) +vault kv put secret/apps/my-app \ + password="my-db-password" \ + api_key="my-api-key" + +# Add a key to an existing secret without overwriting others +vault kv patch secret/apps/my-app new_key="new-value" + +# Using 1Password to inject the value at write time +op run -- vault kv patch secret/apps/my-app \ + postgres_url="op://pedro/POSTGRES_URL/credential" +``` + +## Troubleshooting + +### `/vault/secrets/: No such file` + +The injector sidecar didn't run. Check: + +1. Is `openbao-injector` installed? + ```bash + foundry component status openbao-injector + kubectl get mutatingwebhookconfigurations | grep openbao + ``` + +2. Does the pod have `vault.hashicorp.com/agent-inject: "true"` annotation? + +3. Is the pod in a namespace the webhook targets? By default it targets all namespaces. + +### `permission denied` fetching secrets + +The Kubernetes auth role doesn't bind to this pod's service account or namespace: + +```bash +# Verify the role bindings +vault read auth/kubernetes/role/my-app +``` + +Ensure `bound_service_account_namespaces` includes the pod's namespace. + +### `source: not found` + +Using `source` instead of `.` in a `dash`-based image. Replace: +```sh +# Wrong (bash only) +source /vault/secrets/my-app + +# Correct (POSIX sh / dash compatible) +. /vault/secrets/my-app +``` diff --git a/v1/cmd/foundry/commands/cluster/init.go b/v1/cmd/foundry/commands/cluster/init.go index b692b20..2910071 100644 --- a/v1/cmd/foundry/commands/cluster/init.go +++ b/v1/cmd/foundry/commands/cluster/init.go @@ -223,11 +223,10 @@ func InitializeCluster(ctx context.Context, cfg *config.Config) error { } // Get OpenBAO client with authenticated token - openbaoIP, err := cfg.GetPrimaryOpenBAOAddress() + openbaoAddr, err := cfg.GetPrimaryOpenBAOURL() if err != nil { return fmt.Errorf("failed to get OpenBAO address: %w", err) } - openbaoAddr := fmt.Sprintf("http://%s:8200", openbaoIP) openbaoClient := openbao.NewClient(openbaoAddr, keyMaterial.RootToken) fmt.Println("✓ OpenBAO credentials loaded") diff --git a/v1/cmd/foundry/commands/cluster/node_add.go b/v1/cmd/foundry/commands/cluster/node_add.go index 899120f..717c44b 100644 --- a/v1/cmd/foundry/commands/cluster/node_add.go +++ b/v1/cmd/foundry/commands/cluster/node_add.go @@ -223,11 +223,10 @@ func printNodeAddPlan(hostname string, role *k3s.DeterminedRole, cfg *config.Con func addNodeToCluster(ctx context.Context, hostname string, nodeRole *k3s.DeterminedRole, cfg *config.Config) error { // Step 1: Get OpenBAO client fmt.Println("Connecting to OpenBAO...") - openbaoIP, err := cfg.GetPrimaryOpenBAOAddress() + openbaoAddr, err := cfg.GetPrimaryOpenBAOURL() if err != nil { return fmt.Errorf("failed to get OpenBAO address: %w", err) } - openbaoAddr := fmt.Sprintf("http://%s:8200", openbaoIP) // Load OpenBAO token from keys.json file configDir, err := config.GetConfigDir() diff --git a/v1/cmd/foundry/commands/cluster/node_remove.go b/v1/cmd/foundry/commands/cluster/node_remove.go index 64a461d..79e767a 100644 --- a/v1/cmd/foundry/commands/cluster/node_remove.go +++ b/v1/cmd/foundry/commands/cluster/node_remove.go @@ -113,11 +113,10 @@ func printNodeRemovePlan(hostname string, cfg *config.Config) { func removeNodeFromCluster(ctx context.Context, hostname string, cfg *config.Config) error { // Step 1: Get OpenBAO client and load kubeconfig fmt.Println("Loading kubeconfig from OpenBAO...") - openbaoIP, err := cfg.GetPrimaryOpenBAOAddress() + openbaoAddr, err := cfg.GetPrimaryOpenBAOURL() if err != nil { return fmt.Errorf("failed to get OpenBAO address: %w", err) } - openbaoAddr := fmt.Sprintf("http://%s:8200", openbaoIP) // TODO: Get token from auth management openbaoClient := openbao.NewClient(openbaoAddr, "") diff --git a/v1/cmd/foundry/commands/component/install.go b/v1/cmd/foundry/commands/component/install.go index 281013d..0a78729 100644 --- a/v1/cmd/foundry/commands/component/install.go +++ b/v1/cmd/foundry/commands/component/install.go @@ -17,9 +17,10 @@ import ( "github.com/catalystcommunity/foundry/v1/internal/component/gatewayapi" "github.com/catalystcommunity/foundry/v1/internal/component/grafana" "github.com/catalystcommunity/foundry/v1/internal/component/loki" - "github.com/catalystcommunity/foundry/v1/internal/component/seaweedfs" "github.com/catalystcommunity/foundry/v1/internal/component/openbao" + "github.com/catalystcommunity/foundry/v1/internal/component/openbaoinjector" "github.com/catalystcommunity/foundry/v1/internal/component/prometheus" + "github.com/catalystcommunity/foundry/v1/internal/component/seaweedfs" componentStorage "github.com/catalystcommunity/foundry/v1/internal/component/storage" "github.com/catalystcommunity/foundry/v1/internal/component/velero" "github.com/catalystcommunity/foundry/v1/internal/config" @@ -101,16 +102,17 @@ Examples: // k8sComponents lists all components that are installed via kubeconfig (Helm/K8s) var k8sComponents = map[string]bool{ - "gateway-api": true, - "contour": true, - "cert-manager": true, - "storage": true, - "seaweedfs": true, - "prometheus": true, - "loki": true, - "grafana": true, - "external-dns": true, - "velero": true, + "gateway-api": true, + "contour": true, + "cert-manager": true, + "storage": true, + "seaweedfs": true, + "prometheus": true, + "loki": true, + "grafana": true, + "external-dns": true, + "velero": true, + "openbao-injector": true, } func runInstall(ctx context.Context, cmd *cli.Command) error { @@ -296,6 +298,14 @@ func installK8sComponent(ctx context.Context, cmd *cli.Command, name string, sta cfg["s3_bucket"] = "velero" cfg["s3_region"] = "us-east-1" componentWithClients = velero.NewComponent(helmClient, k8sClient) + case "openbao-injector": + // Inject the OpenBao address so the webhook knows where to reach it + url, err := stackConfig.GetPrimaryOpenBAOURL() + if err != nil { + return fmt.Errorf("failed to get OpenBao address for injector: %w", err) + } + cfg["external_vault_addr"] = url + componentWithClients = openbaoinjector.NewComponent(helmClient) default: return fmt.Errorf("unknown kubernetes component: %s", name) } @@ -348,11 +358,10 @@ func getDNSAPIKey(stackConfig *config.Config) (string, error) { return "", err } - addr, err := stackConfig.GetPrimaryOpenBAOAddress() + openBAOAddr, err := stackConfig.GetPrimaryOpenBAOURL() if err != nil { return "", err } - openBAOAddr := fmt.Sprintf("http://%s:8200", addr) keysPath := filepath.Join(configDir, "openbao-keys", stackConfig.Cluster.Name, "keys.json") keysData, err := os.ReadFile(keysPath) @@ -441,10 +450,9 @@ func installSSHComponent(ctx context.Context, cmd *cli.Command, name string, sta // Add OpenBAO API URL (for OpenBAO component initialization) if name == "openbao" { - addr, err := stackConfig.GetPrimaryOpenBAOAddress() + url, err := stackConfig.GetPrimaryOpenBAOURL() if err == nil { - // Construct the API URL from the network config - cfg["api_url"] = fmt.Sprintf("http://%s:8200", addr) + cfg["api_url"] = url } } @@ -709,21 +717,20 @@ func buildSecretResolver(cfg *config.Config) (*secrets.ChainResolver, *secrets.R // Try to get OpenBAO address and token var openBAOAddr, openBAOToken string - addr, err := cfg.GetPrimaryOpenBAOAddress() - if err == nil { - openBAOAddr = fmt.Sprintf("http://%s:8200", addr) - - // Try to read OpenBAO token from keys file - configDir, errConfig := config.GetConfigDir() - if errConfig == nil { - keysPath := filepath.Join(configDir, "openbao-keys", cfg.Cluster.Name, "keys.json") - if keysData, errRead := os.ReadFile(keysPath); errRead == nil { - var keys struct { - RootToken string `json:"root_token"` - } - if errUnmarshal := json.Unmarshal(keysData, &keys); errUnmarshal == nil { - openBAOToken = keys.RootToken - } + if addr, err := cfg.GetPrimaryOpenBAOURL(); err == nil { + openBAOAddr = addr + } + + // Try to read OpenBAO token from keys file + configDir, errConfig := config.GetConfigDir() + if errConfig == nil { + keysPath := filepath.Join(configDir, "openbao-keys", cfg.Cluster.Name, "keys.json") + if keysData, errRead := os.ReadFile(keysPath); errRead == nil { + var keys struct { + RootToken string `json:"root_token"` + } + if errUnmarshal := json.Unmarshal(keysData, &keys); errUnmarshal == nil { + openBAOToken = keys.RootToken } } } @@ -890,11 +897,10 @@ func ensureDNSAPIKey(stackConfig *config.Config) (string, error) { } // Get OpenBAO address from config - addr, err := stackConfig.GetPrimaryOpenBAOAddress() + openBAOAddr, err := stackConfig.GetPrimaryOpenBAOURL() if err != nil { return "", fmt.Errorf("OpenBAO host not configured: %w", err) } - openBAOAddr := fmt.Sprintf("http://%s:8200", addr) // Get OpenBAO token from keys file keysPath := filepath.Join(configDir, "openbao-keys", stackConfig.Cluster.Name, "keys.json") @@ -1056,12 +1062,12 @@ func buildExternalTargetsFromStackConfig(stackConfig *config.Config) []prometheu return targets } - // OpenBAO metrics at /v1/sys/metrics?format=prometheus on port 8200 + // OpenBAO metrics at /v1/sys/metrics?format=prometheus if stackConfig.SetupState.OpenBAOInstalled { - if addr, err := stackConfig.GetPrimaryOpenBAOAddress(); err == nil { + if addr, err := stackConfig.GetOpenBAOClientAddr(); err == nil { targets = append(targets, prometheus.ExternalTarget{ Name: "openbao", - Targets: []string{fmt.Sprintf("%s:8200", addr)}, + Targets: []string{addr}, MetricsPath: "/v1/sys/metrics", Params: map[string][]string{ "format": {"prometheus"}, diff --git a/v1/cmd/foundry/commands/component/status.go b/v1/cmd/foundry/commands/component/status.go index c6796b6..a5288d3 100644 --- a/v1/cmd/foundry/commands/component/status.go +++ b/v1/cmd/foundry/commands/component/status.go @@ -160,7 +160,15 @@ func CheckOpenBAOStatus(ctx context.Context, cfg *config.Config) (*component.Com version := "" if healthy { - healthJSON, err := conn.Exec("curl -s http://localhost:8200/v1/sys/health") + openbaoURL, err := cfg.GetPrimaryOpenBAOURL() + if err != nil { + return &component.ComponentStatus{ + Installed: false, + Healthy: false, + Message: fmt.Sprintf("failed to get OpenBAO URL: %v", err), + }, nil + } + healthJSON, err := conn.Exec(fmt.Sprintf("curl -s %s/v1/sys/health", openbaoURL)) if err == nil && healthJSON.ExitCode == 0 { var healthData struct { Initialized bool `json:"initialized"` diff --git a/v1/cmd/foundry/commands/dns/zone.go b/v1/cmd/foundry/commands/dns/zone.go index 13e1d08..2be3b95 100644 --- a/v1/cmd/foundry/commands/dns/zone.go +++ b/v1/cmd/foundry/commands/dns/zone.go @@ -469,8 +469,8 @@ func buildSecretResolver(cfg *config.Config) (*secrets.ChainResolver, *secrets.R // Try to get OpenBAO address and token var openBAOAddr, openBAOToken string - if addr, err := cfg.GetPrimaryOpenBAOAddress(); err == nil { - openBAOAddr = fmt.Sprintf("http://%s:8200", addr) + if addr, err := cfg.GetPrimaryOpenBAOURL(); err == nil { + openBAOAddr = addr // Try to read OpenBAO token from keys file configDir, err := config.GetConfigDir() diff --git a/v1/cmd/foundry/commands/grafana/commands.go b/v1/cmd/foundry/commands/grafana/commands.go index 3f9192e..76e0626 100644 --- a/v1/cmd/foundry/commands/grafana/commands.go +++ b/v1/cmd/foundry/commands/grafana/commands.go @@ -156,11 +156,10 @@ func getOpenBAOClient() (*openbao.Client, error) { } // Get OpenBAO address - addr, err := stackConfig.GetPrimaryOpenBAOAddress() + openBAOAddr, err := stackConfig.GetPrimaryOpenBAOURL() if err != nil { return nil, fmt.Errorf("OpenBAO host not configured: %w", err) } - openBAOAddr := fmt.Sprintf("http://%s:8200", addr) // Get config directory for OpenBAO keys configDir, err := config.GetConfigDir() diff --git a/v1/cmd/foundry/commands/openbao/unseal.go b/v1/cmd/foundry/commands/openbao/unseal.go index 882bafa..2eb2f60 100644 --- a/v1/cmd/foundry/commands/openbao/unseal.go +++ b/v1/cmd/foundry/commands/openbao/unseal.go @@ -41,11 +41,10 @@ func runUnseal(ctx context.Context, cmd *cli.Command) error { } // Get OpenBAO API URL from config - apiURL, err := stackConfig.GetPrimaryOpenBAOAddress() + apiURL, err := stackConfig.GetPrimaryOpenBAOURL() if err != nil { return fmt.Errorf("failed to get OpenBAO address: %w", err) } - apiURL = fmt.Sprintf("http://%s:8200", apiURL) // Get cluster name for keys clusterName := stackConfig.Cluster.Name diff --git a/v1/cmd/foundry/commands/stack/install.go b/v1/cmd/foundry/commands/stack/install.go index f91bab0..f10f123 100644 --- a/v1/cmd/foundry/commands/stack/install.go +++ b/v1/cmd/foundry/commands/stack/install.go @@ -23,9 +23,9 @@ import ( "github.com/catalystcommunity/foundry/v1/internal/component/gatewayapi" "github.com/catalystcommunity/foundry/v1/internal/component/grafana" "github.com/catalystcommunity/foundry/v1/internal/component/loki" - "github.com/catalystcommunity/foundry/v1/internal/component/seaweedfs" "github.com/catalystcommunity/foundry/v1/internal/component/openbao" "github.com/catalystcommunity/foundry/v1/internal/component/prometheus" + "github.com/catalystcommunity/foundry/v1/internal/component/seaweedfs" "github.com/catalystcommunity/foundry/v1/internal/component/storage" "github.com/catalystcommunity/foundry/v1/internal/component/velero" "github.com/catalystcommunity/foundry/v1/internal/config" @@ -1093,11 +1093,10 @@ resources: // getDNSAPIKeyFromOpenBAO retrieves the DNS API key from OpenBAO func getDNSAPIKeyFromOpenBAO(ctx context.Context, cfg *config.Config, configDir string) (string, error) { // Get OpenBAO address from config - addr, err := cfg.GetPrimaryOpenBAOAddress() + openBAOAddr, err := cfg.GetPrimaryOpenBAOURL() if err != nil { return "", err } - openBAOAddr := fmt.Sprintf("http://%s:8200", addr) // Get OpenBAO token from keys file keysPath := filepath.Join(configDir, "openbao-keys", cfg.Cluster.Name, "keys.json") @@ -1795,9 +1794,8 @@ func buildComponentConfig(ctx context.Context, cfg *config.Config, componentName switch componentName { case "openbao": // OpenBAO needs API URL for initialization - addr, err := cfg.GetPrimaryOpenBAOAddress() - if err == nil { - compCfg["api_url"] = fmt.Sprintf("http://%s:8200", addr) + if url, err := cfg.GetPrimaryOpenBAOURL(); err == nil { + compCfg["api_url"] = url } case "dns", "powerdns": @@ -2226,11 +2224,10 @@ func ensureDNSAPIKey(stackConfig *config.Config) (string, error) { } // Get OpenBAO address from config - addr, err := stackConfig.GetPrimaryOpenBAOAddress() + openBAOAddr, err := stackConfig.GetPrimaryOpenBAOURL() if err != nil { return "", fmt.Errorf("OpenBAO host not configured: %w", err) } - openBAOAddr := fmt.Sprintf("http://%s:8200", addr) // Get OpenBAO token from keys file keysPath := filepath.Join(configDir, "openbao-keys", stackConfig.Cluster.Name, "keys.json") @@ -2293,11 +2290,10 @@ func generateDNSAPIKey() (string, error) { // This enables authenticated pulls to avoid Docker Hub rate limiting func getZotDockerHubCredentials(cfg *config.Config, configDir string) (username, password string, err error) { // Get OpenBAO address from config - addr, err := cfg.GetPrimaryOpenBAOAddress() + openBAOAddr, err := cfg.GetPrimaryOpenBAOURL() if err != nil { return "", "", err } - openBAOAddr := fmt.Sprintf("http://%s:8200", addr) // Get OpenBAO token from keys file keysPath := filepath.Join(configDir, "openbao-keys", cfg.Cluster.Name, "keys.json") diff --git a/v1/cmd/foundry/registry/init.go b/v1/cmd/foundry/registry/init.go index f4f3837..e4e8377 100644 --- a/v1/cmd/foundry/registry/init.go +++ b/v1/cmd/foundry/registry/init.go @@ -10,9 +10,10 @@ import ( "github.com/catalystcommunity/foundry/v1/internal/component/grafana" "github.com/catalystcommunity/foundry/v1/internal/component/k3s" "github.com/catalystcommunity/foundry/v1/internal/component/loki" - "github.com/catalystcommunity/foundry/v1/internal/component/seaweedfs" "github.com/catalystcommunity/foundry/v1/internal/component/openbao" + "github.com/catalystcommunity/foundry/v1/internal/component/openbaoinjector" "github.com/catalystcommunity/foundry/v1/internal/component/prometheus" + "github.com/catalystcommunity/foundry/v1/internal/component/seaweedfs" "github.com/catalystcommunity/foundry/v1/internal/component/storage" "github.com/catalystcommunity/foundry/v1/internal/component/velero" "github.com/catalystcommunity/foundry/v1/internal/component/zot" @@ -43,6 +44,14 @@ func InitComponents() error { return err } + // Register OpenBao agent injector - depends on OpenBAO and K3s + // Installs the MutatingWebhookConfiguration so pods can receive secrets + // from OpenBao via vault.hashicorp.com/agent-inject annotations + openbaoInjectorComp := openbaoinjector.NewComponent(nil) + if err := component.Register(openbaoInjectorComp); err != nil { + return err + } + // Register Gateway API - depends on K3s // Gateway API CRDs are installed as a cluster-level feature, independent of ingress controllers gatewayAPIComp := gatewayapi.NewComponent(nil) diff --git a/v1/internal/component/openbao/types.go b/v1/internal/component/openbao/types.go index 71f55dc..74cd5cf 100644 --- a/v1/internal/component/openbao/types.go +++ b/v1/internal/component/openbao/types.go @@ -8,6 +8,8 @@ import ( "github.com/catalystcommunity/foundry/v1/internal/container" ) +const DefaultPort = 8200 + // Component implements the component.Component interface for OpenBAO type Component struct { conn container.SSHExecutor diff --git a/v1/internal/component/openbaoinjector/install.go b/v1/internal/component/openbaoinjector/install.go new file mode 100644 index 0000000..c857c69 --- /dev/null +++ b/v1/internal/component/openbaoinjector/install.go @@ -0,0 +1,121 @@ +package openbaoinjector + +import ( + "context" + "fmt" + "time" + + "github.com/catalystcommunity/foundry/v1/internal/helm" +) + +const ( + // DefaultNamespace is the namespace where the injector will be installed + DefaultNamespace = "openbao" + + // ReleaseName is the Helm release name + ReleaseName = "openbao-injector" + + // Helm repo constants + repoName = "openbao" + repoURL = "https://openbao.github.io/openbao-helm" + chart = "openbao/openbao" + + installTimeout = 5 * time.Minute +) + +// TODO: After Helm install, automate Kubernetes auth setup in OpenBao so pods can authenticate: +// 1. Create vault-reviewer ServiceAccount + ClusterRoleBinding (system:auth-delegator) in kube-system +// 2. vault auth enable kubernetes +// 3. vault write auth/kubernetes/config kubernetes_host="https://:443" +// kubernetes_ca_cert= token_reviewer_jwt= disable_iss_validation=true +// Note: kubernetes_host must use the ClusterIP (not Tailscale hostname) so it's reachable from the host node. +// Note: disable_iss_validation=true is required because the JWT issuer is an in-cluster DNS name. + +// Install installs the OpenBao agent injector using Helm. +// Only the injector is deployed (server.enabled=false). The injector registers +// a MutatingWebhookConfiguration so that pods with vault.hashicorp.com/agent-inject +// annotations automatically get secrets mounted from OpenBao. +func Install(ctx context.Context, helmClient HelmClient, cfg *Config) error { + if helmClient == nil { + return fmt.Errorf("helm client cannot be nil") + } + if cfg == nil { + return fmt.Errorf("config cannot be nil") + } + + // Add OpenBao Helm repository + if err := helmClient.AddRepo(ctx, helm.RepoAddOptions{ + Name: repoName, + URL: repoURL, + ForceUpdate: true, + }); err != nil { + return fmt.Errorf("failed to add openbao helm repo: %w", err) + } + + values := buildHelmValues(cfg) + + // Check if release already exists + releases, err := helmClient.List(ctx, cfg.Namespace) + if err == nil { + for _, rel := range releases { + if rel.Name == ReleaseName { + if rel.Status == "deployed" { + fmt.Println(" Upgrading existing OpenBao injector deployment...") + return helmClient.Upgrade(ctx, helm.UpgradeOptions{ + ReleaseName: ReleaseName, + Namespace: cfg.Namespace, + Chart: chart, + Version: cfg.Version, + Values: values, + Wait: true, + Timeout: installTimeout, + }) + } + // Failed/pending release — uninstall and reinstall + fmt.Printf(" Removing failed release (status: %s)...\n", rel.Status) + if err := helmClient.Uninstall(ctx, helm.UninstallOptions{ + ReleaseName: ReleaseName, + Namespace: cfg.Namespace, + }); err != nil { + return fmt.Errorf("failed to remove existing release: %w", err) + } + break + } + } + } + + if err := helmClient.Install(ctx, helm.InstallOptions{ + ReleaseName: ReleaseName, + Namespace: cfg.Namespace, + Chart: chart, + Version: cfg.Version, + Values: values, + CreateNamespace: true, + Wait: true, + Timeout: installTimeout, + }); err != nil { + return fmt.Errorf("failed to install openbao injector: %w", err) + } + + fmt.Printf(" ✓ OpenBao agent injector installed\n") + fmt.Printf(" ✓ MutatingWebhookConfiguration registered\n") + fmt.Printf(" Pods annotated with vault.hashicorp.com/agent-inject=true will now\n") + fmt.Printf(" automatically receive secrets from OpenBao at %s\n", cfg.ExternalVaultAddr) + + return nil +} + +// buildHelmValues constructs the Helm values for injector-only installation. +// server.enabled=false — we already have OpenBao running on the host. +// injector.enabled=true — this is the only thing we're installing. +func buildHelmValues(cfg *Config) map[string]interface{} { + return map[string]interface{}{ + "server": map[string]interface{}{ + "enabled": false, + }, + "injector": map[string]interface{}{ + "enabled": true, + "externalVaultAddr": cfg.ExternalVaultAddr, + }, + } +} diff --git a/v1/internal/component/openbaoinjector/install_test.go b/v1/internal/component/openbaoinjector/install_test.go new file mode 100644 index 0000000..2f559e0 --- /dev/null +++ b/v1/internal/component/openbaoinjector/install_test.go @@ -0,0 +1,279 @@ +package openbaoinjector + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/catalystcommunity/foundry/v1/internal/helm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockHelmClient struct { + addRepoCalls []helm.RepoAddOptions + installCalls []helm.InstallOptions + upgradeCalls []helm.UpgradeOptions + uninstallCalls []helm.UninstallOptions + listResponse []helm.Release + listErr error + addRepoErr error + installErr error + upgradeErr error + uninstallErr error +} + +func (m *mockHelmClient) AddRepo(ctx context.Context, opts helm.RepoAddOptions) error { + m.addRepoCalls = append(m.addRepoCalls, opts) + if m.addRepoErr != nil { + return m.addRepoErr + } + return nil +} + +func (m *mockHelmClient) Install(ctx context.Context, opts helm.InstallOptions) error { + m.installCalls = append(m.installCalls, opts) + if m.installErr != nil { + return m.installErr + } + return nil +} + +func (m *mockHelmClient) Upgrade(ctx context.Context, opts helm.UpgradeOptions) error { + m.upgradeCalls = append(m.upgradeCalls, opts) + if m.upgradeErr != nil { + return m.upgradeErr + } + return nil +} + +func (m *mockHelmClient) Uninstall(ctx context.Context, opts helm.UninstallOptions) error { + m.uninstallCalls = append(m.uninstallCalls, opts) + if m.uninstallErr != nil { + return m.uninstallErr + } + return nil +} + +func (m *mockHelmClient) List(ctx context.Context, namespace string) ([]helm.Release, error) { + return m.listResponse, m.listErr +} + +func TestInstall_Success(t *testing.T) { + mock := &mockHelmClient{} + cfg := &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), mock, cfg) + + require.NoError(t, err) + assert.Len(t, mock.addRepoCalls, 1) + assert.Equal(t, "openbao", mock.addRepoCalls[0].Name) + assert.Equal(t, "https://openbao.github.io/openbao-helm", mock.addRepoCalls[0].URL) + assert.Len(t, mock.installCalls, 1) + assert.Equal(t, "openbao-injector", mock.installCalls[0].ReleaseName) +} + +func TestInstall_NilHelmClient(t *testing.T) { + cfg := &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), nil, cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "helm client cannot be nil") +} + +func TestInstall_NilConfig(t *testing.T) { + mock := &mockHelmClient{} + + err := Install(context.Background(), mock, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "config cannot be nil") +} + +func TestInstall_AddRepoFailure(t *testing.T) { + mock := &mockHelmClient{ + addRepoErr: errors.New("failed to add repo"), + } + cfg := &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), mock, cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to add openbao helm repo") +} + +func TestInstall_UpgradeExisting(t *testing.T) { + mock := &mockHelmClient{ + listResponse: []helm.Release{ + { + Name: "openbao-injector", + Status: "deployed", + AppVersion: "0.26.2", + }, + }, + } + cfg := &Config{ + Version: "0.26.3", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), mock, cfg) + + require.NoError(t, err) + assert.Len(t, mock.upgradeCalls, 1) + assert.Equal(t, "openbao-injector", mock.upgradeCalls[0].ReleaseName) + assert.Equal(t, "0.26.3", mock.upgradeCalls[0].Version) + assert.Len(t, mock.installCalls, 0) +} + +func TestInstall_ReplaceFailed(t *testing.T) { + mock := &mockHelmClient{ + listResponse: []helm.Release{ + { + Name: "openbao-injector", + Status: "failed", + AppVersion: "0.26.2", + }, + }, + } + cfg := &Config{ + Version: "0.26.3", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), mock, cfg) + + require.NoError(t, err) + assert.Len(t, mock.uninstallCalls, 1) + assert.Len(t, mock.installCalls, 1) +} + +func TestInstall_ReplaceFailedUninstallError(t *testing.T) { + mock := &mockHelmClient{ + listResponse: []helm.Release{ + { + Name: "openbao-injector", + Status: "failed", + AppVersion: "0.26.2", + }, + }, + uninstallErr: errors.New("failed to uninstall"), + } + cfg := &Config{ + Version: "0.26.3", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), mock, cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to remove existing release") +} + +func TestInstall_InstallFailure(t *testing.T) { + mock := &mockHelmClient{ + installErr: errors.New("failed to install"), + } + cfg := &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + err := Install(context.Background(), mock, cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to install openbao injector") +} + +func TestBuildHelmValues(t *testing.T) { + cfg := &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + } + + values := buildHelmValues(cfg) + + serverConfig, ok := values["server"].(map[string]interface{}) + require.True(t, ok, "server should be a map") + assert.Equal(t, false, serverConfig["enabled"]) + + injectorConfig, ok := values["injector"].(map[string]interface{}) + require.True(t, ok, "injector should be a map") + assert.Equal(t, true, injectorConfig["enabled"]) + assert.Equal(t, "http://10.0.0.1:8200", injectorConfig["externalVaultAddr"]) +} + +func TestComponent_Install(t *testing.T) { + tests := []struct { + name string + cfg map[string]interface{} + setupMock func(*mockHelmClient) + wantErr bool + errContains string + }{ + { + name: "successful install", + cfg: map[string]interface{}{ + "external_vault_addr": "http://10.0.0.1:8200", + }, + setupMock: func(m *mockHelmClient) { + m.listResponse = []helm.Release{} + }, + wantErr: false, + }, + { + name: "missing external_vault_addr", + cfg: map[string]interface{}{}, + setupMock: func(m *mockHelmClient) {}, + wantErr: true, + errContains: "external_vault_addr is required", + }, + { + name: "helm client error", + cfg: map[string]interface{}{ + "external_vault_addr": "http://10.0.0.1:8200", + }, + setupMock: func(m *mockHelmClient) { + m.addRepoErr = fmt.Errorf("repo error") + }, + wantErr: true, + errContains: "failed to add openbao helm repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockHelmClient{} + tt.setupMock(mock) + + comp := NewComponent(mock) + err := comp.Install(context.Background(), tt.cfg) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/v1/internal/component/openbaoinjector/types.gen.go b/v1/internal/component/openbaoinjector/types.gen.go new file mode 100644 index 0000000..9e4f89a --- /dev/null +++ b/v1/internal/component/openbaoinjector/types.gen.go @@ -0,0 +1,11 @@ +// Package openbaoinjector contains generated types. +// +// Code generated by csilgen; DO NOT EDIT. +package openbaoinjector + +// Config represents a structured data type +type Config struct { + Version string `json:"version" yaml:"version"` + Namespace string `json:"namespace" yaml:"namespace"` + ExternalVaultAddr string `json:"external_vault_addr" yaml:"external_vault_addr"` +} diff --git a/v1/internal/component/openbaoinjector/types.go b/v1/internal/component/openbaoinjector/types.go new file mode 100644 index 0000000..94e1fc9 --- /dev/null +++ b/v1/internal/component/openbaoinjector/types.go @@ -0,0 +1,143 @@ +package openbaoinjector + +import ( + "context" + "fmt" + + "github.com/catalystcommunity/foundry/v1/internal/component" + "github.com/catalystcommunity/foundry/v1/internal/helm" +) + +// HelmClient defines the Helm operations needed for the OpenBao injector +type HelmClient interface { + AddRepo(ctx context.Context, opts helm.RepoAddOptions) error + Install(ctx context.Context, opts helm.InstallOptions) error + Upgrade(ctx context.Context, opts helm.UpgradeOptions) error + Uninstall(ctx context.Context, opts helm.UninstallOptions) error + List(ctx context.Context, namespace string) ([]helm.Release, error) +} + +// Component implements the component.Component interface for the OpenBao agent injector +type Component struct { + helmClient HelmClient +} + +// NewComponent creates a new OpenBao injector component instance +func NewComponent(helmClient HelmClient) *Component { + return &Component{helmClient: helmClient} +} + +// Name returns the component name +func (c *Component) Name() string { + return "openbao-injector" +} + +// Dependencies returns the components this depends on +func (c *Component) Dependencies() []string { + return []string{"openbao", "k3s"} +} + +// Install installs the OpenBao agent injector via Helm +func (c *Component) Install(ctx context.Context, cfg component.ComponentConfig) error { + config, err := ParseConfig(cfg) + if err != nil { + return fmt.Errorf("parse config: %w", err) + } + return Install(ctx, c.helmClient, config) +} + +// Upgrade upgrades the OpenBao agent injector +func (c *Component) Upgrade(ctx context.Context, cfg component.ComponentConfig) error { + return fmt.Errorf("upgrade not yet implemented") +} + +// Status returns the current status of the OpenBao agent injector +func (c *Component) Status(ctx context.Context) (*component.ComponentStatus, error) { + if c.helmClient == nil { + return &component.ComponentStatus{ + Installed: false, + Healthy: false, + Message: "helm client not initialized", + }, nil + } + + releases, err := c.helmClient.List(ctx, DefaultNamespace) + if err != nil { + return &component.ComponentStatus{ + Installed: false, + Healthy: false, + Message: fmt.Sprintf("failed to list releases: %v", err), + }, nil + } + + for _, rel := range releases { + if rel.Name == ReleaseName { + healthy := rel.Status == "deployed" + msg := fmt.Sprintf("release status: %s", rel.Status) + if healthy { + msg = "injector webhook running" + } + return &component.ComponentStatus{ + Installed: true, + Version: rel.AppVersion, + Healthy: healthy, + Message: msg, + }, nil + } + } + + return &component.ComponentStatus{ + Installed: false, + Healthy: false, + Message: "release not found", + }, nil +} + +// Uninstall removes the OpenBao agent injector +func (c *Component) Uninstall(ctx context.Context) error { + return fmt.Errorf("uninstall not yet implemented") +} + +// DefaultConfig returns a Config with sensible defaults +func DefaultConfig() *Config { + return &Config{ + Version: "0.26.2", + Namespace: DefaultNamespace, + ExternalVaultAddr: "", + } +} + +// ParseConfig parses a ComponentConfig into an openbaoinjector Config +func ParseConfig(cfg component.ComponentConfig) (*Config, error) { + config := DefaultConfig() + + if version, ok := cfg.GetString("version"); ok { + config.Version = version + } + if namespace, ok := cfg.GetString("namespace"); ok { + config.Namespace = namespace + } + if addr, ok := cfg.GetString("external_vault_addr"); ok { + config.ExternalVaultAddr = addr + } + + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + return config, nil +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Version == "" { + return fmt.Errorf("version is required") + } + if c.Namespace == "" { + return fmt.Errorf("namespace is required") + } + if c.ExternalVaultAddr == "" { + return fmt.Errorf("external_vault_addr is required — set it to your OpenBao address (e.g. http://100.81.89.62:8200)") + } + return nil +} diff --git a/v1/internal/component/openbaoinjector/types_test.go b/v1/internal/component/openbaoinjector/types_test.go new file mode 100644 index 0000000..987288c --- /dev/null +++ b/v1/internal/component/openbaoinjector/types_test.go @@ -0,0 +1,172 @@ +package openbaoinjector + +import ( + "testing" + + "github.com/catalystcommunity/foundry/v1/internal/component" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + assert.Equal(t, "0.26.2", cfg.Version) + assert.Equal(t, "openbao", cfg.Namespace) + assert.Equal(t, "", cfg.ExternalVaultAddr) +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr bool + errMsg string + }{ + { + name: "valid config", + cfg: &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + }, + wantErr: false, + }, + { + name: "missing version", + cfg: &Config{ + Version: "", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + }, + wantErr: true, + errMsg: "version is required", + }, + { + name: "missing namespace", + cfg: &Config{ + Version: "0.26.2", + Namespace: "", + ExternalVaultAddr: "http://10.0.0.1:8200", + }, + wantErr: true, + errMsg: "namespace is required", + }, + { + name: "missing external_vault_addr", + cfg: &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "", + }, + wantErr: true, + errMsg: "external_vault_addr is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input component.ComponentConfig + expected *Config + wantErr bool + errMsg string + }{ + { + name: "empty config fails - missing external_vault_addr", + input: component.ComponentConfig{}, + wantErr: true, + errMsg: "external_vault_addr is required", + }, + { + name: "valid config with defaults", + input: component.ComponentConfig{ + "external_vault_addr": "http://10.0.0.1:8200", + }, + expected: &Config{ + Version: "0.26.2", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + }, + wantErr: false, + }, + { + name: "override version", + input: component.ComponentConfig{ + "external_vault_addr": "http://10.0.0.1:8200", + "version": "0.27.0", + }, + expected: &Config{ + Version: "0.27.0", + Namespace: "openbao", + ExternalVaultAddr: "http://10.0.0.1:8200", + }, + wantErr: false, + }, + { + name: "override namespace", + input: component.ComponentConfig{ + "external_vault_addr": "http://10.0.0.1:8200", + "namespace": "custom-ns", + }, + expected: &Config{ + Version: "0.26.2", + Namespace: "custom-ns", + ExternalVaultAddr: "http://10.0.0.1:8200", + }, + wantErr: false, + }, + { + name: "override all fields", + input: component.ComponentConfig{ + "external_vault_addr": "https://openbao.example.com:8200", + "version": "0.27.0", + "namespace": "vaultns", + }, + expected: &Config{ + Version: "0.27.0", + Namespace: "vaultns", + ExternalVaultAddr: "https://openbao.example.com:8200", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseConfig(tt.input) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestComponent_Name(t *testing.T) { + comp := NewComponent(nil) + assert.Equal(t, "openbao-injector", comp.Name()) +} + +func TestComponent_Dependencies(t *testing.T) { + comp := NewComponent(nil) + deps := comp.Dependencies() + assert.Contains(t, deps, "openbao") + assert.Contains(t, deps, "k3s") +} diff --git a/v1/internal/config/helpers.go b/v1/internal/config/helpers.go index feff4b0..ffb1a5d 100644 --- a/v1/internal/config/helpers.go +++ b/v1/internal/config/helpers.go @@ -2,10 +2,18 @@ package config import ( "fmt" + "os" + "strconv" "github.com/catalystcommunity/foundry/v1/internal/host" ) +const ( + OpenBAODefaultPort = 8200 + OpenBAOAddrEnvVar = "OPENBAO_ADDR" + OpenBAOClientAddrEnv = "OPENBAO_CLIENT_ADDR" +) + // GetHostsByRole returns all hosts that have the specified role func (c *Config) GetHostsByRole(role string) []*host.Host { var hosts []*host.Host @@ -104,6 +112,47 @@ func (c *Config) GetPrimaryOpenBAOAddress() (string, error) { return h.Address, nil } +// GetPrimaryOpenBAOAddr returns the full address with port for the first OpenBAO host +func (c *Config) GetPrimaryOpenBAOAddr() (string, error) { + h, err := c.GetPrimaryOpenBAOHost() + if err != nil { + return "", err + } + return fmt.Sprintf("%s:%d", h.Address, OpenBAODefaultPort), nil +} + +// GetPrimaryOpenBAOURL returns the full HTTP URL for the first OpenBAO host +// It first checks the OPENBAO_ADDR environment variable, then falls back to config +func (c *Config) GetPrimaryOpenBAOURL() (string, error) { + if addr := os.Getenv(OpenBAOAddrEnvVar); addr != "" { + return addr, nil + } + addr, err := c.GetPrimaryOpenBAOAddr() + if err != nil { + return "", err + } + return fmt.Sprintf("http://%s", addr), nil +} + +// GetOpenBAOClientAddr returns the client address for OpenBAO connections +// It first checks OPENBAO_CLIENT_ADDR env var, then falls back to config address +func (c *Config) GetOpenBAOClientAddr() (string, error) { + if addr := os.Getenv(OpenBAOClientAddrEnv); addr != "" { + return addr, nil + } + return c.GetPrimaryOpenBAOAddr() +} + +// GetOpenBAOPort returns the OpenBAO port, first checking OPENBAO_PORT env var +func (c *Config) GetOpenBAOPort() int { + if port := os.Getenv("OPENBAO_PORT"); port != "" { + if p, err := strconv.Atoi(port); err == nil { + return p + } + } + return OpenBAODefaultPort +} + // GetPrimaryDNSHost returns the first DNS host func (c *Config) GetPrimaryDNSHost() (*host.Host, error) { hosts := c.GetDNSHosts() From bf18bf7b12e5464200b97fcf04f2ea23c75e6098 Mon Sep 17 00:00:00 2001 From: soypete Date: Wed, 15 Apr 2026 21:33:10 -0700 Subject: [PATCH 2/3] feat: add Kubernetes auth automation for OpenBao agent injector - Add configure_k8s_auth boolean and k8s_auth_roles array to CSIL config - Add K8sClient and OpenBAOClient interfaces to openbaoinjector types - Add CreateServiceAccount, GetServiceAccountToken, GetClusterCACert, ApplyClusterRoleBinding, GetKubernetesHost methods to k8s/client.go - Add EnableAuth, WriteAuthConfig, WriteRole methods to openbao/client.go - Add configureKubernetesAuth function that: - Creates vault-reviewer SA in kube-system - Creates ClusterRoleBinding with system:auth-delegator role - Gets SA token and cluster CA from k8s client - Enables kubernetes auth in OpenBao - Writes auth config with disable_iss_validation=true - Creates roles for configured apps - Update registry/init.go and install.go to use new signature --- csil/v1/components/openbao-injector.csil | 17 +- v1/cmd/foundry/commands/component/install.go | 40 +++- v1/cmd/foundry/registry/init.go | 2 +- v1/internal/component/openbao/client.go | 40 ++++ .../component/openbaoinjector/install.go | 186 +++++++++++++++--- .../component/openbaoinjector/types.gen.go | 17 +- .../component/openbaoinjector/types.go | 71 ++++++- v1/internal/k8s/client.go | 109 ++++++++-- 8 files changed, 421 insertions(+), 61 deletions(-) diff --git a/csil/v1/components/openbao-injector.csil b/csil/v1/components/openbao-injector.csil index f4c21ba..c803042 100644 --- a/csil/v1/components/openbao-injector.csil +++ b/csil/v1/components/openbao-injector.csil @@ -8,10 +8,21 @@ options { go_package: "github.com/catalystcommunity/foundry/v1/internal/component/openbaoinjector" } +; K8sAuthRole defines a Kubernetes auth role for a specific app +K8sAuthRole = { + role_name: text, ; Role name in OpenBao (e.g., "redditwatch") + service_account_name: text, ; Service account name to bind (e.g., "redditwatch") + service_account_namespace: text, ; Namespace where the SA exists (e.g., "default") + policies: text, ; Comma-separated policies to grant (e.g., "foundry-core-read") + ttl: text, ; Token TTL (e.g., "1h"), default: "1h" +} + ; OpenBao Agent Injector component configuration ; Default values will be set in Go code DefaultConfig() function Config = { - version: text, ; Default: "0.26.2" - namespace: text, ; Default: "openbao" - external_vault_addr: text, ; Required: OpenBao address (e.g. http://100.81.89.62:8200) + version: text, ; Default: "0.26.2" + namespace: text, ; Default: "openbao" + external_vault_addr: text, ; Required: OpenBao address (e.g. http://100.81.89.62:8200) + configure_k8s_auth: boolean, ; Enable Kubernetes auth in OpenBao (default: false) + k8s_auth_roles: [K8sAuthRole], ; List of K8s auth roles to create } \ No newline at end of file diff --git a/v1/cmd/foundry/commands/component/install.go b/v1/cmd/foundry/commands/component/install.go index 0a78729..4482c12 100644 --- a/v1/cmd/foundry/commands/component/install.go +++ b/v1/cmd/foundry/commands/component/install.go @@ -305,7 +305,13 @@ func installK8sComponent(ctx context.Context, cmd *cli.Command, name string, sta return fmt.Errorf("failed to get OpenBao address for injector: %w", err) } cfg["external_vault_addr"] = url - componentWithClients = openbaoinjector.NewComponent(helmClient) + + // Create OpenBAO client for configuring Kubernetes auth + openbaoClient, err := createOpenBAOClient(stackConfig) + if err != nil { + return fmt.Errorf("failed to create OpenBAO client: %w", err) + } + componentWithClients = openbaoinjector.NewComponent(helmClient, k8sClient, openbaoClient) default: return fmt.Errorf("unknown kubernetes component: %s", name) } @@ -388,6 +394,38 @@ func getDNSAPIKey(stackConfig *config.Config) (string, error) { return "", fmt.Errorf("api_key not found in OpenBAO") } +// createOpenBAOClient creates an OpenBAO client from stack config +func createOpenBAOClient(stackConfig *config.Config) (*openbao.Client, error) { + configDir, err := config.GetConfigDir() + if err != nil { + return nil, err + } + + openBAOAddr, err := stackConfig.GetPrimaryOpenBAOURL() + if err != nil { + return nil, err + } + + keysPath := filepath.Join(configDir, "openbao-keys", stackConfig.Cluster.Name, "keys.json") + keysData, err := os.ReadFile(keysPath) + if err != nil { + return nil, fmt.Errorf("failed to read OpenBAO keys file: %w", err) + } + + var keys struct { + RootToken string `json:"root_token"` + } + if err := json.Unmarshal(keysData, &keys); err != nil { + return nil, fmt.Errorf("failed to parse OpenBAO keys file: %w", err) + } + + if keys.RootToken == "" { + return nil, fmt.Errorf("root token not found in keys file") + } + + return openbao.NewClient(openBAOAddr, keys.RootToken), nil +} + // installSSHComponent installs a container-based component via SSH func installSSHComponent(ctx context.Context, cmd *cli.Command, name string, stackConfig *config.Config, dryRun bool, version string) error { // Determine target host for this component diff --git a/v1/cmd/foundry/registry/init.go b/v1/cmd/foundry/registry/init.go index e4e8377..bda05a3 100644 --- a/v1/cmd/foundry/registry/init.go +++ b/v1/cmd/foundry/registry/init.go @@ -47,7 +47,7 @@ func InitComponents() error { // Register OpenBao agent injector - depends on OpenBAO and K3s // Installs the MutatingWebhookConfiguration so pods can receive secrets // from OpenBao via vault.hashicorp.com/agent-inject annotations - openbaoInjectorComp := openbaoinjector.NewComponent(nil) + openbaoInjectorComp := openbaoinjector.NewComponent(nil, nil, nil) if err := component.Register(openbaoInjectorComp); err != nil { return err } diff --git a/v1/internal/component/openbao/client.go b/v1/internal/component/openbao/client.go index a7255c7..e2a5ccd 100644 --- a/v1/internal/component/openbao/client.go +++ b/v1/internal/component/openbao/client.go @@ -264,3 +264,43 @@ func (c *Client) EnableKVv2Engine(ctx context.Context, mount string) error { return readResponse(resp, nil) } + +// EnableAuth enables an auth method at the specified path +func (c *Client) EnableAuth(ctx context.Context, authType string) error { + apiPath := fmt.Sprintf("/v1/sys/auth/%s", authType) + + body := map[string]interface{}{ + "type": authType, + } + + resp, err := c.doRequest(ctx, "POST", apiPath, body) + if err != nil { + return fmt.Errorf("failed to enable %s auth: %w", authType, err) + } + + return readResponse(resp, nil) +} + +// WriteAuthConfig writes configuration for an auth method +func (c *Client) WriteAuthConfig(ctx context.Context, authPath string, data map[string]interface{}) error { + apiPath := fmt.Sprintf("/v1/auth/%s/config", authPath) + + resp, err := c.doRequest(ctx, "POST", apiPath, data) + if err != nil { + return fmt.Errorf("failed to write auth config for %s: %w", authPath, err) + } + + return readResponse(resp, nil) +} + +// WriteRole writes a role for an auth method +func (c *Client) WriteRole(ctx context.Context, authPath, roleName string, data map[string]interface{}) error { + apiPath := fmt.Sprintf("/v1/auth/%s/role/%s", authPath, roleName) + + resp, err := c.doRequest(ctx, "POST", apiPath, data) + if err != nil { + return fmt.Errorf("failed to write role %s for %s: %w", roleName, authPath, err) + } + + return readResponse(resp, nil) +} diff --git a/v1/internal/component/openbaoinjector/install.go b/v1/internal/component/openbaoinjector/install.go index c857c69..e6fcf54 100644 --- a/v1/internal/component/openbaoinjector/install.go +++ b/v1/internal/component/openbaoinjector/install.go @@ -6,6 +6,8 @@ import ( "time" "github.com/catalystcommunity/foundry/v1/internal/helm" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -21,21 +23,19 @@ const ( chart = "openbao/openbao" installTimeout = 5 * time.Minute -) -// TODO: After Helm install, automate Kubernetes auth setup in OpenBao so pods can authenticate: -// 1. Create vault-reviewer ServiceAccount + ClusterRoleBinding (system:auth-delegator) in kube-system -// 2. vault auth enable kubernetes -// 3. vault write auth/kubernetes/config kubernetes_host="https://:443" -// kubernetes_ca_cert= token_reviewer_jwt= disable_iss_validation=true -// Note: kubernetes_host must use the ClusterIP (not Tailscale hostname) so it's reachable from the host node. -// Note: disable_iss_validation=true is required because the JWT issuer is an in-cluster DNS name. + // vault-reviewer ServiceAccount for Kubernetes auth + vaultReviewerSA = "vault-reviewer" + vaultReviewerClusterRole = "system:auth-delegator" + kubeSystemNamespace = "kube-system" +) // Install installs the OpenBao agent injector using Helm. // Only the injector is deployed (server.enabled=false). The injector registers // a MutatingWebhookConfiguration so that pods with vault.hashicorp.com/agent-inject // annotations automatically get secrets mounted from OpenBao. -func Install(ctx context.Context, helmClient HelmClient, cfg *Config) error { +// If configureK8sAuth is true, it also sets up Kubernetes auth in OpenBao. +func Install(ctx context.Context, helmClient HelmClient, k8sClient K8sClient, openbaoClient OpenBAOClient, cfg *Config, configureK8sAuth bool) error { if helmClient == nil { return fmt.Errorf("helm client cannot be nil") } @@ -61,7 +61,7 @@ func Install(ctx context.Context, helmClient HelmClient, cfg *Config) error { if rel.Name == ReleaseName { if rel.Status == "deployed" { fmt.Println(" Upgrading existing OpenBao injector deployment...") - return helmClient.Upgrade(ctx, helm.UpgradeOptions{ + if err := helmClient.Upgrade(ctx, helm.UpgradeOptions{ ReleaseName: ReleaseName, Namespace: cfg.Namespace, Chart: chart, @@ -69,32 +69,47 @@ func Install(ctx context.Context, helmClient HelmClient, cfg *Config) error { Values: values, Wait: true, Timeout: installTimeout, - }) - } - // Failed/pending release — uninstall and reinstall - fmt.Printf(" Removing failed release (status: %s)...\n", rel.Status) - if err := helmClient.Uninstall(ctx, helm.UninstallOptions{ - ReleaseName: ReleaseName, - Namespace: cfg.Namespace, - }); err != nil { - return fmt.Errorf("failed to remove existing release: %w", err) + }); err != nil { + return fmt.Errorf("failed to upgrade openbao injector: %w", err) + } + } else { + // Failed/pending release — uninstall and reinstall + fmt.Printf(" Removing failed release (status: %s)...\n", rel.Status) + if err := helmClient.Uninstall(ctx, helm.UninstallOptions{ + ReleaseName: ReleaseName, + Namespace: cfg.Namespace, + }); err != nil { + return fmt.Errorf("failed to remove existing release: %w", err) + } } break } } } - if err := helmClient.Install(ctx, helm.InstallOptions{ - ReleaseName: ReleaseName, - Namespace: cfg.Namespace, - Chart: chart, - Version: cfg.Version, - Values: values, - CreateNamespace: true, - Wait: true, - Timeout: installTimeout, - }); err != nil { - return fmt.Errorf("failed to install openbao injector: %w", err) + // Only install if release wasn't already deployed (we didn't upgrade above) + releaseExists := false + releases, _ = helmClient.List(ctx, cfg.Namespace) + for _, rel := range releases { + if rel.Name == ReleaseName && rel.Status == "deployed" { + releaseExists = true + break + } + } + + if !releaseExists { + if err := helmClient.Install(ctx, helm.InstallOptions{ + ReleaseName: ReleaseName, + Namespace: cfg.Namespace, + Chart: chart, + Version: cfg.Version, + Values: values, + CreateNamespace: true, + Wait: true, + Timeout: installTimeout, + }); err != nil { + return fmt.Errorf("failed to install openbao injector: %w", err) + } } fmt.Printf(" ✓ OpenBao agent injector installed\n") @@ -102,6 +117,16 @@ func Install(ctx context.Context, helmClient HelmClient, cfg *Config) error { fmt.Printf(" Pods annotated with vault.hashicorp.com/agent-inject=true will now\n") fmt.Printf(" automatically receive secrets from OpenBao at %s\n", cfg.ExternalVaultAddr) + // Configure Kubernetes auth if requested + if configureK8sAuth { + if k8sClient == nil || openbaoClient == nil { + return fmt.Errorf("k8s client and openbao client are required for k8s auth configuration") + } + if err := configureKubernetesAuth(ctx, k8sClient, openbaoClient, cfg); err != nil { + return fmt.Errorf("failed to configure Kubernetes auth: %w", err) + } + } + return nil } @@ -114,8 +139,105 @@ func buildHelmValues(cfg *Config) map[string]interface{} { "enabled": false, }, "injector": map[string]interface{}{ - "enabled": true, - "externalVaultAddr": cfg.ExternalVaultAddr, + "enabled": true, + "externalVaultAddr": cfg.ExternalVaultAddr, + }, + } +} + +// configureKubernetesAuth sets up Kubernetes auth in OpenBao so pods can authenticate +func configureKubernetesAuth(ctx context.Context, k8sClient K8sClient, openbaoClient OpenBAOClient, cfg *Config) error { + fmt.Println("\n Configuring Kubernetes auth in OpenBao...") + + // 1. Create vault-reviewer ServiceAccount in kube-system + fmt.Println(" Creating vault-reviewer ServiceAccount...") + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: vaultReviewerSA, + Namespace: kubeSystemNamespace, }, } + if err := k8sClient.CreateServiceAccount(ctx, vaultReviewerSA, sa); err != nil { + return fmt.Errorf("failed to create vault-reviewer SA: %w", err) + } + + // 2. Create ClusterRoleBinding to grant auth-delegator role + fmt.Println(" Creating ClusterRoleBinding for vault-reviewer...") + clusterRoleBindingManifest := fmt.Sprintf(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-reviewer-auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: %s +subjects: +- kind: ServiceAccount + name: %s + namespace: %s +`, vaultReviewerClusterRole, vaultReviewerSA, kubeSystemNamespace) + + if err := k8sClient.ApplyClusterRoleBinding(ctx, clusterRoleBindingManifest); err != nil { + return fmt.Errorf("failed to create ClusterRoleBinding: %w", err) + } + + // 3. Get the ServiceAccount token + fmt.Println(" Getting vault-reviewer ServiceAccount token...") + token, err := k8sClient.GetServiceAccountToken(ctx, kubeSystemNamespace, vaultReviewerSA) + if err != nil { + return fmt.Errorf("failed to get SA token: %w", err) + } + + // 4. Get cluster CA from kubeconfig + clusterCA, err := k8sClient.GetClusterCACert(ctx) + if err != nil { + return fmt.Errorf("failed to get cluster CA: %w", err) + } + + // 5. Get Kubernetes API server host from kubeconfig + k8sHost := k8sClient.GetKubernetesHost() + if k8sHost == "" { + return fmt.Errorf("failed to get Kubernetes host from kubeconfig") + } + + // 6. Enable Kubernetes auth method in OpenBao + fmt.Println(" Enabling Kubernetes auth method in OpenBao...") + if err := openbaoClient.EnableAuth(ctx, "kubernetes"); err != nil { + return fmt.Errorf("failed to enable kubernetes auth: %w", err) + } + + // 7. Write auth config - kubernetes_host must use ClusterIP, not Tailscale + // Note: kubernetes_host must use the ClusterIP so it's reachable from the host node + // Also disable_iss_validation=true because JWT issuer is in-cluster DNS name + authConfig := map[string]interface{}{ + "kubernetes_host": k8sHost, + "kubernetes_ca_cert": clusterCA, + "token_reviewer_jwt": token, + "disable_iss_validation": "true", + "disable_local_ca_jwt": "true", + } + + fmt.Println(" Writing Kubernetes auth configuration to OpenBao...") + if err := openbaoClient.WriteAuthConfig(ctx, "kubernetes", authConfig); err != nil { + return fmt.Errorf("failed to write kubernetes auth config: %w", err) + } + + // 7. Create roles for configured apps (from cfg.K8sAuthRoles if present) + for _, role := range cfg.K8sAuthRoles { + roleData := map[string]interface{}{ + "bound_service_account_names": role.ServiceAccountName, + "bound_service_account_namespaces": role.ServiceAccountNamespace, + "policies": role.Policies, + "ttl": role.TTL, + } + fmt.Printf(" Creating Kubernetes auth role: %s\n", role.RoleName) + if err := openbaoClient.WriteRole(ctx, "kubernetes", role.RoleName, roleData); err != nil { + return fmt.Errorf("failed to write role %s: %w", role.RoleName, err) + } + } + + fmt.Println(" ✓ Kubernetes auth configured successfully") + fmt.Println(" Pods can now authenticate to OpenBao using Kubernetes service accounts") + + return nil } diff --git a/v1/internal/component/openbaoinjector/types.gen.go b/v1/internal/component/openbaoinjector/types.gen.go index 9e4f89a..2f46485 100644 --- a/v1/internal/component/openbaoinjector/types.gen.go +++ b/v1/internal/component/openbaoinjector/types.gen.go @@ -3,9 +3,20 @@ // Code generated by csilgen; DO NOT EDIT. package openbaoinjector +// K8sAuthRole defines a Kubernetes auth role for a specific app +type K8sAuthRole struct { + RoleName string `json:"role_name" yaml:"role_name"` + ServiceAccountName string `json:"service_account_name" yaml:"service_account_name"` + ServiceAccountNamespace string `json:"service_account_namespace" yaml:"service_account_namespace"` + Policies string `json:"policies" yaml:"policies"` + TTL string `json:"ttl" yaml:"ttl"` +} + // Config represents a structured data type type Config struct { - Version string `json:"version" yaml:"version"` - Namespace string `json:"namespace" yaml:"namespace"` - ExternalVaultAddr string `json:"external_vault_addr" yaml:"external_vault_addr"` + Version string `json:"version" yaml:"version"` + Namespace string `json:"namespace" yaml:"namespace"` + ExternalVaultAddr string `json:"external_vault_addr" yaml:"external_vault_addr"` + ConfigureK8sAuth bool `json:"configure_k8s_auth" yaml:"configure_k8s_auth"` + K8sAuthRoles []K8sAuthRole `json:"k8s_auth_roles" yaml:"k8s_auth_roles"` } diff --git a/v1/internal/component/openbaoinjector/types.go b/v1/internal/component/openbaoinjector/types.go index 94e1fc9..bfd7052 100644 --- a/v1/internal/component/openbaoinjector/types.go +++ b/v1/internal/component/openbaoinjector/types.go @@ -6,6 +6,7 @@ import ( "github.com/catalystcommunity/foundry/v1/internal/component" "github.com/catalystcommunity/foundry/v1/internal/helm" + corev1 "k8s.io/api/core/v1" ) // HelmClient defines the Helm operations needed for the OpenBao injector @@ -17,14 +18,37 @@ type HelmClient interface { List(ctx context.Context, namespace string) ([]helm.Release, error) } +// K8sClient defines the Kubernetes operations needed for the OpenBao injector +type K8sClient interface { + GetSecret(ctx context.Context, namespace, name string) (*corev1.Secret, error) + CreateServiceAccount(ctx context.Context, name string, sa *corev1.ServiceAccount) error + GetServiceAccountToken(ctx context.Context, namespace, name string) (string, error) + ApplyClusterRoleBinding(ctx context.Context, manifest string) error + GetClusterCACert(ctx context.Context) (string, error) + GetKubernetesHost() string +} + +// OpenBAOClient defines the OpenBAO operations needed for configuring Kubernetes auth +type OpenBAOClient interface { + EnableAuth(ctx context.Context, authType string) error + WriteAuthConfig(ctx context.Context, authPath string, data map[string]interface{}) error + WriteRole(ctx context.Context, authPath, roleName string, data map[string]interface{}) error +} + // Component implements the component.Component interface for the OpenBao agent injector type Component struct { - helmClient HelmClient + helmClient HelmClient + k8sClient K8sClient + openbaoClient OpenBAOClient } // NewComponent creates a new OpenBao injector component instance -func NewComponent(helmClient HelmClient) *Component { - return &Component{helmClient: helmClient} +func NewComponent(helmClient HelmClient, k8sClient K8sClient, openbaoClient OpenBAOClient) *Component { + return &Component{ + helmClient: helmClient, + k8sClient: k8sClient, + openbaoClient: openbaoClient, + } } // Name returns the component name @@ -43,7 +67,8 @@ func (c *Component) Install(ctx context.Context, cfg component.ComponentConfig) if err != nil { return fmt.Errorf("parse config: %w", err) } - return Install(ctx, c.helmClient, config) + + return Install(ctx, c.helmClient, c.k8sClient, c.openbaoClient, config, config.ConfigureK8sAuth) } // Upgrade upgrades the OpenBao agent injector @@ -121,6 +146,44 @@ func ParseConfig(cfg component.ComponentConfig) (*Config, error) { config.ExternalVaultAddr = addr } + // Parse configure_k8s_auth + if configureK8sAuth, ok := cfg.GetBool("configure_k8s_auth"); ok { + config.ConfigureK8sAuth = configureK8sAuth + } + + // Parse k8s_auth_roles + if rolesVal, ok := cfg["k8s_auth_roles"]; ok { + if roles, ok := rolesVal.([]K8sAuthRole); ok { + config.K8sAuthRoles = roles + } else if roleMaps, ok := rolesVal.([]interface{}); ok { + for _, rm := range roleMaps { + if roleMap, ok := rm.(map[string]interface{}); ok { + role := K8sAuthRole{ + TTL: "1h", // Default TTL + } + if rn, ok := roleMap["role_name"].(string); ok { + role.RoleName = rn + } + if san, ok := roleMap["service_account_name"].(string); ok { + role.ServiceAccountName = san + } + if ns, ok := roleMap["service_account_namespace"].(string); ok { + role.ServiceAccountNamespace = ns + } + if pol, ok := roleMap["policies"].(string); ok { + role.Policies = pol + } + if ttl, ok := roleMap["ttl"].(string); ok { + role.TTL = ttl + } + if role.RoleName != "" && role.ServiceAccountName != "" { + config.K8sAuthRoles = append(config.K8sAuthRoles, role) + } + } + } + } + } + if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } diff --git a/v1/internal/k8s/client.go b/v1/internal/k8s/client.go index 2115c14..1a4d17c 100644 --- a/v1/internal/k8s/client.go +++ b/v1/internal/k8s/client.go @@ -3,6 +3,7 @@ package k8s import ( "context" "fmt" + "os" "strings" "time" @@ -243,23 +244,23 @@ func (c *Client) applySingleManifest(ctx context.Context, manifest string) error // isClusterScopedResource returns true if the resource kind is cluster-scoped func isClusterScopedResource(kind string) bool { clusterScoped := map[string]bool{ - "ClusterIssuer": true, - "ClusterRole": true, - "ClusterRoleBinding": true, - "Namespace": true, - "Node": true, - "PersistentVolume": true, - "StorageClass": true, - "CustomResourceDefinition": true, - "PriorityClass": true, - "IngressClass": true, - "RuntimeClass": true, - "VolumeSnapshotClass": true, - "CSIDriver": true, - "CSINode": true, - "ValidatingWebhookConfiguration": true, - "MutatingWebhookConfiguration": true, - "GatewayClass": true, // Gateway API cluster-scoped resource + "ClusterIssuer": true, + "ClusterRole": true, + "ClusterRoleBinding": true, + "Namespace": true, + "Node": true, + "PersistentVolume": true, + "StorageClass": true, + "CustomResourceDefinition": true, + "PriorityClass": true, + "IngressClass": true, + "RuntimeClass": true, + "VolumeSnapshotClass": true, + "CSIDriver": true, + "CSINode": true, + "ValidatingWebhookConfiguration": true, + "MutatingWebhookConfiguration": true, + "GatewayClass": true, // Gateway API cluster-scoped resource } return clusterScoped[kind] } @@ -301,6 +302,80 @@ func (c *Client) GetSecret(ctx context.Context, namespace, name string) (*corev1 return c.clientset.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) } +// CreateServiceAccount creates a ServiceAccount in the specified namespace +func (c *Client) CreateServiceAccount(ctx context.Context, name string, sa *corev1.ServiceAccount) error { + if sa == nil { + sa = &corev1.ServiceAccount{} + } + if sa.ObjectMeta.Name == "" { + sa.ObjectMeta.Name = name + } + _, err := c.clientset.CoreV1().ServiceAccounts(sa.Namespace).Create(ctx, sa, metav1.CreateOptions{}) + if err != nil && strings.Contains(err.Error(), "already exists") { + return nil // Already exists - idempotent + } + return err +} + +// GetServiceAccountToken retrieves the token from a ServiceAccount's secret +func (c *Client) GetServiceAccountToken(ctx context.Context, namespace, name string) (string, error) { + sa, err := c.clientset.CoreV1().ServiceAccounts(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get service account %s/%s: %w", namespace, name, err) + } + + // If the SA has secrets, get the token from the first one + if len(sa.Secrets) > 0 { + secretName := sa.Secrets[0].Name + secret, err := c.clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, secretName, err) + } + token, ok := secret.Data["token"] + if !ok { + return "", fmt.Errorf("token not found in secret %s", secretName) + } + return string(token), nil + } + + // Otherwise, need to create a token manually via TokenRequest + // This requires the ServiceAccount to have annotations or use TokenRequest API + // For now, return error - user should ensure SA has a token secret + return "", fmt.Errorf("no token secret found for service account %s/%s", namespace, name) +} + +// GetClusterCACert returns the cluster CA certificate data +func (c *Client) GetClusterCACert(ctx context.Context) (string, error) { + if c.config == nil { + return "", fmt.Errorf("client config is nil") + } + if c.config.CAData != nil { + return string(c.config.CAData), nil + } + if c.config.CAFile != "" { + data, err := os.ReadFile(c.config.CAFile) + if err != nil { + return "", fmt.Errorf("failed to read CA file: %w", err) + } + return string(data), nil + } + return "", fmt.Errorf("no CA certificate found in config") +} + +// ApplyClusterRoleBinding applies a ClusterRoleBinding manifest +func (c *Client) ApplyClusterRoleBinding(ctx context.Context, manifest string) error { + return c.ApplyManifest(ctx, manifest) +} + +// GetKubernetesHost returns the Kubernetes API server host +// This returns the host from the kubeconfig (e.g., the ClusterIP or hostname) +func (c *Client) GetKubernetesHost() string { + if c.config == nil { + return "" + } + return c.config.Host +} + // CordonNode marks a node as unschedulable func (c *Client) CordonNode(ctx context.Context, nodeName string) error { // Get the node From 438ada7c7e80b77fe2398aced9437b5811846a20 Mon Sep 17 00:00:00 2001 From: soypete Date: Thu, 16 Apr 2026 08:54:33 -0700 Subject: [PATCH 3/3] docs: update pod-secrets.md with automatic k8s auth option --- docs/pod-secrets.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/pod-secrets.md b/docs/pod-secrets.md index cb6e9ec..4ff0bcd 100644 --- a/docs/pod-secrets.md +++ b/docs/pod-secrets.md @@ -23,15 +23,43 @@ This deploys the OpenBao agent injector and registers a `MutatingWebhookConfigur ### Enable Kubernetes Auth -The injector authenticates pods against OpenBao using the Kubernetes service account JWT. Enable this once: +The injector authenticates pods against OpenBao using the Kubernetes service account JWT. You have two options: + +#### Option 1: Automatic (Recommended) + +Add `configure_k8s_auth: true` to your stack config when installing the injector: + +```yaml +components: + openbao-injector: + configure_k8s_auth: true + k8s_auth_roles: + - role_name: "my-app" + service_account_name: "default" + service_account_namespace: "my-namespace" + policies: "my-app" + ttl: "1h" +``` + +This automatically: +- Creates a `vault-reviewer` ServiceAccount in `kube-system` +- Binds it to `system:auth-delegator` ClusterRole +- Enables Kubernetes auth in OpenBao +- Configures it with `disable_iss_validation=true` (required for in-cluster JWT issuer) +- Creates the roles you specify + +#### Option 2: Manual ```bash vault auth enable kubernetes vault write auth/kubernetes/config \ - kubernetes_host="https://$(kubectl get svc kubernetes -o jsonpath='{.spec.clusterIP}'):443" + kubernetes_host="https://$(kubectl get svc kubernetes -o jsonpath='{.spec.clusterIP}'):443" \ + disable_iss_validation=true ``` +> **Note**: `kubernetes_host` must use the ClusterIP (e.g., `https://10.43.0.1:443`), not a Tailscale hostname. The injector runs on Kubernetes nodes where the ClusterIP is reachable, but Tailscale addresses may not be routable from the host node. + ### Create a Policy Define what secrets a pod is allowed to read: