From d0a18b6a6ce894e11326a0d83f6059274b12301c Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 17:06:16 +0100 Subject: [PATCH] feat: add bedrock credential type for AWS Bedrock authentication Add a new `bedrock` credential type that injects AWS environment variables (CLAUDE_CODE_USE_BEDROCK, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) from a referenced Secret, with optional support for AWS_SESSION_TOKEN and ANTHROPIC_BEDROCK_BASE_URL. Refactor credential injection into a centralized credentialEnvVars() function so that adding future providers (e.g. Vertex) requires only a new case block. Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/task_types.go | 4 +- examples/09-bedrock-credentials/README.md | 72 +++++++++++++++++ examples/09-bedrock-credentials/secret.yaml | 14 ++++ examples/09-bedrock-credentials/task.yaml | 11 +++ internal/cli/config.go | 10 +++ internal/cli/run.go | 84 +++++++++++++++++++- internal/controller/job_builder.go | 78 ++++++++++++------- internal/controller/job_builder_test.go | 86 +++++++++++++++++++++ internal/manifests/install-crd.yaml | 2 + 9 files changed, 329 insertions(+), 32 deletions(-) create mode 100644 examples/09-bedrock-credentials/README.md create mode 100644 examples/09-bedrock-credentials/secret.yaml create mode 100644 examples/09-bedrock-credentials/task.yaml diff --git a/api/v1alpha1/task_types.go b/api/v1alpha1/task_types.go index dfce1d83..2e572898 100644 --- a/api/v1alpha1/task_types.go +++ b/api/v1alpha1/task_types.go @@ -13,6 +13,8 @@ const ( CredentialTypeAPIKey CredentialType = "api-key" // CredentialTypeOAuth uses OAuth for authentication. CredentialTypeOAuth CredentialType = "oauth" + // CredentialTypeBedrock uses AWS credentials for Bedrock authentication. + CredentialTypeBedrock CredentialType = "bedrock" ) // TaskPhase represents the current phase of a Task. @@ -40,7 +42,7 @@ type SecretReference struct { // Credentials defines how to authenticate with the AI agent. type Credentials struct { // Type specifies the credential type (api-key or oauth). - // +kubebuilder:validation:Enum=api-key;oauth + // +kubebuilder:validation:Enum=api-key;oauth;bedrock Type CredentialType `json:"type"` // SecretRef references the Secret containing credentials. diff --git a/examples/09-bedrock-credentials/README.md b/examples/09-bedrock-credentials/README.md new file mode 100644 index 00000000..e6aec0a9 --- /dev/null +++ b/examples/09-bedrock-credentials/README.md @@ -0,0 +1,72 @@ +# Bedrock Credentials + +This example demonstrates running a Claude Code task using AWS Bedrock instead of the Anthropic API directly. + +## Prerequisites + +- AWS account with Bedrock access enabled for Claude models +- AWS IAM credentials with `bedrock:InvokeModel` permissions + +## Setup + +1. Create the Secret with your AWS credentials: + + ```bash + kubectl create secret generic bedrock-credentials \ + --from-literal=AWS_ACCESS_KEY_ID= \ + --from-literal=AWS_SECRET_ACCESS_KEY= \ + --from-literal=AWS_REGION=us-east-1 + ``` + +2. Create the Task: + + ```bash + kubectl apply -f task.yaml + ``` + +## Using the CLI + +You can also use `kelos run` with a config file: + +```yaml +# ~/.kelos/config.yaml +bedrock: + accessKeyID: AKIAIOSFODNN7EXAMPLE + secretAccessKey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + region: us-east-1 +``` + +```bash +kelos run -p "Fix the bug" +``` + +Or with a pre-created secret: + +```bash +kelos run -p "Fix the bug" --credential-type bedrock --secret bedrock-credentials +``` + +## Optional Fields + +- `AWS_SESSION_TOKEN`: Required when using temporary credentials (e.g. from STS AssumeRole) +- `ANTHROPIC_BEDROCK_BASE_URL`: Custom Bedrock endpoint URL + +## IAM Roles for Service Accounts (IRSA) + +On EKS, you can use IRSA instead of static credentials. In that case, use `podOverrides.env` to set only the required environment variables: + +```yaml +spec: + credentials: + type: api-key + secretRef: + name: dummy-secret # Required by schema; not used by Bedrock + podOverrides: + env: + - name: CLAUDE_CODE_USE_BEDROCK + value: "1" + - name: AWS_REGION + value: us-east-1 +``` + +Note: First-class IRSA support (making `secretRef` optional for bedrock) is planned for a future release. diff --git a/examples/09-bedrock-credentials/secret.yaml b/examples/09-bedrock-credentials/secret.yaml new file mode 100644 index 00000000..020b0abb --- /dev/null +++ b/examples/09-bedrock-credentials/secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: bedrock-credentials +type: Opaque +stringData: + # TODO: Replace with your AWS credentials + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE" + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + AWS_REGION: "us-east-1" + # Optional: uncomment if using temporary credentials (e.g. STS AssumeRole) + # AWS_SESSION_TOKEN: "your-session-token" + # Optional: uncomment to use a custom Bedrock endpoint + # ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock-runtime.us-east-1.amazonaws.com" diff --git a/examples/09-bedrock-credentials/task.yaml b/examples/09-bedrock-credentials/task.yaml new file mode 100644 index 00000000..6d16aa93 --- /dev/null +++ b/examples/09-bedrock-credentials/task.yaml @@ -0,0 +1,11 @@ +apiVersion: kelos.dev/v1alpha1 +kind: Task +metadata: + name: bedrock-task +spec: + type: claude-code + prompt: "Write a Python script that prints the first 20 Fibonacci numbers." + credentials: + type: bedrock + secretRef: + name: bedrock-credentials diff --git a/internal/cli/config.go b/internal/cli/config.go index 60ca0f62..214471e9 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -19,6 +19,16 @@ type Config struct { Namespace string `json:"namespace,omitempty"` Workspace WorkspaceConfig `json:"workspace,omitempty"` AgentConfig string `json:"agentConfig,omitempty"` + Bedrock *BedrockConfig `json:"bedrock,omitempty"` +} + +// BedrockConfig holds AWS credentials for Bedrock authentication. +type BedrockConfig struct { + AccessKeyID string `json:"accessKeyID"` + SecretAccessKey string `json:"secretAccessKey"` + Region string `json:"region"` + SessionToken string `json:"sessionToken,omitempty"` + BaseURL string `json:"baseURL,omitempty"` } // WorkspaceConfig holds workspace-related configuration. diff --git a/internal/cli/run.go b/internal/cli/run.go index c85a7aa3..d0a56be7 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -79,8 +79,18 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { // Auto-create secret from token if no explicit secret is set. if secret == "" && cfg.Config != nil { - if cfg.Config.OAuthToken != "" && cfg.Config.APIKey != "" { - return fmt.Errorf("config file must specify either oauthToken or apiKey, not both") + sources := 0 + if cfg.Config.OAuthToken != "" { + sources++ + } + if cfg.Config.APIKey != "" { + sources++ + } + if cfg.Config.Bedrock != nil { + sources++ + } + if sources > 1 { + return fmt.Errorf("config file must specify only one of oauthToken, apiKey, or bedrock") } if token := cfg.Config.OAuthToken; token != "" { resolved, err := resolveContent(token) @@ -108,6 +118,17 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { } secret = "kelos-credentials" credentialType = "api-key" + } else if br := cfg.Config.Bedrock; br != nil { + if br.AccessKeyID == "" || br.SecretAccessKey == "" || br.Region == "" { + return fmt.Errorf("bedrock config requires accessKeyID, secretAccessKey, and region") + } + if !dryRun { + if err := ensureBedrockSecret(cfg, "kelos-credentials", br, yes); err != nil { + return err + } + } + secret = "kelos-credentials" + credentialType = "bedrock" } } @@ -304,7 +325,7 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { cmd.MarkFlagRequired("prompt") - _ = cmd.RegisterFlagCompletionFunc("credential-type", cobra.FixedCompletions([]string{"api-key", "oauth"}, cobra.ShellCompDirectiveNoFileComp)) + _ = cmd.RegisterFlagCompletionFunc("credential-type", cobra.FixedCompletions([]string{"api-key", "oauth", "bedrock"}, cobra.ShellCompDirectiveNoFileComp)) _ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions([]string{"claude-code", "codex", "gemini", "opencode", "cursor"}, cobra.ShellCompDirectiveNoFileComp)) return cmd @@ -478,3 +499,60 @@ func ensureCredentialSecret(cfg *ClientConfig, name, key, value string, skipConf } return nil } + +// ensureBedrockSecret creates or updates a Secret with AWS Bedrock credentials. +func ensureBedrockSecret(cfg *ClientConfig, name string, br *BedrockConfig, skipConfirm bool) error { + cs, ns, err := cfg.NewClientset() + if err != nil { + return err + } + + data := map[string]string{ + "AWS_ACCESS_KEY_ID": br.AccessKeyID, + "AWS_SECRET_ACCESS_KEY": br.SecretAccessKey, + "AWS_REGION": br.Region, + } + if br.SessionToken != "" { + data["AWS_SESSION_TOKEN"] = br.SessionToken + } + if br.BaseURL != "" { + data["ANTHROPIC_BEDROCK_BASE_URL"] = br.BaseURL + } + + ctx := context.Background() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + StringData: data, + } + + existing, err := cs.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + if _, err := cs.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("creating Bedrock credentials secret: %w", err) + } + return nil + } + if err != nil { + return fmt.Errorf("checking Bedrock credentials secret: %w", err) + } + + if !skipConfirm { + ok, err := confirmOverride(fmt.Sprintf("secret/%s", name)) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("aborted") + } + } + + existing.Data = nil + existing.StringData = secret.StringData + if _, err := cs.CoreV1().Secrets(ns).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating Bedrock credentials secret: %w", err) + } + return nil +} diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 57517c1a..8e967328 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -168,6 +168,54 @@ func oauthEnvVar(agentType string) string { } } +// credentialEnvVars returns the environment variables to inject for the given +// credential type, agent type, and secret name. This centralises all +// credential-type-specific logic so that new providers (e.g. Vertex) only +// need to add a case here. +func credentialEnvVars(credType kelosv1alpha1.CredentialType, agentType, secretName string) []corev1.EnvVar { + secretRef := func(key string, optional bool) corev1.EnvVar { + sel := &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: key, + } + if optional { + sel.Optional = ptr(true) + } + return corev1.EnvVar{ + Name: key, + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: sel}, + } + } + + switch credType { + case kelosv1alpha1.CredentialTypeAPIKey: + keyName := apiKeyEnvVar(agentType) + return []corev1.EnvVar{secretRef(keyName, false)} + + case kelosv1alpha1.CredentialTypeOAuth: + tokenName := oauthEnvVar(agentType) + return []corev1.EnvVar{secretRef(tokenName, false)} + + case kelosv1alpha1.CredentialTypeBedrock: + return []corev1.EnvVar{ + {Name: "CLAUDE_CODE_USE_BEDROCK", Value: "1"}, + secretRef("AWS_ACCESS_KEY_ID", false), + secretRef("AWS_SECRET_ACCESS_KEY", false), + secretRef("AWS_REGION", false), + secretRef("AWS_SESSION_TOKEN", true), + secretRef("ANTHROPIC_BEDROCK_BASE_URL", true), + } + + default: + return nil + } +} + +// ptr returns a pointer to the given value. +func ptr[T any](v T) *T { + return &v +} + func effectiveWorkspaceRemotes(workspace *kelosv1alpha1.WorkspaceSpec) []kelosv1alpha1.GitRemote { if workspace == nil { return nil @@ -224,34 +272,8 @@ func (b *JobBuilder) buildAgentJob(task *kelosv1alpha1.Task, workspace *kelosv1a }) } - switch task.Spec.Credentials.Type { - case kelosv1alpha1.CredentialTypeAPIKey: - keyName := apiKeyEnvVar(task.Spec.Type) - envVars = append(envVars, corev1.EnvVar{ - Name: keyName, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: task.Spec.Credentials.SecretRef.Name, - }, - Key: keyName, - }, - }, - }) - case kelosv1alpha1.CredentialTypeOAuth: - tokenName := oauthEnvVar(task.Spec.Type) - envVars = append(envVars, corev1.EnvVar{ - Name: tokenName, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: task.Spec.Credentials.SecretRef.Name, - }, - Key: tokenName, - }, - }, - }) - } + credEnvVars := credentialEnvVars(task.Spec.Credentials.Type, task.Spec.Type, task.Spec.Credentials.SecretRef.Name) + envVars = append(envVars, credEnvVars...) var workspaceEnvVars []corev1.EnvVar var isEnterprise bool diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 6729441d..43b06d29 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -4246,3 +4246,89 @@ func TestBuildJob_UpstreamRepoSpecWithoutRemote(t *testing.T) { t.Error("Expected KELOS_UPSTREAM_REPO env var on main container") } } + +func TestBuildClaudeCodeJob_BedrockCredentials(t *testing.T) { + builder := NewJobBuilder() + task := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bedrock", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: AgentTypeClaudeCode, + Prompt: "Fix the bug", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeBedrock, + SecretRef: kelosv1alpha1.SecretReference{Name: "bedrock-creds"}, + }, + }, + } + + job, err := builder.Build(task, nil, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + + // Collect env vars by name for easier assertions. + envMap := make(map[string]corev1.EnvVar) + for _, env := range container.Env { + envMap[env.Name] = env + } + + // CLAUDE_CODE_USE_BEDROCK should be set as a literal value. + if env, ok := envMap["CLAUDE_CODE_USE_BEDROCK"]; !ok { + t.Error("Expected CLAUDE_CODE_USE_BEDROCK env var") + } else if env.Value != "1" { + t.Errorf("CLAUDE_CODE_USE_BEDROCK = %q, want %q", env.Value, "1") + } + + // Required AWS credentials should reference the secret. + for _, key := range []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"} { + env, ok := envMap[key] + if !ok { + t.Errorf("Expected %s env var", key) + continue + } + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Errorf("Expected %s to reference a secret", key) + continue + } + if env.ValueFrom.SecretKeyRef.Name != "bedrock-creds" { + t.Errorf("%s secret name = %q, want %q", key, env.ValueFrom.SecretKeyRef.Name, "bedrock-creds") + } + if env.ValueFrom.SecretKeyRef.Key != key { + t.Errorf("%s secret key = %q, want %q", key, env.ValueFrom.SecretKeyRef.Key, key) + } + if env.ValueFrom.SecretKeyRef.Optional != nil && *env.ValueFrom.SecretKeyRef.Optional { + t.Errorf("%s should not be optional", key) + } + } + + // Optional AWS credentials should be marked optional. + for _, key := range []string{"AWS_SESSION_TOKEN", "ANTHROPIC_BEDROCK_BASE_URL"} { + env, ok := envMap[key] + if !ok { + t.Errorf("Expected %s env var", key) + continue + } + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Errorf("Expected %s to reference a secret", key) + continue + } + if env.ValueFrom.SecretKeyRef.Optional == nil || !*env.ValueFrom.SecretKeyRef.Optional { + t.Errorf("%s should be optional", key) + } + } + + // ANTHROPIC_API_KEY should NOT be set for bedrock credential type. + if _, ok := envMap["ANTHROPIC_API_KEY"]; ok { + t.Error("ANTHROPIC_API_KEY should not be set for bedrock credential type") + } + + // CLAUDE_CODE_OAUTH_TOKEN should NOT be set. + if _, ok := envMap["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Error("CLAUDE_CODE_OAUTH_TOKEN should not be set for bedrock credential type") + } +} diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 9cff7acd..1ec0e328 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -317,6 +317,7 @@ spec: enum: - api-key - oauth + - bedrock type: string required: - secretRef @@ -805,6 +806,7 @@ spec: enum: - api-key - oauth + - bedrock type: string required: - secretRef