diff --git a/cmd/ecr-credential-provider/main.go b/cmd/ecr-credential-provider/main.go index 97f71d88e..88ef2fe31 100644 --- a/cmd/ecr-credential-provider/main.go +++ b/cmd/ecr-credential-provider/main.go @@ -1,175 +1,15 @@ -// ecr-credential-provider is a kubelet exec credential provider that lets a -// Spinifex EKS worker node pull images from the internal Spinifex ECR registry. -// -// kubelet invokes it per-pull, feeding a CredentialProviderRequest on STDIN and -// reading a CredentialProviderResponse on STDOUT. The provider fetches the -// node IAM-role credentials from IMDS, SigV4-signs GetAuthorizationToken against -// the AWS gateway's ECR endpoint, and returns the minted AWS: as Basic auth -// for the requested registry host. -// -// Static node config (gateway URL, CA, region) is read from the cloud-init env -// file /etc/spinifex-eks/agent.env (KEY=value); real env vars override it. +// ecr-credential-provider is the kubelet exec credential provider for Spinifex +// EKS worker nodes; the implementation is in agents/eks/credentialprovider. package main import ( - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" "os" - "time" - - "github.com/mulgadc/spinifex/internal/ecrauth" - "github.com/mulgadc/spinifex/internal/guestenv" - "github.com/mulgadc/spinifex/internal/imdscreds" _ "github.com/mulgadc/bluebottle/pkg/fipsboot" + + "github.com/mulgadc/spinifex/spinifex/agents/eks/credentialprovider" ) -const ( - credProviderAPIVersion = "credentialprovider.kubelet.k8s.io/v1" - defaultCacheDuration = "10h" - // expiryMargin trims the token's real TTL so kubelet re-invokes the provider - // before the JWT actually expires mid-run. - expiryMargin = 30 * time.Minute - - defaultEnvFile = "/etc/spinifex-eks/agent.env" - defaultGatewayCA = "/etc/spinifex-eks/gateway-ca.pem" - defaultIMDSBase = "http://169.254.169.254/latest" -) - -// CredentialProviderRequest is the kubelet exec-provider request on STDIN. -type CredentialProviderRequest struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Image string `json:"image"` -} - -// AuthConfig is a single registry credential entry. -type AuthConfig struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// CredentialProviderResponse is the kubelet exec-provider response on STDOUT. -type CredentialProviderResponse struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - CacheKeyType string `json:"cacheKeyType"` - CacheDuration string `json:"cacheDuration"` - Auth map[string]AuthConfig `json:"auth"` -} - -// config holds the static node settings the provider needs. -type config struct { - GatewayURL string - GatewayCA string - Region string - IMDSBase string -} - func main() { - cfg := loadConfig(defaultEnvFile) - - httpClient, err := ecrauth.GatewayHTTPClient(cfg.GatewayCA) - if err != nil { - slog.Error("ecr-credential-provider: build gateway client", "err", err) - emitEmpty(os.Stdout) - os.Exit(1) - } - - if err := run(os.Stdin, os.Stdout, cfg, httpClient); err != nil { - slog.Error("ecr-credential-provider: run failed", "err", err) - // Still emit a valid empty-auth response so kubelet has a parseable reply. - emitEmpty(os.Stdout) - os.Exit(1) - } -} - -// run is the testable core: it reads a CredentialProviderRequest from in, -// resolves credentials via IMDS + the gateway, and writes a -// CredentialProviderResponse to out. The httpClient must trust the gateway CA. -func run(in io.Reader, out io.Writer, cfg config, httpClient *http.Client) error { - var req CredentialProviderRequest - if err := json.NewDecoder(in).Decode(&req); err != nil { - return fmt.Errorf("decode request: %w", err) - } - if req.Image == "" { - return fmt.Errorf("request image is empty") - } - imageHost := ecrauth.HostFromImage(req.Image) - - creds, err := imdscreds.Fetch(httpClient, cfg.IMDSBase) - if err != nil { - return fmt.Errorf("fetch IMDS credentials: %w", err) - } - - tok, err := ecrauth.GetAuthorizationToken(cfg.Region, cfg.GatewayURL, httpClient, - creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken) - if err != nil { - return fmt.Errorf("get authorization token: %w", err) - } - - auth := AuthConfig{Username: tok.Username, Password: tok.Password} - authMap := map[string]AuthConfig{imageHost: auth} - if tok.ProxyHost != "" && tok.ProxyHost != imageHost { - authMap[tok.ProxyHost] = auth - } - - resp := CredentialProviderResponse{ - APIVersion: credProviderAPIVersion, - Kind: "CredentialProviderResponse", - CacheKeyType: "Registry", - CacheDuration: cacheDurationFrom(tok.ExpiresAt), - Auth: authMap, - } - return json.NewEncoder(out).Encode(&resp) -} - -// cacheDurationFrom derives a Go-duration string from the token expiry minus a -// safety margin, falling back to the default when expiry is unknown or too near. -func cacheDurationFrom(expiresAt time.Time) string { - if expiresAt.IsZero() { - return defaultCacheDuration - } - d := time.Until(expiresAt) - expiryMargin - if d <= 0 { - return defaultCacheDuration - } - return d.Round(time.Second).String() -} - -// loadConfig reads the cloud-init env file then lets real env vars override. The -// IMDS base is fixed (not in the env file) but overridable for tests. -func loadConfig(envFile string) config { - get := guestenv.Load(envFile).Get - - cfg := config{ - GatewayURL: get("EKS_GATEWAY_URL"), - GatewayCA: get("EKS_GATEWAY_CA"), - Region: get("EKS_REGION"), - IMDSBase: get("EKS_IMDS_BASE"), - } - if cfg.GatewayCA == "" { - cfg.GatewayCA = defaultGatewayCA - } - if cfg.IMDSBase == "" { - cfg.IMDSBase = defaultIMDSBase - } - return cfg -} - -// emitEmpty writes a valid CredentialProviderResponse with no auth entries. -func emitEmpty(out io.Writer) { - resp := CredentialProviderResponse{ - APIVersion: credProviderAPIVersion, - Kind: "CredentialProviderResponse", - CacheKeyType: "Registry", - CacheDuration: defaultCacheDuration, - Auth: map[string]AuthConfig{}, - } - if err := json.NewEncoder(out).Encode(&resp); err != nil { - slog.Error("ecr-credential-provider: encode empty response", "err", err) - } + os.Exit(credentialprovider.Main(os.Stdin, os.Stdout)) } diff --git a/spinifex/agents/eks/credentialprovider/provider.go b/spinifex/agents/eks/credentialprovider/provider.go new file mode 100644 index 000000000..52c3c229a --- /dev/null +++ b/spinifex/agents/eks/credentialprovider/provider.go @@ -0,0 +1,176 @@ +// Package credentialprovider implements ecr-credential-provider, a kubelet exec +// credential provider that lets a Spinifex EKS worker node pull images from the +// internal Spinifex ECR registry. +// +// kubelet invokes it per-pull, feeding a CredentialProviderRequest on STDIN and +// reading a CredentialProviderResponse on STDOUT. The provider fetches the +// node IAM-role credentials from IMDS, SigV4-signs GetAuthorizationToken against +// the AWS gateway's ECR endpoint, and returns the minted AWS: as Basic auth +// for the requested registry host. +// +// Static node config (gateway URL, CA, region) is read from the cloud-init env +// file /etc/spinifex-eks/agent.env (KEY=value); real env vars override it. +package credentialprovider + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/mulgadc/spinifex/internal/ecrauth" + "github.com/mulgadc/spinifex/internal/guestenv" + "github.com/mulgadc/spinifex/internal/imdscreds" +) + +const ( + credProviderAPIVersion = "credentialprovider.kubelet.k8s.io/v1" //nolint:gosec // G101 false positive: kubelet API version, not a credential + defaultCacheDuration = "10h" + // expiryMargin trims the token's real TTL so kubelet re-invokes the provider + // before the JWT actually expires mid-run. + expiryMargin = 30 * time.Minute + + defaultEnvFile = "/etc/spinifex-eks/agent.env" + defaultGatewayCA = "/etc/spinifex-eks/gateway-ca.pem" + defaultIMDSBase = "http://169.254.169.254/latest" +) + +// CredentialProviderRequest is the kubelet exec-provider request on STDIN. +type CredentialProviderRequest struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Image string `json:"image"` +} + +// AuthConfig is a single registry credential entry. +type AuthConfig struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// CredentialProviderResponse is the kubelet exec-provider response on STDOUT. +type CredentialProviderResponse struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + CacheKeyType string `json:"cacheKeyType"` + CacheDuration string `json:"cacheDuration"` + Auth map[string]AuthConfig `json:"auth"` +} + +// config holds the static node settings the provider needs. +type config struct { + GatewayURL string + GatewayCA string + Region string + IMDSBase string +} + +// Main runs one kubelet exec-provider invocation over in and out and returns +// the process exit code. +func Main(in io.Reader, out io.Writer) int { + cfg := loadConfig(defaultEnvFile) + + httpClient, err := ecrauth.GatewayHTTPClient(cfg.GatewayCA) + if err != nil { + slog.Error("ecr-credential-provider: build gateway client", "err", err) + emitEmpty(out) + return 1 + } + + if err := run(in, out, cfg, httpClient); err != nil { + slog.Error("ecr-credential-provider: run failed", "err", err) + // Still emit a valid empty-auth response so kubelet has a parseable reply. + emitEmpty(out) + return 1 + } + return 0 +} + +// run is the testable core: it reads a CredentialProviderRequest from in, +// resolves credentials via IMDS + the gateway, and writes a +// CredentialProviderResponse to out. The httpClient must trust the gateway CA. +func run(in io.Reader, out io.Writer, cfg config, httpClient *http.Client) error { + var req CredentialProviderRequest + if err := json.NewDecoder(in).Decode(&req); err != nil { + return fmt.Errorf("decode request: %w", err) + } + if req.Image == "" { + return fmt.Errorf("request image is empty") + } + imageHost := ecrauth.HostFromImage(req.Image) + + creds, err := imdscreds.Fetch(httpClient, cfg.IMDSBase) + if err != nil { + return fmt.Errorf("fetch IMDS credentials: %w", err) + } + + tok, err := ecrauth.GetAuthorizationToken(cfg.Region, cfg.GatewayURL, httpClient, + creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken) + if err != nil { + return fmt.Errorf("get authorization token: %w", err) + } + + auth := AuthConfig{Username: tok.Username, Password: tok.Password} + authMap := map[string]AuthConfig{imageHost: auth} + if tok.ProxyHost != "" && tok.ProxyHost != imageHost { + authMap[tok.ProxyHost] = auth + } + + resp := CredentialProviderResponse{ + APIVersion: credProviderAPIVersion, + Kind: "CredentialProviderResponse", + CacheKeyType: "Registry", + CacheDuration: cacheDurationFrom(tok.ExpiresAt), + Auth: authMap, + } + return json.NewEncoder(out).Encode(&resp) +} + +// cacheDurationFrom derives a Go-duration string from the token expiry minus a +// safety margin, falling back to the default when expiry is unknown or too near. +func cacheDurationFrom(expiresAt time.Time) string { + if expiresAt.IsZero() { + return defaultCacheDuration + } + d := time.Until(expiresAt) - expiryMargin + if d <= 0 { + return defaultCacheDuration + } + return d.Round(time.Second).String() +} + +// loadConfig reads the cloud-init env file then lets real env vars override. The +// IMDS base is fixed (not in the env file) but overridable for tests. +func loadConfig(envFile string) config { + get := guestenv.Load(envFile).Get + + cfg := config{ + GatewayURL: get("EKS_GATEWAY_URL"), + GatewayCA: get("EKS_GATEWAY_CA"), + Region: get("EKS_REGION"), + IMDSBase: get("EKS_IMDS_BASE"), + } + if cfg.GatewayCA == "" { + cfg.GatewayCA = defaultGatewayCA + } + if cfg.IMDSBase == "" { + cfg.IMDSBase = defaultIMDSBase + } + return cfg +} + +// emitEmpty writes a valid CredentialProviderResponse with no auth entries. +func emitEmpty(out io.Writer) { + resp := CredentialProviderResponse{ + APIVersion: credProviderAPIVersion, + Kind: "CredentialProviderResponse", + CacheKeyType: "Registry", + CacheDuration: defaultCacheDuration, + Auth: map[string]AuthConfig{}, + } + if err := json.NewEncoder(out).Encode(&resp); err != nil { + slog.Error("ecr-credential-provider: encode empty response", "err", err) + } +} diff --git a/cmd/ecr-credential-provider/main_test.go b/spinifex/agents/eks/credentialprovider/provider_test.go similarity index 99% rename from cmd/ecr-credential-provider/main_test.go rename to spinifex/agents/eks/credentialprovider/provider_test.go index 7e88221f1..50d2485be 100644 --- a/cmd/ecr-credential-provider/main_test.go +++ b/spinifex/agents/eks/credentialprovider/provider_test.go @@ -1,4 +1,4 @@ -package main +package credentialprovider import ( "bytes"