diff --git a/.github/workflows/release_inject.yaml b/.github/workflows/release_inject.yaml new file mode 100644 index 0000000000..ef860550bd --- /dev/null +++ b/.github/workflows/release_inject.yaml @@ -0,0 +1,51 @@ +name: Release inject + +on: + workflow_dispatch: + push: + tags: + - "inject/v[0-9]+.[0-9]+.[0-9]+*" + +permissions: + contents: write + packages: write + +concurrency: release-inject + +jobs: + release: + runs-on: depot-ubuntu-24.04-4 + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: ./.github/actions/setup-go + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF#refs/tags/inject/}" >> $GITHUB_OUTPUT + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser-pro + version: "~> v2" + args: release --clean --config cmd/inject/.goreleaser.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/.gitignore b/.gitignore index 9b1677cd6e..993a16e273 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,6 @@ go.work go.work.sum /unkey -/unkey-env +/inject # Added by goreleaser init: dist/ diff --git a/Dockerfile.unkey-env b/Dockerfile.unkey-env deleted file mode 100644 index a88e084fea..0000000000 --- a/Dockerfile.unkey-env +++ /dev/null @@ -1,10 +0,0 @@ -# Minimal image for unkey-env binary -# This image is injected as an init container into customer pods -FROM alpine:3.19 - -# Add ca-certificates for HTTPS calls to krane -RUN apk --no-cache add ca-certificates - -COPY bin/unkey-env /unkey-env - -ENTRYPOINT ["/unkey-env"] diff --git a/Tiltfile b/Tiltfile index 912ce3a348..65cbbbdb6e 100644 --- a/Tiltfile +++ b/Tiltfile @@ -19,7 +19,7 @@ debug_mode = cfg.get('debug', False) print("Tilt starting with services: %s" % services) # Suppress warnings for images used indirectly (injected into pods by webhook) -update_settings(suppress_unused_image_warnings=["unkey-env:latest"]) +update_settings(suppress_unused_image_warnings=["inject:latest"]) # Create namespace using the extension with allow_duplicates namespace_create('unkey', allow_duplicates=True) @@ -42,7 +42,6 @@ start_preflight = 'all' in services or 'preflight' in services # Apply RBAC k8s_yaml('k8s/manifests/rbac.yaml') - # Redis service redis_started = False if start_api: # Redis is needed by API service @@ -154,9 +153,10 @@ if start_api or start_ctrl or start_krane or start_preflight: print("Building Unkey binary...") # Build locally first for faster updates local_resource( - 'unkey-compile', + 'build-unkey', 'CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/unkey ./main.go', deps=['./main.go', './pkg', './cmd', './svc'], + labels=['build'], ) @@ -185,7 +185,7 @@ if start_api: if redis_started: api_deps.append('redis') # Add compilation dependency for Unkey services - api_deps.append('unkey-compile') + api_deps.append('build-unkey') k8s_resource( 'api', @@ -220,7 +220,7 @@ if start_ctrl: if start_s3: ctrl_deps.append('s3') if start_restate: ctrl_deps.append('restate') # Add compilation dependency for Unkey services - ctrl_deps.append('unkey-compile') + ctrl_deps.append('build-unkey') k8s_resource( 'ctrl', @@ -252,7 +252,7 @@ if start_krane: # Build dependency list krane_deps = [] # Add compilation dependency for Unkey services - krane_deps.append('unkey-compile') + krane_deps.append('build-unkey') krane_deps.append('ctrl') k8s_resource( @@ -282,13 +282,13 @@ if start_preflight: labels=['unkey'], ) - # Build unkey-env image for injection into customer pods - # Uses local_resource so it shows up in Tilt UI and can be triggered + # Build inject image for injection into customer pods + # Uses local_resource so it shows up in Tilt UI and can be triggered manually local_resource( - 'unkey-env', - 'CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/unkey-env ./cmd/unkey-env && docker build -t unkey-env:latest -f Dockerfile.unkey-env .', - deps=['./cmd/unkey-env', './pkg/secrets', './pkg/cli', './Dockerfile.unkey-env'], - labels=['unkey'], + 'build-inject', + 'docker build -t inject:latest -f cmd/inject/Dockerfile .', + deps=['./cmd/inject', './pkg/secrets', './pkg/cli', './cmd/inject/Dockerfile'], + labels=['build'], ) docker_build_with_restart( @@ -304,7 +304,7 @@ if start_preflight: k8s_yaml('k8s/manifests/preflight.yaml') - preflight_deps = ['unkey-compile', 'preflight-tls', 'unkey-env'] + preflight_deps = ['build-unkey', 'preflight-tls', 'build-inject'] if start_krane: preflight_deps.append('krane') k8s_resource( diff --git a/cmd/inject/.goreleaser.yaml b/cmd/inject/.goreleaser.yaml new file mode 100644 index 0000000000..ea17236f48 --- /dev/null +++ b/cmd/inject/.goreleaser.yaml @@ -0,0 +1,57 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# Run from repo root: goreleaser release --config cmd/inject/.goreleaser.yaml +version: 2 + +project_name: inject + +# Skip Go builds - the Dockerfile handles compilation via multi-stage build +builds: + - skip: true + +dockers: + - image_templates: + - "ghcr.io/unkeyed/inject:{{ .Version }}-amd64" + dockerfile: cmd/inject/Dockerfile + use: buildx + build_flag_templates: + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--platform=linux/amd64" + + - image_templates: + - "ghcr.io/unkeyed/inject:{{ .Version }}-arm64" + dockerfile: cmd/inject/Dockerfile + use: buildx + build_flag_templates: + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--platform=linux/arm64" + +docker_manifests: + - name_template: "ghcr.io/unkeyed/inject:{{ .Version }}" + image_templates: + - "ghcr.io/unkeyed/inject:{{ .Version }}-amd64" + - "ghcr.io/unkeyed/inject:{{ .Version }}-arm64" + + - name_template: "ghcr.io/unkeyed/inject:latest" + image_templates: + - "ghcr.io/unkeyed/inject:{{ .Version }}-amd64" + - "ghcr.io/unkeyed/inject:{{ .Version }}-arm64" + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + +release: + prerelease: auto diff --git a/cmd/inject/Dockerfile b/cmd/inject/Dockerfile new file mode 100644 index 0000000000..99b67638f4 --- /dev/null +++ b/cmd/inject/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM golang:1.25 AS builder + +RUN apt-get update && apt-get install -y upx-ucl + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary with all optimizations +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /inject ./cmd/inject \ + && upx --best --lzma /inject + +# Final stage - minimal image with CA certs for HTTPS calls +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates + +COPY --from=builder /inject /inject + +LABEL org.opencontainers.image.source=https://github.com/unkeyed/unkey +LABEL org.opencontainers.image.description="Unkey Secrets Injector" + +ENTRYPOINT ["/inject"] diff --git a/cmd/inject/command.go b/cmd/inject/command.go new file mode 100644 index 0000000000..2563b28413 --- /dev/null +++ b/cmd/inject/command.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/secrets/provider" +) + +var cmd = &cli.Command{ + Name: "inject", + Usage: "Fetch secrets and exec the given command", + AcceptsArgs: true, + Flags: []cli.Flag{ + cli.String("provider", "Secrets provider type", + cli.Default(string(provider.KraneVault)), + cli.EnvVar("UNKEY_PROVIDER")), + cli.String("endpoint", "Provider API endpoint", + cli.EnvVar("UNKEY_PROVIDER_ENDPOINT")), + cli.String("deployment-id", "Deployment ID", + cli.EnvVar("UNKEY_DEPLOYMENT_ID")), + cli.String("environment-id", "Environment ID for decryption", + cli.EnvVar("UNKEY_ENVIRONMENT_ID")), + cli.String("secrets-blob", "Base64-encoded encrypted secrets blob", + cli.EnvVar("UNKEY_ENCRYPTED_ENV")), + cli.String("token", "Authentication token", + cli.EnvVar("UNKEY_TOKEN")), + cli.String("token-path", "Path to token file", + cli.EnvVar("UNKEY_TOKEN_PATH")), + cli.Bool("debug", "Enable debug logging", + cli.EnvVar("UNKEY_DEBUG")), + }, + Action: action, +} + +func action(ctx context.Context, c *cli.Command) error { + cfg := config{ + Provider: provider.Type(c.String("provider")), + Endpoint: c.String("endpoint"), + DeploymentID: c.String("deployment-id"), + EnvironmentID: c.String("environment-id"), + Encrypted: c.String("secrets-blob"), + Token: c.String("token"), + TokenPath: c.String("token-path"), + Debug: c.Bool("debug"), + Args: c.Args(), + } + + if err := cfg.validate(); err != nil { + return cli.Exit(err.Error(), 1) + } + + return run(ctx, cfg) +} diff --git a/cmd/inject/config.go b/cmd/inject/config.go new file mode 100644 index 0000000000..94e62d6b0e --- /dev/null +++ b/cmd/inject/config.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + + "github.com/unkeyed/unkey/pkg/assert" + "github.com/unkeyed/unkey/pkg/secrets/provider" +) + +// allowedUnkeyVars are environment variables that should NOT be cleared before exec. +// All other UNKEY_* vars are removed to prevent leaking sensitive config to the child process. +var allowedUnkeyVars = map[string]bool{ + "UNKEY_DEPLOYMENT_ID": true, + "UNKEY_ENVIRONMENT_ID": true, + "UNKEY_REGION": true, + "UNKEY_INSTANCE_ID": true, +} + +type config struct { + Provider provider.Type + Endpoint string + DeploymentID string + EnvironmentID string + Encrypted string + Token string + TokenPath string + Debug bool + Args []string +} + +func (c *config) hasSecrets() bool { + return c.Encrypted != "" +} + +func (c *config) validate() error { + if err := assert.True(len(c.Args) > 0, "command is required"); err != nil { + return err + } + + if !c.hasSecrets() { + return nil + } + + switch c.Provider { + case provider.KraneVault: + return assert.All( + assert.True((c.Token != "") != (c.TokenPath != ""), "exactly one of token or token-path is required"), + assert.NotEmpty(c.EnvironmentID, "environment-id is required when secrets-blob is provided"), + assert.NotEmpty(c.Endpoint, "endpoint is required for krane-vault provider"), + assert.NotEmpty(c.DeploymentID, "deployment-id is required for krane-vault provider"), + ) + default: + return fmt.Errorf("unknown provider: %s", c.Provider) + } +} diff --git a/cmd/inject/main.go b/cmd/inject/main.go new file mode 100644 index 0000000000..580e05294f --- /dev/null +++ b/cmd/inject/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "context" + "fmt" + "os" +) + +func main() { + if err := cmd.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "inject: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/inject/run.go b/cmd/inject/run.go new file mode 100644 index 0000000000..7b15b62498 --- /dev/null +++ b/cmd/inject/run.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "github.com/unkeyed/unkey/pkg/otel/logging" + "github.com/unkeyed/unkey/pkg/secrets/provider" +) + +func run(ctx context.Context, cfg config) error { + if len(cfg.Args) == 0 { + return fmt.Errorf("no command specified") + } + + logger := logging.New() + + // Clear sensitive UNKEY_* env vars first, before setting user secrets. + // This prevents leaking config like UNKEY_TOKEN to the child process, + // while allowing users to have secrets named UNKEY_* if they want. + clearSensitiveEnvVars() + + if cfg.hasSecrets() { + secrets, err := fetchSecrets(ctx, cfg) + if err != nil { + return err + } + + logger.Debug("fetched secrets", "count", len(secrets)) + + for key, value := range secrets { + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("failed to set env var %s: %w", key, err) + } + if cfg.Debug { + logger.Debug("set environment variable", "key", key) + } + } + } + + return execCommand(cfg.Args, logger) +} + +func fetchSecrets(ctx context.Context, cfg config) (map[string]string, error) { + p, err := provider.New(provider.Config{ + Type: cfg.Provider, + Endpoint: cfg.Endpoint, + }) + if err != nil { + return nil, fmt.Errorf("failed to create provider: %w", err) + } + + fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + encrypted, err := base64.StdEncoding.DecodeString(cfg.Encrypted) + if err != nil { + return nil, fmt.Errorf("failed to decode secrets blob: %w", err) + } + + secrets, err := p.FetchSecrets(fetchCtx, provider.FetchOptions{ + DeploymentID: cfg.DeploymentID, + EnvironmentID: cfg.EnvironmentID, + Encrypted: encrypted, + Token: cfg.Token, + TokenPath: cfg.TokenPath, + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch secrets: %w", err) + } + + return secrets, nil +} + +func clearSensitiveEnvVars() { + for _, env := range os.Environ() { + name, _, _ := strings.Cut(env, "=") + if strings.HasPrefix(name, "UNKEY_") && !allowedUnkeyVars[name] { + os.Unsetenv(name) + } + } +} + +func execCommand(args []string, logger logging.Logger) error { + binary, err := exec.LookPath(args[0]) + if err != nil { + return fmt.Errorf("command not found: %s: %w", args[0], err) + } + + logger.Debug("executing command", "binary", binary, "args", args) + + if err := syscall.Exec(binary, args, os.Environ()); err != nil { + return fmt.Errorf("exec failed: %w", err) + } + + return nil +} diff --git a/cmd/preflight/main.go b/cmd/preflight/main.go index b8760e7775..acd19de48d 100644 --- a/cmd/preflight/main.go +++ b/cmd/preflight/main.go @@ -17,10 +17,10 @@ var Cmd = &cli.Command{ cli.Required(), cli.EnvVar("WEBHOOK_TLS_CERT_FILE")), cli.String("tls-key-file", "Path to TLS private key file", cli.Required(), cli.EnvVar("WEBHOOK_TLS_KEY_FILE")), - cli.String("unkey-env-image", "Container image for unkey-env binary", - cli.Default("unkey-env:latest"), cli.EnvVar("UNKEY_ENV_IMAGE")), - cli.String("unkey-env-image-pull-policy", "Image pull policy (Always, IfNotPresent, Never)", - cli.Default("IfNotPresent"), cli.EnvVar("UNKEY_ENV_IMAGE_PULL_POLICY")), + cli.String("inject-image", "Container image for inject binary", + cli.Default("inject:latest"), cli.EnvVar("INJECT_IMAGE")), + cli.String("inject-image-pull-policy", "Image pull policy (Always, IfNotPresent, Never)", + cli.Default("IfNotPresent"), cli.EnvVar("INJECT_IMAGE_PULL_POLICY")), cli.String("krane-endpoint", "Endpoint for Krane secrets service", cli.Default("http://krane.unkey.svc.cluster.local:8080"), cli.EnvVar("KRANE_ENDPOINT")), cli.String("depot-token", "Depot API token for fetching on-demand pull tokens", @@ -31,13 +31,13 @@ var Cmd = &cli.Command{ func action(ctx context.Context, cmd *cli.Command) error { config := preflight.Config{ - HttpPort: cmd.Int("port"), - TLSCertFile: cmd.String("tls-cert-file"), - TLSKeyFile: cmd.String("tls-key-file"), - UnkeyEnvImage: cmd.String("unkey-env-image"), - UnkeyEnvImagePullPolicy: cmd.String("unkey-env-image-pull-policy"), - KraneEndpoint: cmd.String("krane-endpoint"), - DepotToken: cmd.String("depot-token"), + HttpPort: cmd.Int("port"), + TLSCertFile: cmd.RequireString("tls-cert-file"), + TLSKeyFile: cmd.RequireString("tls-key-file"), + InjectImage: cmd.String("inject-image"), + InjectImagePullPolicy: cmd.String("inject-image-pull-policy"), + KraneEndpoint: cmd.String("krane-endpoint"), + DepotToken: cmd.RequireString("depot-token"), } if err := config.Validate(); err != nil { diff --git a/cmd/unkey-env/Dockerfile b/cmd/unkey-env/Dockerfile deleted file mode 100644 index f79b24eb59..0000000000 --- a/cmd/unkey-env/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# Build stage -FROM golang:1.23-alpine AS builder - -RUN apk add --no-cache upx - -WORKDIR /app - -# Copy go mod files -COPY go.mod go.sum ./ -RUN go mod download - -# Copy source code -COPY . . - -# Build the binary with all optimizations -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /unkey-env ./cmd/unkey-env \ - && upx --best --lzma /unkey-env - -# Final stage - scratch image with just the binary -FROM scratch - -COPY --from=builder /unkey-env /unkey-env - -ENTRYPOINT ["/unkey-env"] diff --git a/cmd/unkey-env/main.go b/cmd/unkey-env/main.go deleted file mode 100644 index c55b59df4b..0000000000 --- a/cmd/unkey-env/main.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "fmt" - "log/slog" - "os" - "os/exec" - "strings" - "syscall" - "time" - - "github.com/unkeyed/unkey/pkg/assert" - "github.com/unkeyed/unkey/pkg/cli" - "github.com/unkeyed/unkey/pkg/otel/logging" - "github.com/unkeyed/unkey/pkg/secrets/provider" -) - -var allowedUnkeyVars = map[string]bool{ - "UNKEY_DEPLOYMENT_ID": true, - "UNKEY_ENVIRONMENT_ID": true, - "UNKEY_REGION": true, - "UNKEY_INSTANCE_ID": true, -} - -type Config struct { - Provider provider.Type - Endpoint string - DeploymentID string - EnvironmentID string - Encrypted string - Token string - TokenPath string - Debug bool - Args []string -} - -func (c *Config) Validate() error { - return assert.All( - assert.NotEmpty(c.Endpoint, "endpoint is required"), - assert.NotEmpty(c.DeploymentID, "deployment-id is required"), - assert.True(c.Token != "" || c.TokenPath != "", "either token or token-path is required"), - assert.True(len(c.Args) > 0, "command is required"), - ) -} - -var Cmd = &cli.Command{ - Name: "unkey-env", - Usage: "Fetch secrets and exec the given command", - AcceptsArgs: true, - Flags: []cli.Flag{ - cli.String("provider", "Secrets provider type", - cli.Default(string(provider.KraneVault)), - cli.EnvVar("UNKEY_PROVIDER")), - cli.String("endpoint", "Provider API endpoint", - cli.Required(), - cli.EnvVar("UNKEY_PROVIDER_ENDPOINT")), - cli.String("deployment-id", "Deployment ID", - cli.Required(), - cli.EnvVar("UNKEY_DEPLOYMENT_ID")), - cli.String("environment-id", "Environment ID for decryption", - cli.EnvVar("UNKEY_ENVIRONMENT_ID")), - cli.String("secrets-blob", "Base64-encoded encrypted secrets blob", - cli.EnvVar("UNKEY_ENCRYPTED_ENV")), - cli.String("token", "Authentication token", - cli.EnvVar("UNKEY_TOKEN")), - cli.String("token-path", "Path to token file", - cli.EnvVar("UNKEY_TOKEN_PATH")), - cli.Bool("debug", "Enable debug logging", - cli.EnvVar("UNKEY_DEBUG")), - }, - Action: action, -} - -func main() { - if err := Cmd.Run(context.Background(), os.Args); err != nil { - fmt.Fprintf(os.Stderr, "unkey-env: %v\n", err) - os.Exit(1) - } -} - -func action(ctx context.Context, cmd *cli.Command) error { - cfg := Config{ - Provider: provider.Type(cmd.String("provider")), - Endpoint: cmd.String("endpoint"), - DeploymentID: cmd.String("deployment-id"), - EnvironmentID: cmd.String("environment-id"), - Encrypted: cmd.String("secrets-blob"), - Token: cmd.String("token"), - TokenPath: cmd.String("token-path"), - Debug: cmd.Bool("debug"), - Args: cmd.Args(), - } - - if err := cfg.Validate(); err != nil { - return cli.Exit(err.Error(), 1) - } - - return run(ctx, cfg) -} - -func run(ctx context.Context, cfg Config) error { - logger := logging.New() - if cfg.Debug { - logger = logger.With(slog.String("deployment", cfg.DeploymentID)) - } - - p, err := provider.New(provider.Config{ - Type: cfg.Provider, - Endpoint: cfg.Endpoint, - }) - if err != nil { - return fmt.Errorf("failed to create provider: %w", err) - } - - fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - var Encrypted []byte - if cfg.Encrypted != "" { - var decodeErr error - Encrypted, decodeErr = base64.StdEncoding.DecodeString(cfg.Encrypted) - if decodeErr != nil { - return fmt.Errorf("failed to decode secrets blob: %w", decodeErr) - } - } - - secrets, err := p.FetchSecrets(fetchCtx, provider.FetchOptions{ - DeploymentID: cfg.DeploymentID, - EnvironmentID: cfg.EnvironmentID, - Encrypted: Encrypted, - Token: cfg.Token, - TokenPath: cfg.TokenPath, - }) - if err != nil { - return fmt.Errorf("failed to fetch secrets: %w", err) - } - - logger.Debug("fetched secrets", "count", len(secrets)) - for _, env := range os.Environ() { - name, _, _ := strings.Cut(env, "=") - if strings.HasPrefix(name, "UNKEY_") && !allowedUnkeyVars[name] { - os.Unsetenv(name) - } - } - - for key, value := range secrets { - if err := os.Setenv(key, value); err != nil { - return fmt.Errorf("failed to set env var %s: %w", key, err) - } - logger.Debug("set environment variable", "key", key) - } - - command := cfg.Args[0] - binary, err := exec.LookPath(command) - if err != nil { - return fmt.Errorf("command not found: %s: %w", command, err) - } - - logger.Debug("executing command", "binary", binary, "args", cfg.Args) - - err = syscall.Exec(binary, cfg.Args, os.Environ()) - if err != nil { - return fmt.Errorf("exec failed: %w", err) - } - - return nil -} diff --git a/gen/proto/krane/v1/scheduler.pb.go b/gen/proto/krane/v1/scheduler.pb.go index 334ccb43f0..62e8fbc7e7 100644 --- a/gen/proto/krane/v1/scheduler.pb.go +++ b/gen/proto/krane/v1/scheduler.pb.go @@ -590,9 +590,9 @@ type ApplyDeploymentRequest struct { // if we did not build this image via depot, no buildID exists and we // assume kubernetes will pull from a public registry BuildId *string `protobuf:"bytes,11,opt,name=build_id,json=buildId,proto3,oneof" json:"build_id,omitempty"` - // Encrypted secrets blob to be decrypted at runtime by unkey-env. + // Encrypted secrets blob to be decrypted at runtime by inject. // This is set as UNKEY_ENCRYPTED_ENV env var in the container. - // unkey-env calls krane's DecryptSecretsBlob RPC to decrypt. + // inject calls krane's DecryptSecretsBlob RPC to decrypt. EncryptedEnvironmentVariables []byte `protobuf:"bytes,12,opt,name=encrypted_environment_variables,json=encryptedEnvironmentVariables,proto3" json:"encrypted_environment_variables,omitempty"` // An opaque identifier used in a restate awakable. // If set, the cluster must add this as annotation and report back during Watch checks diff --git a/k8s/manifests/preflight.yaml b/k8s/manifests/preflight.yaml index ee3dd00341..3b72721c45 100644 --- a/k8s/manifests/preflight.yaml +++ b/k8s/manifests/preflight.yaml @@ -71,9 +71,9 @@ spec: value: "/certs/tls.crt" - name: WEBHOOK_TLS_KEY_FILE value: "/certs/tls.key" - - name: UNKEY_ENV_IMAGE - value: "unkey-env:latest" - - name: UNKEY_ENV_IMAGE_PULL_POLICY + - name: INJECT_IMAGE + value: "inject:latest" + - name: INJECT_IMAGE_PULL_POLICY value: "Never" # Local dev uses pre-loaded images; use IfNotPresent in prod - name: KRANE_ENDPOINT value: "http://krane.unkey.svc.cluster.local:8080" diff --git a/k8s/manifests/rbac.yaml b/k8s/manifests/rbac.yaml index 0c0401022f..452dd0172c 100644 --- a/k8s/manifests/rbac.yaml +++ b/k8s/manifests/rbac.yaml @@ -11,7 +11,7 @@ metadata: --- # Restricted service account for customer workloads # This account has NO K8s API permissions - customers cannot query the K8s API. -# Token is mounted for unkey-env to authenticate with the secrets provider. +# Token is mounted for inject to authenticate with the secrets provider. apiVersion: v1 kind: ServiceAccount metadata: diff --git a/pkg/secrets/provider/krane_vault.go b/pkg/secrets/provider/krane_vault.go index ba894759e3..f2ffb03f9e 100644 --- a/pkg/secrets/provider/krane_vault.go +++ b/pkg/secrets/provider/krane_vault.go @@ -2,6 +2,7 @@ package provider import ( "context" + "fmt" "net/http" "os" "strings" @@ -16,8 +17,7 @@ import ( // KraneVaultProvider fetches secrets via Krane's SecretsService. // Krane handles token validation and calls Vault for decryption. type KraneVaultProvider struct { - client kranev1connect.SecretsServiceClient - endpoint string + client kranev1connect.SecretsServiceClient } // NewKraneVaultProvider creates a new Krane-Vault secrets provider. @@ -31,10 +31,7 @@ func NewKraneVaultProvider(cfg Config) (*KraneVaultProvider, error) { cfg.Endpoint, ) - return &KraneVaultProvider{ - client: client, - endpoint: cfg.Endpoint, - }, nil + return &KraneVaultProvider{client: client}, nil } // Name returns the provider name. @@ -43,43 +40,52 @@ func (p *KraneVaultProvider) Name() string { } // FetchSecrets retrieves secrets from Krane (which decrypts via Vault). -// If Encrypted is provided, uses DecryptSecretsBlob RPC (no DB lookup). -// Otherwise falls back to GetDeploymentSecrets (requires DB lookup). func (p *KraneVaultProvider) FetchSecrets(ctx context.Context, opts FetchOptions) (map[string]string, error) { - token := opts.Token + if len(opts.Encrypted) == 0 { + return make(map[string]string), nil + } - if opts.TokenPath != "" { - tokenBytes, err := os.ReadFile(opts.TokenPath) - if err != nil { - return nil, err - } - token = strings.TrimSpace(string(tokenBytes)) + token, err := resolveToken(opts) + if err != nil { + return nil, err } if err := assert.All( - assert.NotEmpty(token, "token is required"), assert.NotEmpty(opts.DeploymentID, "deployment_id is required"), + assert.NotEmpty(opts.EnvironmentID, "environment_id is required for blob decryption"), ); err != nil { return nil, err } - // Use DecryptSecretsBlob if we have an encrypted blob (preferred - no DB lookup) - if len(opts.Encrypted) > 0 { - if err := assert.NotEmpty(opts.EnvironmentID, "environment_id is required for blob decryption"); err != nil { - return nil, err - } + resp, err := p.client.DecryptSecretsBlob(ctx, connect.NewRequest(&kranev1.DecryptSecretsBlobRequest{ + EncryptedBlob: opts.Encrypted, + EnvironmentId: opts.EnvironmentID, + Token: token, + DeploymentId: opts.DeploymentID, + })) + if err != nil { + return nil, err + } + + return resp.Msg.GetEnvVars(), nil +} - resp, err := p.client.DecryptSecretsBlob(ctx, connect.NewRequest(&kranev1.DecryptSecretsBlobRequest{ - EncryptedBlob: opts.Encrypted, - EnvironmentId: opts.EnvironmentID, - Token: token, - DeploymentId: opts.DeploymentID, - })) +func resolveToken(opts FetchOptions) (string, error) { + if opts.TokenPath != "" { + tokenBytes, err := os.ReadFile(opts.TokenPath) if err != nil { - return nil, err + return "", fmt.Errorf("failed to read token file: %w", err) + } + token := strings.TrimSpace(string(tokenBytes)) + if err := assert.NotEmpty(token, "token file exists but is empty"); err != nil { + return "", err } - return resp.Msg.GetEnvVars(), nil + return token, nil + } + + if err := assert.NotEmpty(opts.Token, "token is required"); err != nil { + return "", err } - return make(map[string]string), nil + return opts.Token, nil } diff --git a/pkg/secrets/provider/provider.go b/pkg/secrets/provider/provider.go index e9c632afd9..976478ab3e 100644 --- a/pkg/secrets/provider/provider.go +++ b/pkg/secrets/provider/provider.go @@ -1,5 +1,5 @@ // Package provider defines the interface for secrets providers. -// This abstraction allows unkey-env to fetch secrets from different backends. +// This abstraction allows inject to fetch secrets from different backends. package provider import ( diff --git a/svc/krane/proto/krane/v1/scheduler.proto b/svc/krane/proto/krane/v1/scheduler.proto index 9ba0265042..78bbdb5450 100644 --- a/svc/krane/proto/krane/v1/scheduler.proto +++ b/svc/krane/proto/krane/v1/scheduler.proto @@ -149,9 +149,9 @@ message ApplyDeploymentRequest { // assume kubernetes will pull from a public registry optional string build_id = 11; - // Encrypted secrets blob to be decrypted at runtime by unkey-env. + // Encrypted secrets blob to be decrypted at runtime by inject. // This is set as UNKEY_ENCRYPTED_ENV env var in the container. - // unkey-env calls krane's DecryptSecretsBlob RPC to decrypt. + // inject calls krane's DecryptSecretsBlob RPC to decrypt. bytes encrypted_environment_variables = 12; // An opaque identifier used in a restate awakable. diff --git a/svc/krane/proto/krane/v1/secrets.proto b/svc/krane/proto/krane/v1/secrets.proto index 43c021cb48..751babe6af 100644 --- a/svc/krane/proto/krane/v1/secrets.proto +++ b/svc/krane/proto/krane/v1/secrets.proto @@ -5,7 +5,7 @@ package krane.v1; option go_package = "github.com/unkeyed/unkey/gen/proto/krane/v1;kranev1"; // SecretsService provides decrypted secrets to running workloads. -// Called by the unkey-env binary injected into customer pods/containers. +// Called by the inject binary injected into customer pods/containers. service SecretsService { // DecryptSecretsBlob decrypts an encrypted secrets blob passed in the pod spec. // This avoids DB lookups - the encrypted blob travels with the pod. diff --git a/svc/preflight/config.go b/svc/preflight/config.go index e36e4c2a48..527825a0e5 100644 --- a/svc/preflight/config.go +++ b/svc/preflight/config.go @@ -2,14 +2,20 @@ package preflight import "github.com/unkeyed/unkey/pkg/assert" +var validImagePullPolicies = map[string]bool{ + "Always": true, + "IfNotPresent": true, + "Never": true, +} + type Config struct { - HttpPort int - TLSCertFile string - TLSKeyFile string - UnkeyEnvImage string - UnkeyEnvImagePullPolicy string - KraneEndpoint string - DepotToken string + HttpPort int + TLSCertFile string + TLSKeyFile string + InjectImage string + InjectImagePullPolicy string + KraneEndpoint string + DepotToken string } func (c *Config) Validate() error { @@ -17,10 +23,5 @@ func (c *Config) Validate() error { c.HttpPort = 8443 } - return assert.All( - assert.NotEmpty(c.TLSCertFile, "tls-cert-file is required"), - assert.NotEmpty(c.TLSKeyFile, "tls-key-file is required"), - assert.NotEmpty(c.UnkeyEnvImage, "unkey-env-image is required"), - assert.NotEmpty(c.KraneEndpoint, "krane-endpoint is required"), - ) + return assert.True(validImagePullPolicies[c.InjectImagePullPolicy], "inject-image-pull-policy must be one of: Always, IfNotPresent, Never") } diff --git a/svc/preflight/internal/services/mutator/config.go b/svc/preflight/internal/services/mutator/config.go index 2520738840..1bed5642aa 100644 --- a/svc/preflight/internal/services/mutator/config.go +++ b/svc/preflight/internal/services/mutator/config.go @@ -10,9 +10,9 @@ import ( ) const ( - unkeyEnvVolumeName = "unkey-env-bin" - unkeyEnvMountPath = "/unkey" - unkeyEnvBinary = "/unkey/unkey-env" + injectVolumeName = "inject-bin" + injectMountPath = "/inject-bin" + injectBinary = "/inject-bin/inject" //nolint:gosec // G101: This is a file path, not credentials ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" ) @@ -28,8 +28,8 @@ type Config struct { Registry *registry.Registry Clientset kubernetes.Interface Credentials *credentials.Manager - UnkeyEnvImage string - UnkeyEnvImagePullPolicy string + InjectImage string + InjectImagePullPolicy string DefaultProviderEndpoint string } diff --git a/svc/preflight/internal/services/mutator/mutator.go b/svc/preflight/internal/services/mutator/mutator.go index 9f6608f14e..d4667235b7 100644 --- a/svc/preflight/internal/services/mutator/mutator.go +++ b/svc/preflight/internal/services/mutator/mutator.go @@ -30,8 +30,8 @@ type Mutator struct { registry *registry.Registry clientset kubernetes.Interface credentials *credentials.Manager - unkeyEnvImage string - unkeyEnvImagePullPolicy string + injectImage string + injectImagePullPolicy string defaultProviderEndpoint string } @@ -41,8 +41,8 @@ func New(cfg Config) *Mutator { registry: cfg.Registry, clientset: cfg.Clientset, credentials: cfg.Credentials, - unkeyEnvImage: cfg.UnkeyEnvImage, - unkeyEnvImagePullPolicy: cfg.UnkeyEnvImagePullPolicy, + injectImage: cfg.InjectImage, + injectImagePullPolicy: cfg.InjectImagePullPolicy, defaultProviderEndpoint: cfg.DefaultProviderEndpoint, } } @@ -130,7 +130,7 @@ func (m *Mutator) Mutate(ctx context.Context, pod *corev1.Pod, namespace string) return &Result{ Mutated: true, Patch: patchBytes, - Message: fmt.Sprintf("injected unkey-env for deployment %s", podCfg.DeploymentID), + Message: fmt.Sprintf("injected secrets for deployment %s", podCfg.DeploymentID), }, nil } diff --git a/svc/preflight/internal/services/mutator/patch.go b/svc/preflight/internal/services/mutator/patch.go index abc3f83c52..f9a4b35797 100644 --- a/svc/preflight/internal/services/mutator/patch.go +++ b/svc/preflight/internal/services/mutator/patch.go @@ -9,14 +9,14 @@ import ( func (m *Mutator) buildInitContainer() corev1.Container { return corev1.Container{ - Name: "copy-unkey-env", - Image: m.unkeyEnvImage, - ImagePullPolicy: corev1.PullPolicy(m.unkeyEnvImagePullPolicy), - Command: []string{"cp", "/unkey-env", unkeyEnvBinary}, + Name: "copy-inject", + Image: m.injectImage, + ImagePullPolicy: corev1.PullPolicy(m.injectImagePullPolicy), + Command: []string{"cp", "/inject", injectBinary}, VolumeMounts: []corev1.VolumeMount{ { - Name: unkeyEnvVolumeName, - MountPath: unkeyEnvMountPath, + Name: injectVolumeName, + MountPath: injectMountPath, }, }, } @@ -24,7 +24,7 @@ func (m *Mutator) buildInitContainer() corev1.Container { func (m *Mutator) buildVolume() corev1.Volume { return corev1.Volume{ - Name: unkeyEnvVolumeName, + Name: injectVolumeName, VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{ Medium: corev1.StorageMediumMemory, @@ -46,8 +46,8 @@ func (m *Mutator) buildContainerPatches( basePath := fmt.Sprintf("/spec/containers/%d", containerIndex) volumeMount := corev1.VolumeMount{ - Name: unkeyEnvVolumeName, - MountPath: unkeyEnvMountPath, + Name: injectVolumeName, + MountPath: injectMountPath, ReadOnly: true, } @@ -82,7 +82,7 @@ func (m *Mutator) buildContainerPatches( } } - // We replace the container's command with unkey-env, which decrypts secrets and then + // We replace the container's command with inject, which decrypts secrets and then // exec's the original entrypoint. If the pod spec doesn't define a command, we need to // fetch the image's ENTRYPOINT/CMD from the registry so we know what to exec into. var args []string @@ -109,7 +109,7 @@ func (m *Mutator) buildContainerPatches( patches = append(patches, map[string]interface{}{ "op": "add", "path": fmt.Sprintf("%s/command", basePath), - "value": []string{unkeyEnvBinary}, + "value": []string{injectBinary}, }) if len(args) > 0 { diff --git a/svc/preflight/run.go b/svc/preflight/run.go index 67fff7e97d..36c046f5b9 100644 --- a/svc/preflight/run.go +++ b/svc/preflight/run.go @@ -73,8 +73,8 @@ func Run(ctx context.Context, cfg Config) error { Registry: reg, Clientset: clientset, Credentials: credentialsManager, - UnkeyEnvImage: cfg.UnkeyEnvImage, - UnkeyEnvImagePullPolicy: cfg.UnkeyEnvImagePullPolicy, + InjectImage: cfg.InjectImage, + InjectImagePullPolicy: cfg.InjectImagePullPolicy, DefaultProviderEndpoint: cfg.KraneEndpoint, })