Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/v1alpha1/task_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions examples/09-bedrock-credentials/README.md
Original file line number Diff line number Diff line change
@@ -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=<your-access-key> \
--from-literal=AWS_SECRET_ACCESS_KEY=<your-secret-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.
14 changes: 14 additions & 0 deletions examples/09-bedrock-credentials/secret.yaml
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions examples/09-bedrock-credentials/task.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 81 additions & 3 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
78 changes: 50 additions & 28 deletions internal/controller/job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading