From d0a18b6a6ce894e11326a0d83f6059274b12301c Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 17:06:16 +0100 Subject: [PATCH 01/26] 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 From d9c994316c5627f8e09fe25cd116c2450163ad56 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 17:40:36 +0100 Subject: [PATCH 02/26] feat: add first-class IRSA support for bedrock credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Credentials.SecretRef a pointer (*SecretReference) so it can be omitted for bedrock credentials using IAM Roles for Service Accounts. Add Region and ServiceAccountName fields to Credentials for IRSA mode. CEL validation ensures secretRef remains required for api-key and oauth credential types. In IRSA mode, only CLAUDE_CODE_USE_BEDROCK=1 and AWS_REGION are injected — the AWS SDK handles auth via the projected service account token. Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/task_types.go | 19 +- api/v1alpha1/taskspawner_types.go | 1 + api/v1alpha1/zz_generated.deepcopy.go | 10 +- cmd/kelos-spawner/main_test.go | 16 +- examples/09-bedrock-credentials/README.md | 58 +++-- .../09-bedrock-credentials/task-irsa.yaml | 11 + internal/cli/config.go | 13 +- internal/cli/printer.go | 4 +- internal/cli/printer_test.go | 4 +- internal/cli/run.go | 60 +++-- internal/controller/job_builder.go | 66 ++++-- internal/controller/job_builder_test.go | 224 ++++++++++++------ internal/controller/task_controller_test.go | 4 +- .../taskspawner_deployment_builder_test.go | 4 +- internal/manifests/install-crd.yaml | 47 +++- internal/reporting/watcher_test.go | 4 +- test/e2e/opencode_test.go | 2 +- test/e2e/skills_test.go | 2 +- test/e2e/task_test.go | 16 +- test/e2e/taskspawner_test.go | 8 +- test/integration/cli_test.go | 22 +- test/integration/completion_test.go | 6 +- test/integration/install_test.go | 2 +- test/integration/metrics_test.go | 2 +- test/integration/task_test.go | 86 +++---- test/integration/taskspawner_test.go | 54 ++--- 26 files changed, 473 insertions(+), 272 deletions(-) create mode 100644 examples/09-bedrock-credentials/task-irsa.yaml diff --git a/api/v1alpha1/task_types.go b/api/v1alpha1/task_types.go index 2e572898..90435c7c 100644 --- a/api/v1alpha1/task_types.go +++ b/api/v1alpha1/task_types.go @@ -41,12 +41,26 @@ type SecretReference struct { // Credentials defines how to authenticate with the AI agent. type Credentials struct { - // Type specifies the credential type (api-key or oauth). + // Type specifies the credential type. // +kubebuilder:validation:Enum=api-key;oauth;bedrock Type CredentialType `json:"type"` // SecretRef references the Secret containing credentials. - SecretRef SecretReference `json:"secretRef"` + // Required for api-key and oauth types. Optional for bedrock + // when using IAM Roles for Service Accounts (IRSA). + // +optional + SecretRef *SecretReference `json:"secretRef,omitempty"` + + // Region specifies the cloud provider region (e.g. AWS region for Bedrock). + // Used with bedrock credentials when secretRef is omitted (IRSA mode). + // +optional + Region string `json:"region,omitempty"` + + // ServiceAccountName overrides the pod's service account. + // Use with IAM Roles for Service Accounts (IRSA) on EKS to let + // the pod assume an IAM role without static credentials. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // PodOverrides defines optional overrides for the agent pod. @@ -86,6 +100,7 @@ type TaskSpec struct { // Credentials specifies how to authenticate with the agent. // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self.type == 'bedrock' || has(self.secretRef)",message="secretRef is required for api-key and oauth credential types" Credentials Credentials `json:"credentials"` // Model optionally overrides the default model. diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index d35020e6..479f0cb8 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -304,6 +304,7 @@ type TaskTemplate struct { // Credentials specifies how to authenticate with the agent. // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self.type == 'bedrock' || has(self.secretRef)",message="secretRef is required for api-key and oauth credential types" Credentials Credentials `json:"credentials"` // Model optionally overrides the default model. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 69bc880b..83ace025 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -151,7 +151,11 @@ func (in *AgentDefinition) DeepCopy() *AgentDefinition { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Credentials) DeepCopyInto(out *Credentials) { *out = *in - out.SecretRef = in.SecretRef + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(SecretReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Credentials. @@ -703,7 +707,7 @@ func (in *TaskSpawnerStatus) DeepCopy() *TaskSpawnerStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TaskSpec) DeepCopyInto(out *TaskSpec) { *out = *in - out.Credentials = in.Credentials + in.Credentials.DeepCopyInto(&out.Credentials) if in.WorkspaceRef != nil { in, out := &in.WorkspaceRef, &out.WorkspaceRef *out = new(WorkspaceReference) @@ -779,7 +783,7 @@ func (in *TaskStatus) DeepCopy() *TaskStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TaskTemplate) DeepCopyInto(out *TaskTemplate) { *out = *in - out.Credentials = in.Credentials + in.Credentials.DeepCopyInto(&out.Credentials) if in.WorkspaceRef != nil { in, out := &in.WorkspaceRef, &out.WorkspaceRef *out = new(WorkspaceReference) diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index 0cec6029..5b4cb21f 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -77,7 +77,7 @@ func newTaskSpawner(name, namespace string, maxConcurrency *int32) *kelosv1alpha Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "test-ws"}, }, @@ -100,7 +100,7 @@ func newTask(name, namespace, spawnerName string, phase kelosv1alpha1.TaskPhase) Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, Status: kelosv1alpha1.TaskStatus{ @@ -212,7 +212,7 @@ func TestBuildSource_Jira(t *testing.T) { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, }, @@ -1186,7 +1186,7 @@ func newCompletedTask(name, namespace, spawnerName string, phase kelosv1alpha1.T Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, Status: kelosv1alpha1.TaskStatus{ @@ -1520,7 +1520,7 @@ func TestRunCycleWithSource_PropagatesUpstreamRepo(t *testing.T) { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, }, @@ -1563,7 +1563,7 @@ func TestRunCycleWithSource_ExplicitUpstreamRepoTakesPrecedence(t *testing.T) { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, UpstreamRepo: "explicit-org/explicit-repo", }, @@ -1843,7 +1843,7 @@ func TestRunReportingCycle_ReportsForAnnotatedTasks(t *testing.T) { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, Status: kelosv1alpha1.TaskStatus{ @@ -1904,7 +1904,7 @@ func TestRunReportingCycle_SkipsTasksWithoutReporting(t *testing.T) { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, Status: kelosv1alpha1.TaskStatus{ diff --git a/examples/09-bedrock-credentials/README.md b/examples/09-bedrock-credentials/README.md index e6aec0a9..cce3e76f 100644 --- a/examples/09-bedrock-credentials/README.md +++ b/examples/09-bedrock-credentials/README.md @@ -7,7 +7,7 @@ This example demonstrates running a Claude Code task using AWS Bedrock instead o - AWS account with Bedrock access enabled for Claude models - AWS IAM credentials with `bedrock:InvokeModel` permissions -## Setup +## Option 1: Static Credentials (Secret) 1. Create the Secret with your AWS credentials: @@ -24,9 +24,12 @@ This example demonstrates running a Claude Code task using AWS Bedrock instead o kubectl apply -f task.yaml ``` -## Using the CLI +### Optional Secret Keys -You can also use `kelos run` with a config file: +- `AWS_SESSION_TOKEN`: Required when using temporary credentials (e.g. from STS AssumeRole) +- `ANTHROPIC_BEDROCK_BASE_URL`: Custom Bedrock endpoint URL + +### CLI with Static Credentials ```yaml # ~/.kelos/config.yaml @@ -46,27 +49,42 @@ Or with a pre-created secret: kelos run -p "Fix the bug" --credential-type bedrock --secret bedrock-credentials ``` -## Optional Fields +## Option 2: IAM Roles for Service Accounts (IRSA) -- `AWS_SESSION_TOKEN`: Required when using temporary credentials (e.g. from STS AssumeRole) -- `ANTHROPIC_BEDROCK_BASE_URL`: Custom Bedrock endpoint URL +On EKS, you can use IRSA instead of static credentials. The AWS SDK automatically picks up credentials from the projected service account token — no Secret needed. + +### Prerequisites -## IAM Roles for Service Accounts (IRSA) +1. Create an IAM role with `bedrock:InvokeModel` permissions +2. Create a Kubernetes ServiceAccount annotated with the IAM role: -On EKS, you can use IRSA instead of static credentials. In that case, use `podOverrides.env` to set only the required environment variables: + ```bash + kubectl create serviceaccount bedrock-agent-sa + kubectl annotate serviceaccount bedrock-agent-sa \ + eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/bedrock-agent-role + ``` + +3. Create the Task with `region` and `serviceAccountName` (no `secretRef`): + + ```bash + kubectl apply -f task-irsa.yaml + ``` + +### CLI with IRSA ```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 +# ~/.kelos/config.yaml +bedrock: + region: us-east-1 + serviceAccountName: bedrock-agent-sa ``` -Note: First-class IRSA support (making `secretRef` optional for bedrock) is planned for a future release. +```bash +kelos run -p "Fix the bug" +``` + +Or with flags: + +```bash +kelos run -p "Fix the bug" --credential-type bedrock --region us-east-1 --service-account bedrock-agent-sa +``` diff --git a/examples/09-bedrock-credentials/task-irsa.yaml b/examples/09-bedrock-credentials/task-irsa.yaml new file mode 100644 index 00000000..96a21ee1 --- /dev/null +++ b/examples/09-bedrock-credentials/task-irsa.yaml @@ -0,0 +1,11 @@ +apiVersion: kelos.dev/v1alpha1 +kind: Task +metadata: + name: bedrock-irsa-task +spec: + type: claude-code + prompt: "Write a Python script that prints the first 20 Fibonacci numbers." + credentials: + type: bedrock + region: us-east-1 + serviceAccountName: bedrock-agent-sa diff --git a/internal/cli/config.go b/internal/cli/config.go index 214471e9..9e43fe88 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -23,12 +23,15 @@ type Config struct { } // BedrockConfig holds AWS credentials for Bedrock authentication. +// For IRSA mode, omit accessKeyID and secretAccessKey and set only region +// and serviceAccountName. 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"` + AccessKeyID string `json:"accessKeyID,omitempty"` + SecretAccessKey string `json:"secretAccessKey,omitempty"` + Region string `json:"region"` + SessionToken string `json:"sessionToken,omitempty"` + BaseURL string `json:"baseURL,omitempty"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // WorkspaceConfig holds workspace-related configuration. diff --git a/internal/cli/printer.go b/internal/cli/printer.go index e20c90fd..930e1192 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -64,7 +64,9 @@ func printTaskDetail(w io.Writer, t *kelosv1alpha1.Task) { printField(w, "Type", t.Spec.Type) printField(w, "Phase", string(t.Status.Phase)) printField(w, "Prompt", t.Spec.Prompt) - printField(w, "Secret", t.Spec.Credentials.SecretRef.Name) + if t.Spec.Credentials.SecretRef != nil { + printField(w, "Secret", t.Spec.Credentials.SecretRef.Name) + } printField(w, "Credential Type", string(t.Spec.Credentials.Type)) if t.Spec.Model != "" { printField(w, "Model", t.Spec.Model) diff --git a/internal/cli/printer_test.go b/internal/cli/printer_test.go index 80330601..e74ec1b6 100644 --- a/internal/cli/printer_test.go +++ b/internal/cli/printer_test.go @@ -739,7 +739,7 @@ func TestPrintTaskDetail(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Model: "claude-sonnet-4-20250514", Image: "custom-image:latest", @@ -993,7 +993,7 @@ func TestPrintTaskDetailMinimal(t *testing.T) { Prompt: "Do something", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "secret"}, }, }, Status: kelosv1alpha1.TaskStatus{ diff --git a/internal/cli/run.go b/internal/cli/run.go index d0a56be7..b52c25c0 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -50,6 +50,8 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { agentConfigRef string dependsOn []string branch string + region string + serviceAccount string ) cmd := &cobra.Command{ @@ -119,20 +121,32 @@ 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 + hasStaticCreds := br.AccessKeyID != "" || br.SecretAccessKey != "" + if hasStaticCreds { + if br.AccessKeyID == "" || br.SecretAccessKey == "" || br.Region == "" { + return fmt.Errorf("bedrock config requires accessKeyID, secretAccessKey, and region when using static credentials") + } + if !dryRun { + if err := ensureBedrockSecret(cfg, "kelos-credentials", br, yes); err != nil { + return err + } + } + secret = "kelos-credentials" + } else { + // IRSA mode — no secret needed, region is set on credentials directly. + if br.Region == "" { + return fmt.Errorf("bedrock config requires region") + } + region = br.Region + if br.ServiceAccountName != "" && serviceAccount == "" { + serviceAccount = br.ServiceAccountName } } - secret = "kelos-credentials" credentialType = "bedrock" } } - if secret == "" { + if secret == "" && credentialType != "bedrock" { return fmt.Errorf("no credentials configured (set oauthToken/apiKey in config file, or use --secret flag)") } @@ -214,22 +228,28 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { name = "task-" + rand.String(5) } + creds := kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialType(credentialType), + Region: region, + } + if secret != "" { + creds.SecretRef = &kelosv1alpha1.SecretReference{Name: secret} + } + if serviceAccount != "" { + creds.ServiceAccountName = serviceAccount + } + task := &kelosv1alpha1.Task{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: ns, }, Spec: kelosv1alpha1.TaskSpec{ - Type: agentType, - Prompt: prompt, - Credentials: kelosv1alpha1.Credentials{ - Type: kelosv1alpha1.CredentialType(credentialType), - SecretRef: kelosv1alpha1.SecretReference{ - Name: secret, - }, - }, - Model: model, - Image: image, + Type: agentType, + Prompt: prompt, + Credentials: creds, + Model: model, + Image: image, }, } @@ -309,7 +329,9 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { cmd.Flags().StringVarP(&prompt, "prompt", "p", "", "task prompt (required)") cmd.Flags().StringVarP(&agentType, "type", "t", "claude-code", "agent type (claude-code, codex, gemini, opencode, cursor)") cmd.Flags().StringVar(&secret, "secret", "", "secret name with credentials (overrides oauthToken/apiKey in config)") - cmd.Flags().StringVar(&credentialType, "credential-type", "api-key", "credential type (api-key, oauth)") + cmd.Flags().StringVar(&credentialType, "credential-type", "api-key", "credential type (api-key, oauth, bedrock)") + cmd.Flags().StringVar(®ion, "region", "", "cloud provider region (e.g. us-east-1 for Bedrock IRSA)") + cmd.Flags().StringVar(&serviceAccount, "service-account", "", "pod service account name (e.g. for IRSA on EKS)") cmd.Flags().StringVar(&model, "model", "", "model override") cmd.Flags().StringVar(&image, "image", "", "custom agent image (must implement agent image interface)") cmd.Flags().StringVar(&name, "name", "", "task name (auto-generated if omitted)") diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 8e967328..c10c5548 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -169,11 +169,15 @@ 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 { +// credentials and agent type. This centralises all credential-type-specific +// logic so that new providers (e.g. Vertex) only need to add a case here. +func credentialEnvVars(creds kelosv1alpha1.Credentials, agentType string) []corev1.EnvVar { + secretName := "" + if creds.SecretRef != nil { + secretName = creds.SecretRef.Name + } + + secretEnvRef := func(key string, optional bool) corev1.EnvVar { sel := &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: key, @@ -187,24 +191,37 @@ func credentialEnvVars(credType kelosv1alpha1.CredentialType, agentType, secretN } } - switch credType { + switch creds.Type { case kelosv1alpha1.CredentialTypeAPIKey: keyName := apiKeyEnvVar(agentType) - return []corev1.EnvVar{secretRef(keyName, false)} + return []corev1.EnvVar{secretEnvRef(keyName, false)} case kelosv1alpha1.CredentialTypeOAuth: tokenName := oauthEnvVar(agentType) - return []corev1.EnvVar{secretRef(tokenName, false)} + return []corev1.EnvVar{secretEnvRef(tokenName, false)} case kelosv1alpha1.CredentialTypeBedrock: - return []corev1.EnvVar{ + envs := []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), } + if secretName != "" { + // Static credentials from a Secret. + envs = append(envs, + secretEnvRef("AWS_ACCESS_KEY_ID", false), + secretEnvRef("AWS_SECRET_ACCESS_KEY", false), + secretEnvRef("AWS_REGION", false), + secretEnvRef("AWS_SESSION_TOKEN", true), + secretEnvRef("ANTHROPIC_BEDROCK_BASE_URL", true), + ) + } else if creds.Region != "" { + // IRSA mode — SDK picks up credentials from the projected + // service account token; only the region is needed. + envs = append(envs, corev1.EnvVar{ + Name: "AWS_REGION", + Value: creds.Region, + }) + } + return envs default: return nil @@ -272,7 +289,7 @@ func (b *JobBuilder) buildAgentJob(task *kelosv1alpha1.Task, workspace *kelosv1a }) } - credEnvVars := credentialEnvVars(task.Spec.Credentials.Type, task.Spec.Type, task.Spec.Credentials.SecretRef.Name) + credEnvVars := credentialEnvVars(task.Spec.Credentials, task.Spec.Type) envVars = append(envVars, credEnvVars...) var workspaceEnvVars []corev1.EnvVar @@ -566,6 +583,12 @@ func (b *JobBuilder) buildAgentJob(task *kelosv1alpha1.Task, workspace *kelosv1a } } + // ServiceAccountName from credentials (e.g. IRSA for Bedrock). + var serviceAccountName string + if task.Spec.Credentials.ServiceAccountName != "" { + serviceAccountName = task.Spec.Credentials.ServiceAccountName + } + // Apply PodOverrides before constructing the Job so all overrides // are reflected in the final spec. var activeDeadlineSeconds *int64 @@ -648,12 +671,13 @@ func (b *JobBuilder) buildAgentJob(task *kelosv1alpha1.Task, workspace *kelosv1a }, }, Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - SecurityContext: podSecurityContext, - InitContainers: initContainers, - Volumes: volumes, - Containers: []corev1.Container{mainContainer}, - NodeSelector: nodeSelector, + RestartPolicy: corev1.RestartPolicyNever, + SecurityContext: podSecurityContext, + ServiceAccountName: serviceAccountName, + InitContainers: initContainers, + Volumes: volumes, + Containers: []corev1.Container{mainContainer}, + NodeSelector: nodeSelector, }, }, }, diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 43b06d29..7a2e0194 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -25,7 +25,7 @@ func TestBuildClaudeCodeJob_DefaultImage(t *testing.T) { Prompt: "Hello world", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Model: "claude-sonnet-4-20250514", }, @@ -80,7 +80,7 @@ func TestBuildClaudeCodeJob_CustomImage(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Model: "my-model", Image: "my-custom-agent:latest", @@ -136,7 +136,7 @@ func TestBuildClaudeCodeJob_NoModel(t *testing.T) { Prompt: "Hello", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -168,7 +168,7 @@ func TestBuildClaudeCodeJob_WorkspaceWithRef(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -235,7 +235,7 @@ func TestBuildClaudeCodeJob_WorkspaceWithInjectedFiles(t *testing.T) { Prompt: "Inject plugin files", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -307,7 +307,7 @@ func TestBuildClaudeCodeJob_WorkspaceWithInjectedFilesInvalidPath(t *testing.T) Prompt: "Inject plugin files", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -343,7 +343,7 @@ func TestBuildClaudeCodeJob_CustomImageWithWorkspace(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Image: "my-agent:v1", Model: "gpt-4", @@ -425,7 +425,7 @@ func TestBuildClaudeCodeJob_WorkspaceWithSecretRefPersistsCredentialHelper(t *te Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -483,7 +483,7 @@ func TestBuildClaudeCodeJob_EnterpriseWorkspaceSetsGHHostAndEnterpriseToken(t *t Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -562,7 +562,7 @@ func TestBuildClaudeCodeJob_GithubComWorkspaceUsesGHToken(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -615,7 +615,7 @@ func TestBuildCodexJob_DefaultImage(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "openai-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "openai-secret"}, }, Model: "gpt-4.1", }, @@ -699,7 +699,7 @@ func TestBuildCodexJob_CustomImage(t *testing.T) { Prompt: "Refactor the module", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "openai-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "openai-secret"}, }, Image: "my-codex:v2", }, @@ -735,7 +735,7 @@ func TestBuildCodexJob_WithWorkspace(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "openai-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "openai-secret"}, }, Model: "gpt-4.1", }, @@ -810,7 +810,7 @@ func TestBuildCodexJob_OAuthCredentials(t *testing.T) { Prompt: "Review the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "codex-oauth"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "codex-oauth"}, }, }, } @@ -862,7 +862,7 @@ func TestBuildGeminiJob_DefaultImage(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "gemini-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "gemini-secret"}, }, Model: "gemini-2.5-pro", }, @@ -949,7 +949,7 @@ func TestBuildGeminiJob_CustomImage(t *testing.T) { Prompt: "Refactor the module", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "gemini-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "gemini-secret"}, }, Image: "my-gemini:v2", }, @@ -985,7 +985,7 @@ func TestBuildGeminiJob_WithWorkspace(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "gemini-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "gemini-secret"}, }, Model: "gemini-2.5-pro", }, @@ -1063,7 +1063,7 @@ func TestBuildGeminiJob_OAuthCredentials(t *testing.T) { Prompt: "Review the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "gemini-oauth"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "gemini-oauth"}, }, }, } @@ -1115,7 +1115,7 @@ func TestBuildOpenCodeJob_DefaultImage(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "opencode-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "opencode-secret"}, }, Model: "anthropic/claude-sonnet-4-20250514", }, @@ -1205,7 +1205,7 @@ func TestBuildOpenCodeJob_CustomImage(t *testing.T) { Prompt: "Refactor the module", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "opencode-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "opencode-secret"}, }, Image: "my-opencode:v2", }, @@ -1241,7 +1241,7 @@ func TestBuildOpenCodeJob_WithWorkspace(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "opencode-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "opencode-secret"}, }, Model: "anthropic/claude-sonnet-4-20250514", }, @@ -1322,7 +1322,7 @@ func TestBuildOpenCodeJob_OAuthCredentials(t *testing.T) { Prompt: "Review the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "opencode-oauth"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "opencode-oauth"}, }, }, } @@ -1377,7 +1377,7 @@ func TestBuildCursorJob_DefaultImage(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "cursor-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "cursor-secret"}, }, Model: "claude-sonnet-4-20250514", }, @@ -1461,7 +1461,7 @@ func TestBuildCursorJob_CustomImage(t *testing.T) { Prompt: "Refactor the module", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "cursor-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "cursor-secret"}, }, Image: "my-cursor:v2", }, @@ -1495,7 +1495,7 @@ func TestBuildCursorJob_OAuthCredentials(t *testing.T) { Prompt: "Review the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "cursor-oauth"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "cursor-oauth"}, }, }, } @@ -1555,7 +1555,7 @@ func TestBuildClaudeCodeJob_UnsupportedType(t *testing.T) { Prompt: "Hello", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -1580,7 +1580,7 @@ func TestBuildJob_PodOverridesResources(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, PodOverrides: &kelosv1alpha1.PodOverrides{ Resources: &corev1.ResourceRequirements{ @@ -1634,7 +1634,7 @@ func TestBuildJob_PodOverridesActiveDeadlineSeconds(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, PodOverrides: &kelosv1alpha1.PodOverrides{ ActiveDeadlineSeconds: int64Ptr(1800), @@ -1667,7 +1667,7 @@ func TestBuildJob_PodOverridesEnv(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Model: "claude-sonnet-4-20250514", PodOverrides: &kelosv1alpha1.PodOverrides{ @@ -1718,7 +1718,7 @@ func TestBuildJob_PodOverridesEnvBuiltinPrecedence(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Model: "claude-sonnet-4-20250514", PodOverrides: &kelosv1alpha1.PodOverrides{ @@ -1765,7 +1765,7 @@ func TestBuildJob_PodOverridesNodeSelector(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, PodOverrides: &kelosv1alpha1.PodOverrides{ NodeSelector: map[string]string{ @@ -1805,7 +1805,7 @@ func TestBuildJob_PodOverridesAllFields(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "openai-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "openai-secret"}, }, PodOverrides: &kelosv1alpha1.PodOverrides{ Resources: &corev1.ResourceRequirements{ @@ -1871,7 +1871,7 @@ func TestBuildJob_NoPodOverrides(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -1911,7 +1911,7 @@ func TestBuildJob_AgentConfigAgentsMD(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -1962,7 +1962,7 @@ func TestBuildJob_AgentConfigPlugins(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2067,7 +2067,7 @@ func TestBuildJob_AgentConfigFull(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2126,7 +2126,7 @@ func TestBuildJob_AgentConfigSkills(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2235,7 +2235,7 @@ func TestBuildJob_AgentConfigSkillsWithPlugins(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2294,7 +2294,7 @@ func TestBuildJob_AgentConfigSkillsEmptySource(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2326,7 +2326,7 @@ func TestBuildJob_AgentConfigWithWorkspace(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2387,7 +2387,7 @@ func TestBuildJob_AgentConfigWithoutWorkspace(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2437,7 +2437,7 @@ func TestBuildJob_AgentConfigCodex(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2506,7 +2506,7 @@ func TestBuildJob_AgentConfigGemini(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2572,7 +2572,7 @@ func TestBuildJob_AgentConfigOpenCode(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2641,7 +2641,7 @@ func TestBuildJob_AgentConfigPluginNamePathTraversal(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2715,7 +2715,7 @@ func TestBuildJob_BranchSetupInitContainer(t *testing.T) { Branch: "feature-x", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2798,7 +2798,7 @@ func TestBuildJob_BranchSetupWithSecretRefUsesCredentialHelper(t *testing.T) { Branch: "feature-y", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2857,7 +2857,7 @@ func TestBuildJob_BranchWithoutWorkspaceNoInitContainer(t *testing.T) { Branch: "feature-z", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2898,7 +2898,7 @@ func TestBuildJob_BranchEnvDoesNotMutateWorkspaceEnvVars(t *testing.T) { Branch: "feature-w", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2952,7 +2952,7 @@ func TestBuildJob_KelosAgentTypeAlwaysSet(t *testing.T) { Prompt: "Hello", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -2991,7 +2991,7 @@ func TestBuildJob_AgentConfigMCPServers(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3098,7 +3098,7 @@ func TestBuildJob_AgentConfigMCPServersWithHTTPHeaders(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3161,7 +3161,7 @@ func TestBuildJob_AgentConfigMCPServersWithPluginsAndAgentsMD(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3230,7 +3230,7 @@ func TestBuildJob_AgentConfigMCPServersCodex(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "openai-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "openai-secret"}, }, }, } @@ -3274,7 +3274,7 @@ func TestBuildJob_AgentConfigMCPServersGemini(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "gemini-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "gemini-secret"}, }, }, } @@ -3318,7 +3318,7 @@ func TestBuildJob_AgentConfigMCPServersEmptyName(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3354,7 +3354,7 @@ func TestBuildJob_AgentConfigMCPServersDuplicateName(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3399,7 +3399,7 @@ func TestBuildJob_AgentConfigMCPServerNamePathTraversal(t *testing.T) { Prompt: "Fix issue", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3431,7 +3431,7 @@ func TestBuildJob_KelosBaseBranchSetWhenWorkspaceRefPresent(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3473,7 +3473,7 @@ func TestBuildJob_KelosBaseBranchAbsentWhenRefEmpty(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3507,7 +3507,7 @@ func TestBuildJob_KelosBaseBranchAbsentWithoutWorkspace(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3537,7 +3537,7 @@ func TestBuildJob_WorkspaceWithOneRemote(t *testing.T) { Prompt: "Work on feature", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3588,7 +3588,7 @@ func TestBuildJob_WorkspaceWithMultipleRemotes(t *testing.T) { Prompt: "Work on feature", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3639,7 +3639,7 @@ func TestBuildJob_WorkspaceWithNoRemotesNoRemoteSetupContainer(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3674,7 +3674,7 @@ func TestBuildJob_RemoteSetupOrderingWithBranchSetup(t *testing.T) { Branch: "feature-x", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3730,7 +3730,7 @@ func TestBuildJob_RemoteSetupQuotesShellMetacharacters(t *testing.T) { Prompt: "Do work", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3777,7 +3777,7 @@ func TestBuildJob_WorkspaceWithUpstreamRemoteInjectsEnv(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3823,7 +3823,7 @@ func TestBuildJob_WorkspaceWithNonUpstreamRemoteNoEnv(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3861,7 +3861,7 @@ func TestBuildJob_WorkspaceWithInvalidUpstreamRemoteNoEnv(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3902,7 +3902,7 @@ func TestBuildJob_TaskSpawnerLabelInjectsEnv(t *testing.T) { Prompt: "Hello", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3939,7 +3939,7 @@ func TestBuildJob_NoTaskSpawnerLabelNoEnv(t *testing.T) { Prompt: "Hello", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -3969,7 +3969,7 @@ func TestBuildJob_PodFailurePolicy(t *testing.T) { Prompt: "Hello", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -4033,7 +4033,7 @@ func TestBuildJob_GHConfigDirNotSetWithoutSecretRef(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -4067,7 +4067,7 @@ func TestBuildJob_CustomImageGetsGHConfigDir(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, Image: "my-custom-agent:latest", }, @@ -4116,7 +4116,7 @@ func TestBuildJob_CredentialHelperClearsInheritedHelpers(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, }, } @@ -4168,7 +4168,7 @@ func TestBuildJob_UpstreamRepoSpecOverridesRemote(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, UpstreamRepo: "override-org/override-repo", }, @@ -4215,7 +4215,7 @@ func TestBuildJob_UpstreamRepoSpecWithoutRemote(t *testing.T) { Prompt: "Fix the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "my-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"}, }, UpstreamRepo: "upstream-org/upstream-repo", }, @@ -4259,7 +4259,7 @@ func TestBuildClaudeCodeJob_BedrockCredentials(t *testing.T) { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeBedrock, - SecretRef: kelosv1alpha1.SecretReference{Name: "bedrock-creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "bedrock-creds"}, }, }, } @@ -4332,3 +4332,71 @@ func TestBuildClaudeCodeJob_BedrockCredentials(t *testing.T) { t.Error("CLAUDE_CODE_OAUTH_TOKEN should not be set for bedrock credential type") } } + +func TestBuildClaudeCodeJob_BedrockIRSA(t *testing.T) { + builder := NewJobBuilder() + task := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bedrock-irsa", + Namespace: "default", + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: AgentTypeClaudeCode, + Prompt: "Fix the bug", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeBedrock, + Region: "us-west-2", + ServiceAccountName: "bedrock-agent-sa", + }, + }, + } + + 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. + 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") + } + + // AWS_REGION should be set as a literal value (not from a secret). + if env, ok := envMap["AWS_REGION"]; !ok { + t.Error("Expected AWS_REGION env var") + } else { + if env.Value != "us-west-2" { + t.Errorf("AWS_REGION = %q, want %q", env.Value, "us-west-2") + } + if env.ValueFrom != nil { + t.Error("AWS_REGION should be a literal value, not a secret reference") + } + } + + // Static AWS credentials should NOT be set in IRSA mode. + for _, key := range []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "ANTHROPIC_BEDROCK_BASE_URL"} { + if _, ok := envMap[key]; ok { + t.Errorf("%s should not be set in IRSA mode (no secretRef)", key) + } + } + + // ANTHROPIC_API_KEY should NOT be set. + if _, ok := envMap["ANTHROPIC_API_KEY"]; ok { + t.Error("ANTHROPIC_API_KEY should not be set for bedrock credential type") + } + + // ServiceAccountName should be set on the pod spec. + if job.Spec.Template.Spec.ServiceAccountName != "bedrock-agent-sa" { + t.Errorf("ServiceAccountName = %q, want %q", job.Spec.Template.Spec.ServiceAccountName, "bedrock-agent-sa") + } +} diff --git a/internal/controller/task_controller_test.go b/internal/controller/task_controller_test.go index d4895345..a1323e9f 100644 --- a/internal/controller/task_controller_test.go +++ b/internal/controller/task_controller_test.go @@ -607,7 +607,7 @@ func TestUpdateStatusRefreshesPodName(t *testing.T) { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "creds", }, }, @@ -663,7 +663,7 @@ func TestUpdateStatusClearsStalePodNameWhenNoLivePodsRemain(t *testing.T) { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "creds", }, }, diff --git a/internal/controller/taskspawner_deployment_builder_test.go b/internal/controller/taskspawner_deployment_builder_test.go index c17ae3eb..ca7e4e73 100644 --- a/internal/controller/taskspawner_deployment_builder_test.go +++ b/internal/controller/taskspawner_deployment_builder_test.go @@ -1483,7 +1483,7 @@ func TestBuildCronJob_BasicSchedule(t *testing.T) { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, }, @@ -1611,7 +1611,7 @@ func TestIsCronBased(t *testing.T) { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, }, diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 1ec0e328..30b14269 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -303,8 +303,16 @@ spec: credentials: description: Credentials specifies how to authenticate with the agent. properties: + region: + description: |- + Region specifies the cloud provider region (e.g. AWS region for Bedrock). + Used with bedrock credentials when secretRef is omitted (IRSA mode). + type: string secretRef: - description: SecretRef references the Secret containing credentials. + description: |- + SecretRef references the Secret containing credentials. + Required for api-key and oauth types. Optional for bedrock + when using IAM Roles for Service Accounts (IRSA). properties: name: description: Name is the name of the secret. @@ -312,17 +320,26 @@ spec: required: - name type: object + serviceAccountName: + description: |- + ServiceAccountName overrides the pod's service account. + Use with IAM Roles for Service Accounts (IRSA) on EKS to let + the pod assume an IAM role without static credentials. + type: string type: - description: Type specifies the credential type (api-key or oauth). + description: Type specifies the credential type. enum: - api-key - oauth - bedrock type: string required: - - secretRef - type type: object + x-kubernetes-validations: + - message: secretRef is required for api-key and oauth credential + types + rule: self.type == 'bedrock' || has(self.secretRef) dependsOn: description: DependsOn lists Task names that must succeed before this Task starts. @@ -791,8 +808,16 @@ spec: description: Credentials specifies how to authenticate with the agent. properties: + region: + description: |- + Region specifies the cloud provider region (e.g. AWS region for Bedrock). + Used with bedrock credentials when secretRef is omitted (IRSA mode). + type: string secretRef: - description: SecretRef references the Secret containing credentials. + description: |- + SecretRef references the Secret containing credentials. + Required for api-key and oauth types. Optional for bedrock + when using IAM Roles for Service Accounts (IRSA). properties: name: description: Name is the name of the secret. @@ -800,18 +825,26 @@ spec: required: - name type: object + serviceAccountName: + description: |- + ServiceAccountName overrides the pod's service account. + Use with IAM Roles for Service Accounts (IRSA) on EKS to let + the pod assume an IAM role without static credentials. + type: string type: - description: Type specifies the credential type (api-key or - oauth). + description: Type specifies the credential type. enum: - api-key - oauth - bedrock type: string required: - - secretRef - type type: object + x-kubernetes-validations: + - message: secretRef is required for api-key and oauth credential + types + rule: self.type == 'bedrock' || has(self.secretRef) dependsOn: description: DependsOn lists Task names that spawned Tasks depend on. diff --git a/internal/reporting/watcher_test.go b/internal/reporting/watcher_test.go index 9605af04..4c6b471f 100644 --- a/internal/reporting/watcher_test.go +++ b/internal/reporting/watcher_test.go @@ -107,7 +107,7 @@ func newTaskWithAnnotations(name, namespace string, phase kelosv1alpha1.TaskPhas Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, Status: kelosv1alpha1.TaskStatus{ @@ -555,7 +555,7 @@ func TestReportTaskStatus_NilAnnotations(t *testing.T) { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "creds"}, }, }, Status: kelosv1alpha1.TaskStatus{ diff --git a/test/e2e/opencode_test.go b/test/e2e/opencode_test.go index e8b92d13..05285dd3 100644 --- a/test/e2e/opencode_test.go +++ b/test/e2e/opencode_test.go @@ -31,7 +31,7 @@ var _ = Describe("OpenCode Task", func() { Prompt: "Print 'Hello from OpenCode e2e test' to stdout", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "opencode-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "opencode-credentials"}, }, }, }) diff --git a/test/e2e/skills_test.go b/test/e2e/skills_test.go index 11f09145..0adbf507 100644 --- a/test/e2e/skills_test.go +++ b/test/e2e/skills_test.go @@ -81,7 +81,7 @@ var _ = Describe("Task with skills.sh AgentConfig", func() { Prompt: "Print 'Hello from skills.sh e2e test' to stdout", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, AgentConfigRef: &kelosv1alpha1.AgentConfigReference{ Name: "skills-ac", diff --git a/test/e2e/task_test.go b/test/e2e/task_test.go index 360d1e5d..920eaece 100644 --- a/test/e2e/task_test.go +++ b/test/e2e/task_test.go @@ -37,7 +37,7 @@ func describeAgentTests(cfg agentTestConfig) { Prompt: "Print 'Hello from Kelos e2e test' to stdout", Credentials: kelosv1alpha1.Credentials{ Type: cfg.CredentialType, - SecretRef: kelosv1alpha1.SecretReference{Name: cfg.SecretName}, + SecretRef: &kelosv1alpha1.SecretReference{Name: cfg.SecretName}, }, }, }) @@ -92,7 +92,7 @@ func describeAgentTests(cfg agentTestConfig) { Prompt: "Create a file called 'test.txt' with the content 'hello' in the current directory and print 'done'", Credentials: kelosv1alpha1.Credentials{ Type: cfg.CredentialType, - SecretRef: kelosv1alpha1.SecretReference{Name: cfg.SecretName}, + SecretRef: &kelosv1alpha1.SecretReference{Name: cfg.SecretName}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "e2e-workspace"}, }, @@ -153,7 +153,7 @@ func describeAgentTests(cfg agentTestConfig) { Prompt: "Print 'hello' to stdout", Credentials: kelosv1alpha1.Credentials{ Type: cfg.CredentialType, - SecretRef: kelosv1alpha1.SecretReference{Name: cfg.SecretName}, + SecretRef: &kelosv1alpha1.SecretReference{Name: cfg.SecretName}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "e2e-outputs-workspace"}, }, @@ -225,7 +225,7 @@ func describeAgentTests(cfg agentTestConfig) { Prompt: "Print 'Task A done' to stdout", Credentials: kelosv1alpha1.Credentials{ Type: cfg.CredentialType, - SecretRef: kelosv1alpha1.SecretReference{Name: cfg.SecretName}, + SecretRef: &kelosv1alpha1.SecretReference{Name: cfg.SecretName}, }, }, }) @@ -242,7 +242,7 @@ func describeAgentTests(cfg agentTestConfig) { DependsOn: []string{"dep-chain-a"}, Credentials: kelosv1alpha1.Credentials{ Type: cfg.CredentialType, - SecretRef: kelosv1alpha1.SecretReference{Name: cfg.SecretName}, + SecretRef: &kelosv1alpha1.SecretReference{Name: cfg.SecretName}, }, }, }) @@ -288,7 +288,7 @@ func describeAgentTests(cfg agentTestConfig) { Prompt: "Print 'Hello' to stdout", Credentials: kelosv1alpha1.Credentials{ Type: cfg.CredentialType, - SecretRef: kelosv1alpha1.SecretReference{Name: cfg.SecretName}, + SecretRef: &kelosv1alpha1.SecretReference{Name: cfg.SecretName}, }, }, }) @@ -336,7 +336,7 @@ var _ = Describe("Task with make available", func() { Prompt: "Run 'make --version' and print the output", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, }, }) @@ -400,7 +400,7 @@ var _ = Describe("Task with workspace and secretRef", func() { Prompt: "Run 'gh auth status' and print the output", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "e2e-github-workspace"}, }, diff --git a/test/e2e/taskspawner_test.go b/test/e2e/taskspawner_test.go index 471b963c..d1e12020 100644 --- a/test/e2e/taskspawner_test.go +++ b/test/e2e/taskspawner_test.go @@ -63,7 +63,7 @@ var _ = Describe("TaskSpawner", func() { }, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, PromptTemplate: "Fix: {{.Title}}\n{{.Body}}", }, @@ -112,7 +112,7 @@ var _ = Describe("TaskSpawner", func() { }, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, }, PollInterval: "5m", @@ -174,7 +174,7 @@ var _ = Describe("Cron TaskSpawner", func() { Model: testModel, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, PromptTemplate: "Cron triggered at {{.Time}} (schedule: {{.Schedule}}). Print 'Hello from cron'", }, @@ -212,7 +212,7 @@ var _ = Describe("Cron TaskSpawner", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{Name: "claude-credentials"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "claude-credentials"}, }, }, PollInterval: "5m", diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index ed610eca..41d9faed 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -198,7 +198,7 @@ var _ = Describe("CLI Delete All Commands", func() { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -289,7 +289,7 @@ var _ = Describe("CLI Delete All Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -379,7 +379,7 @@ var _ = Describe("CLI Delete TaskSpawner Command", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -427,7 +427,7 @@ var _ = Describe("CLI Delete TaskSpawner Command", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -475,7 +475,7 @@ var _ = Describe("CLI Delete TaskSpawner Command", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -543,7 +543,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -589,7 +589,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -635,7 +635,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -679,7 +679,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -717,7 +717,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -770,7 +770,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, diff --git a/test/integration/completion_test.go b/test/integration/completion_test.go index 99e295a8..05d9c7e4 100644 --- a/test/integration/completion_test.go +++ b/test/integration/completion_test.go @@ -68,7 +68,7 @@ var _ = Describe("Completion", func() { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -106,7 +106,7 @@ var _ = Describe("Completion", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, @@ -143,7 +143,7 @@ var _ = Describe("Completion", func() { Prompt: "test", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "test-secret", }, }, diff --git a/test/integration/install_test.go b/test/integration/install_test.go index 61ad178b..aa32ede3 100644 --- a/test/integration/install_test.go +++ b/test/integration/install_test.go @@ -241,7 +241,7 @@ var _ = Describe("Install/Uninstall", Ordered, func() { Prompt: "test prompt", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "fake-secret"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "fake-secret"}, }, }, } diff --git a/test/integration/metrics_test.go b/test/integration/metrics_test.go index d53755ec..64da1b10 100644 --- a/test/integration/metrics_test.go +++ b/test/integration/metrics_test.go @@ -91,7 +91,7 @@ func createAndCompleteTask(nsName, taskName, spawner, model string) *kelosv1alph Prompt: fmt.Sprintf("Test task %s", taskName), Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, diff --git a/test/integration/task_test.go b/test/integration/task_test.go index ef339816..642996d7 100644 --- a/test/integration/task_test.go +++ b/test/integration/task_test.go @@ -79,7 +79,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Create a hello world program", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -234,7 +234,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Create a hello world program", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-oauth", }, }, @@ -339,7 +339,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Resolve MCP headersFrom", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, AgentConfigRef: &kelosv1alpha1.AgentConfigReference{Name: "mcp-headers-from-config"}, }, @@ -445,7 +445,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Resolve MCP envFrom", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, AgentConfigRef: &kelosv1alpha1.AgentConfigReference{Name: "mcp-env-from-config"}, }, @@ -534,7 +534,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fail on missing MCP secret", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, AgentConfigRef: &kelosv1alpha1.AgentConfigReference{Name: "mcp-missing-secret-config"}, }, @@ -629,7 +629,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Prefer MCP secret values", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, AgentConfigRef: &kelosv1alpha1.AgentConfigReference{Name: "mcp-precedence-config"}, }, @@ -711,7 +711,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -834,7 +834,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Create a PR", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -953,7 +953,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Review the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1030,7 +1030,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Create a hello world program", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1112,7 +1112,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Create a hello world program", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1183,7 +1183,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Create a hello world program", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1274,7 +1274,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1386,7 +1386,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1481,7 +1481,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "codex-api-key", }, }, @@ -1590,7 +1590,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Refactor the module", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "codex-api-key", }, }, @@ -1674,7 +1674,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Review the code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "codex-oauth-secret", }, }, @@ -1739,7 +1739,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "opencode-api-key", }, }, @@ -1835,7 +1835,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -1958,7 +1958,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -2048,7 +2048,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Test events", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -2164,7 +2164,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Test failure event", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -2248,7 +2248,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Do something", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2266,7 +2266,7 @@ var _ = Describe("Task Controller", func() { DependsOn: []string{"task-a"}, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2364,7 +2364,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Do something", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2382,7 +2382,7 @@ var _ = Describe("Task Controller", func() { DependsOn: []string{"dep-task-a"}, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2467,7 +2467,7 @@ var _ = Describe("Task Controller", func() { Branch: "feature-1", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2510,7 +2510,7 @@ var _ = Describe("Task Controller", func() { Branch: "feature-1", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2597,7 +2597,7 @@ var _ = Describe("Task Controller", func() { Branch: "feature-x", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "test-workspace", @@ -2714,7 +2714,7 @@ var _ = Describe("Task Controller", func() { Branch: "feature-1", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "workspace-a"}, }, @@ -2758,7 +2758,7 @@ var _ = Describe("Task Controller", func() { Branch: "feature-1", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "workspace-b"}, }, @@ -2808,7 +2808,7 @@ var _ = Describe("Task Controller", func() { DependsOn: []string{"cycle-task-b"}, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2826,7 +2826,7 @@ var _ = Describe("Task Controller", func() { DependsOn: []string{"cycle-task-a"}, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2878,7 +2878,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Generate outputs", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2929,7 +2929,7 @@ var _ = Describe("Task Controller", func() { DependsOn: []string{"tmpl-task-a"}, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -2980,7 +2980,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Generate results", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -3035,7 +3035,7 @@ var _ = Describe("Task Controller", func() { DependsOn: []string{"results-task-a"}, Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, }, } @@ -3103,7 +3103,7 @@ var _ = Describe("Task Controller", func() { Branch: "kelos-task-42", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "test-workspace"}, }, @@ -3125,7 +3125,7 @@ var _ = Describe("Task Controller", func() { Branch: "kelos-task-99", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "test-workspace"}, }, @@ -3252,7 +3252,7 @@ var _ = Describe("Task Controller", func() { Branch: "kelos-task-42", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "lock-workspace"}, }, @@ -3298,7 +3298,7 @@ var _ = Describe("Task Controller", func() { Branch: "kelos-task-42", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, + SecretRef: &kelosv1alpha1.SecretReference{Name: "anthropic-api-key"}, }, WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "lock-workspace"}, }, @@ -3372,7 +3372,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Original prompt", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, @@ -3454,7 +3454,7 @@ var _ = Describe("Task Controller", func() { Prompt: "Work on feature", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "anthropic-api-key", }, }, diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go index 7489aa85..84eed323 100644 --- a/test/integration/taskspawner_test.go +++ b/test/integration/taskspawner_test.go @@ -66,7 +66,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -199,7 +199,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -265,7 +265,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -335,7 +335,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -428,7 +428,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -485,7 +485,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -578,7 +578,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -658,7 +658,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -801,7 +801,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -924,7 +924,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1005,7 +1005,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1115,7 +1115,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1188,7 +1188,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1309,7 +1309,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1388,7 +1388,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1465,7 +1465,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1547,7 +1547,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1685,7 +1685,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1831,7 +1831,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1921,7 +1921,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -1996,7 +1996,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -2067,7 +2067,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -2144,7 +2144,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -2222,7 +2222,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -2279,7 +2279,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -2341,7 +2341,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, @@ -2418,7 +2418,7 @@ var _ = Describe("TaskSpawner Controller", func() { Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, - SecretRef: kelosv1alpha1.SecretReference{ + SecretRef: &kelosv1alpha1.SecretReference{ Name: "claude-credentials", }, }, From c2cc3ef7345922030881233439e41578629b901e Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 19:22:19 +0100 Subject: [PATCH 03/26] feat: swap from ghcr to ecr so we can cleanly run this in our own env --- .github/workflows/ci.yaml | 16 ++-- .github/workflows/deploy-dev.yaml | 63 --------------- .github/workflows/release.yaml | 31 ++++--- .github/workflows/run-fake-strategist.yaml | 80 ------------------- Makefile | 2 +- internal/cli/install_test.go | 12 +-- internal/controller/job_builder.go | 10 +-- .../taskspawner_deployment_builder.go | 4 +- internal/manifests/charts/kelos/values.yaml | 16 ++-- local-run.sh | 2 +- 10 files changed, 53 insertions(+), 183 deletions(-) delete mode 100644 .github/workflows/deploy-dev.yaml delete mode 100644 .github/workflows/run-fake-strategist.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c97293b6..dc654347 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -90,17 +90,17 @@ jobs: cluster_name: kind - name: Build images - run: make image VERSION=e2e + run: make image VERSION=e2e REGISTRY=public.ecr.aws/anomalo/kelos - name: Load images into kind run: | - kind load docker-image ghcr.io/kelos-dev/kelos-controller:e2e - kind load docker-image ghcr.io/kelos-dev/kelos-spawner:e2e - kind load docker-image ghcr.io/kelos-dev/claude-code:e2e - kind load docker-image ghcr.io/kelos-dev/codex:e2e - kind load docker-image ghcr.io/kelos-dev/gemini:e2e - kind load docker-image ghcr.io/kelos-dev/opencode:e2e - kind load docker-image ghcr.io/kelos-dev/cursor:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/kelos-controller:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/kelos-spawner:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/claude-code:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/codex:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/gemini:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/opencode:e2e + kind load docker-image public.ecr.aws/anomalo/kelos/cursor:e2e - name: Build CLI run: make build WHAT=cmd/kelos diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml deleted file mode 100644 index 863cee50..00000000 --- a/.github/workflows/deploy-dev.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: Deploy to Dev - -on: - workflow_run: - workflows: [Release] - types: [completed] - branches: [main] - workflow_dispatch: - -permissions: - contents: read - id-token: write - -concurrency: - group: deploy-dev-gke - cancel-in-progress: false - -jobs: - deploy: - if: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - env: - KELOS_NAMESPACE: ${{ vars.KELOS_NAMESPACE || 'default' }} - GCP_PROJECT_ID: gjkim-400213 - GKE_CLUSTER_NAME: gjkim - GKE_CLUSTER_LOCATION: asia-northeast3 - GCP_SERVICE_ACCOUNT_EMAIL: kelos-gh-action@gjkim-400213.iam.gserviceaccount.com - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/317215297044/locations/global/workloadIdentityPools/github/providers/kelos - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Build CLI - run: make build WHAT=cmd/kelos - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.GCP_SERVICE_ACCOUNT_EMAIL }} - - - name: Configure GKE credentials - uses: google-github-actions/get-gke-credentials@v2 - with: - cluster_name: ${{ env.GKE_CLUSTER_NAME }} - location: ${{ env.GKE_CLUSTER_LOCATION }} - project_id: ${{ env.GCP_PROJECT_ID }} - - - name: Install kelos - run: | - bin/kelos install --version main --image-pull-policy Always \ - --spawner-resource-requests cpu=100m,memory=128Mi \ - --token-refresher-resource-requests cpu=50m,memory=64Mi - kubectl rollout restart deployment/kelos-controller-manager -n kelos-system - kubectl rollout status deployment/kelos-controller-manager -n kelos-system --timeout=120s - kubectl rollout restart deployment -l app.kubernetes.io/component=spawner -n "${KELOS_NAMESPACE}" - kubectl rollout status deployment -l app.kubernetes.io/component=spawner -n "${KELOS_NAMESPACE}" --timeout=120s - - - name: Apply self-development resources - run: kubectl apply -f self-development/ -n "${KELOS_NAMESPACE}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ffb90d06..e965cc14 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,12 +9,17 @@ concurrency: group: release cancel-in-progress: false +env: + REGISTRY: public.ecr.aws + IMAGE_NAME: anomalo/kelos + jobs: release: runs-on: ubuntu-latest permissions: contents: write packages: write + id-token: write steps: - uses: actions/checkout@v4 with: @@ -24,12 +29,20 @@ jobs: with: go-version-file: go.mod - - name: Login to GHCR - uses: docker/login-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::580663733917:role/github-actions + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry-type: public - name: Determine version id: version @@ -43,18 +56,18 @@ jobs: - name: Build images env: VERSION: ${{ steps.version.outputs.version }} - run: make image VERSION="$VERSION" + run: make image VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Push images env: VERSION: ${{ steps.version.outputs.version }} - run: make push VERSION="$VERSION" + run: make push VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Push latest tags for releases if: startsWith(github.ref, 'refs/tags/v') run: | - make image VERSION=latest - make push VERSION=latest + make image VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + make push VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Build CLI binaries if: startsWith(github.ref, 'refs/tags/v') diff --git a/.github/workflows/run-fake-strategist.yaml b/.github/workflows/run-fake-strategist.yaml deleted file mode 100644 index 9391bc65..00000000 --- a/.github/workflows/run-fake-strategist.yaml +++ /dev/null @@ -1,80 +0,0 @@ -name: Run Fake Strategist - -on: - workflow_dispatch: - inputs: - namespace: - description: Kubernetes namespace for Kelos resources - required: false - default: "" - -permissions: - contents: read - id-token: write - -concurrency: - group: fake-strategist-gke - cancel-in-progress: false - -jobs: - run-fake-strategist: - runs-on: ubuntu-latest - env: - KELOS_NAMESPACE: ${{ inputs.namespace || vars.KELOS_NAMESPACE || 'default' }} - GCP_PROJECT_ID: gjkim-400213 - GKE_CLUSTER_NAME: gjkim - GKE_CLUSTER_LOCATION: asia-northeast3 - GCP_SERVICE_ACCOUNT_EMAIL: kelos-gh-action@gjkim-400213.iam.gserviceaccount.com - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/317215297044/locations/global/workloadIdentityPools/github/providers/kelos - steps: - - uses: actions/checkout@v4 - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.GCP_SERVICE_ACCOUNT_EMAIL }} - - - name: Configure GKE credentials - uses: google-github-actions/get-gke-credentials@v2 - with: - cluster_name: ${{ env.GKE_CLUSTER_NAME }} - location: ${{ env.GKE_CLUSTER_LOCATION }} - project_id: ${{ env.GCP_PROJECT_ID }} - - - name: Create strategist task if none active - id: create_task - run: | - set -euo pipefail - - existing_task="$( - kubectl get tasks.kelos.dev -n "${KELOS_NAMESPACE}" -l kelos.dev/type=fake-strategist-manual -o json \ - | jq -r '.items[] | select(.status.phase == "Pending" or .status.phase == "Running") | .metadata.name' \ - | head -n1 - )" - - if [[ -n "${existing_task}" ]]; then - echo "action=skipped" >> "$GITHUB_OUTPUT" - echo "task_name=${existing_task}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - task_name="$( - kubectl create -n "${KELOS_NAMESPACE}" -f self-development/tasks/fake-strategist-task.yaml -o jsonpath='{.metadata.name}' - )" - - echo "action=created" >> "$GITHUB_OUTPUT" - echo "task_name=${task_name}" >> "$GITHUB_OUTPUT" - - - name: Print task result - env: - TASK_ACTION: ${{ steps.create_task.outputs.action }} - TASK_NAME: ${{ steps.create_task.outputs.task_name }} - run: | - set -euo pipefail - if [[ "${TASK_ACTION}" == "skipped" ]]; then - echo "Fake strategist task already active: ${TASK_NAME} in namespace ${KELOS_NAMESPACE}" - else - echo "Created fake strategist task: ${TASK_NAME} in namespace ${KELOS_NAMESPACE}" - echo "Check status with: kubectl get task ${TASK_NAME} -n ${KELOS_NAMESPACE} -o yaml" - fi diff --git a/Makefile b/Makefile index 19a60ac1..e79564b7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Image configuration -REGISTRY ?= ghcr.io/kelos-dev +REGISTRY ?= public.ecr.aws/anomalo/kelos VERSION ?= latest IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher claude-code codex gemini opencode cursor diff --git a/internal/cli/install_test.go b/internal/cli/install_test.go index 56c0b851..17560d14 100644 --- a/internal/cli/install_test.go +++ b/internal/cli/install_test.go @@ -198,12 +198,12 @@ func TestRenderChart_ImageArgs(t *testing.T) { t.Fatalf("rendering chart: %v", err) } versionedArgs := []string{ - "--claude-code-image=ghcr.io/kelos-dev/claude-code:v0.3.0", - "--codex-image=ghcr.io/kelos-dev/codex:v0.3.0", - "--gemini-image=ghcr.io/kelos-dev/gemini:v0.3.0", - "--opencode-image=ghcr.io/kelos-dev/opencode:v0.3.0", - "--spawner-image=ghcr.io/kelos-dev/kelos-spawner:v0.3.0", - "--token-refresher-image=ghcr.io/kelos-dev/kelos-token-refresher:v0.3.0", + "--claude-code-image=public.ecr.aws/anomalo/kelos/claude-code:v0.3.0", + "--codex-image=public.ecr.aws/anomalo/kelos/codex:v0.3.0", + "--gemini-image=public.ecr.aws/anomalo/kelos/gemini:v0.3.0", + "--opencode-image=public.ecr.aws/anomalo/kelos/opencode:v0.3.0", + "--spawner-image=public.ecr.aws/anomalo/kelos/kelos-spawner:v0.3.0", + "--token-refresher-image=public.ecr.aws/anomalo/kelos/kelos-token-refresher:v0.3.0", } for _, arg := range versionedArgs { if !bytes.Contains(data, []byte(arg)) { diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 8e967328..13a78a4a 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -16,19 +16,19 @@ import ( const ( // ClaudeCodeImage is the default image for Claude Code agent. - ClaudeCodeImage = "ghcr.io/kelos-dev/claude-code:latest" + ClaudeCodeImage = "public.ecr.aws/anomalo/kelos/claude-code:latest" // CodexImage is the default image for OpenAI Codex agent. - CodexImage = "ghcr.io/kelos-dev/codex:latest" + CodexImage = "public.ecr.aws/anomalo/kelos/codex:latest" // GeminiImage is the default image for Google Gemini CLI agent. - GeminiImage = "ghcr.io/kelos-dev/gemini:latest" + GeminiImage = "public.ecr.aws/anomalo/kelos/gemini:latest" // OpenCodeImage is the default image for OpenCode agent. - OpenCodeImage = "ghcr.io/kelos-dev/opencode:latest" + OpenCodeImage = "public.ecr.aws/anomalo/kelos/opencode:latest" // CursorImage is the default image for Cursor CLI agent. - CursorImage = "ghcr.io/kelos-dev/cursor:latest" + CursorImage = "public.ecr.aws/anomalo/kelos/cursor:latest" // AgentTypeClaudeCode is the agent type for Claude Code. AgentTypeClaudeCode = "claude-code" diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go index 1580f4dd..58e14a67 100644 --- a/internal/controller/taskspawner_deployment_builder.go +++ b/internal/controller/taskspawner_deployment_builder.go @@ -17,10 +17,10 @@ import ( const ( // DefaultSpawnerImage is the default image for the spawner binary. - DefaultSpawnerImage = "ghcr.io/kelos-dev/kelos-spawner:latest" + DefaultSpawnerImage = "public.ecr.aws/anomalo/kelos/kelos-spawner:latest" // DefaultTokenRefresherImage is the default image for the token refresher sidecar. - DefaultTokenRefresherImage = "ghcr.io/kelos-dev/kelos-token-refresher:latest" + DefaultTokenRefresherImage = "public.ecr.aws/anomalo/kelos/kelos-token-refresher:latest" // SpawnerServiceAccount is the service account used by spawner Deployments. SpawnerServiceAccount = "kelos-spawner" diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index 70ef9044..bf521bf5 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -5,15 +5,15 @@ image: telemetry: enabled: true -controllerImage: ghcr.io/kelos-dev/kelos-controller -claudeCodeImage: ghcr.io/kelos-dev/claude-code -codexImage: ghcr.io/kelos-dev/codex -geminiImage: ghcr.io/kelos-dev/gemini -opencodeImage: ghcr.io/kelos-dev/opencode -cursorImage: ghcr.io/kelos-dev/cursor -spawnerImage: ghcr.io/kelos-dev/kelos-spawner +controllerImage: public.ecr.aws/anomalo/kelos/kelos-controller +claudeCodeImage: public.ecr.aws/anomalo/kelos/claude-code +codexImage: public.ecr.aws/anomalo/kelos/codex +geminiImage: public.ecr.aws/anomalo/kelos/gemini +opencodeImage: public.ecr.aws/anomalo/kelos/opencode +cursorImage: public.ecr.aws/anomalo/kelos/cursor +spawnerImage: public.ecr.aws/anomalo/kelos/kelos-spawner spawnerResourceRequests: "" spawnerResourceLimits: "" -tokenRefresherImage: ghcr.io/kelos-dev/kelos-token-refresher +tokenRefresherImage: public.ecr.aws/anomalo/kelos/kelos-token-refresher tokenRefresherResourceRequests: "" tokenRefresherResourceLimits: "" diff --git a/local-run.sh b/local-run.sh index 3584639b..1427e4f6 100755 --- a/local-run.sh +++ b/local-run.sh @@ -5,7 +5,7 @@ set -o nounset set -o pipefail KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" -REGISTRY="${REGISTRY:-ghcr.io/kelos-dev}" +REGISTRY="${REGISTRY:-public.ecr.aws/anomalo/kelos}" LOCAL_IMAGE_TAG="${LOCAL_IMAGE_TAG:-local-dev}" if ! command -v kind >/dev/null 2>&1; then echo "Kind CLI not found in PATH" >&2 From f0c21614a8db651f9247250a96b13ef950f52e5d Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 19:27:34 +0100 Subject: [PATCH 04/26] fix: run CI on prod --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc654347..87ae5144 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main,prod] pull_request: - branches: [main] + branches: [main,prod] types: [opened, synchronize, reopened, labeled] merge_group: workflow_dispatch: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e965cc14..fc47b049 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,7 +2,7 @@ name: Release on: push: - branches: [main] + branches: [main,prod] tags: ["v*"] concurrency: From 27821df1f491e593da850af3a52ccad7773fb484 Mon Sep 17 00:00:00 2001 From: tmarshall Date: Tue, 24 Mar 2026 14:25:26 -0500 Subject: [PATCH 05/26] Add devcontainer configuration for Codespaces --- .devcontainer/Dockerfile | 15 ++++++++++++ .devcontainer/devcontainer.json | 42 +++++++++++++++++++++++++++++++++ .devcontainer/post-create.sh | 13 ++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/post-create.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..9866f59d --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm + + USER root + + # kind (for local K8s clusters) + RUN curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 \ + && chmod +x /usr/local/bin/kind + + # Claude Code + RUN curl -fsSL https://claude.ai/install.sh | bash + + # Oh My Zsh + RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended + + USER vscode diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..da8c72cb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "Kelos Dev", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "kubectl": "latest", + "helm": "latest", + "minikube": "none" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "customizations": { + "vscode": { + "settings": { + "go.toolsManagement.autoUpdate": true, + "go.useLanguageServer": true, + "go.lintTool": "golangci-lint", + "terminal.integrated.defaultProfile.linux": "zsh" + }, + "extensions": [ + "anthropic.claude-code", + "golang.go", + "eamodio.gitlens", + "redhat.vscode-yaml", + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] + } + }, + "forwardPorts": [], + "postCreateCommand": "/bin/bash .devcontainer/post-create.sh", + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + "hostRequirements": { + "cpus": 4, + "memory": "8gb" + } + } diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000..0d6fb7ac --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,13 @@ +#!/bin/bash + set -euo pipefail + + echo "==> Installing Go tool dependencies..." + make -C /workspaces/kelos controller-gen envtest yamlfmt shfmt 2>/dev/null || true + + echo "==> Downloading Go modules..." + cd /workspaces/kelos && go mod download + + echo "==> Building kelos CLI..." + make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true + + echo "==> Done! Run 'make test' to verify your setup." From f184ac727829c10ffcbf53506f6e3fbadcc0702f Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Tue, 24 Mar 2026 14:30:05 -0500 Subject: [PATCH 06/26] cleanup --- .devcontainer/Dockerfile | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9866f59d..36e7507a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,15 +1,15 @@ FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm - - USER root - - # kind (for local K8s clusters) - RUN curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 \ - && chmod +x /usr/local/bin/kind - - # Claude Code - RUN curl -fsSL https://claude.ai/install.sh | bash - - # Oh My Zsh - RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended - - USER vscode + +USER root + +# kind (for local K8s clusters) +RUN curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 \ + && chmod +x /usr/local/bin/kind + +# Claude Code +RUN curl -fsSL https://claude.ai/install.sh | bash + +# Oh My Zsh +RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended + +USER vscode From 57eb1f2864304fbd2be3c15e356c83f83e8350ce Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Tue, 24 Mar 2026 14:30:34 -0500 Subject: [PATCH 07/26] cleanup --- .devcontainer/post-create.sh | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 0d6fb7ac..e4877e1e 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,13 +1,13 @@ #!/bin/bash - set -euo pipefail - - echo "==> Installing Go tool dependencies..." - make -C /workspaces/kelos controller-gen envtest yamlfmt shfmt 2>/dev/null || true - - echo "==> Downloading Go modules..." - cd /workspaces/kelos && go mod download - - echo "==> Building kelos CLI..." - make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true - - echo "==> Done! Run 'make test' to verify your setup." +set -euo pipefail + +echo "==> Installing Go tool dependencies..." +make -C /workspaces/kelos controller-gen envtest yamlfmt shfmt 2>/dev/null || true + +echo "==> Downloading Go modules..." +cd /workspaces/kelos && go mod download + +echo "==> Building kelos CLI..." +make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true + +echo "==> Done! Run 'make test' to verify your setup." From 1938bfd1e24769c4e7123a572c22d4c165d7a6a7 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Tue, 24 Mar 2026 14:33:25 -0500 Subject: [PATCH 08/26] Update Dockerfile to include Claude Code and helper Added installation for Claude Code and a helper function for AWS Bedrock in the Dockerfile. --- .devcontainer/Dockerfile | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 36e7507a..96740e73 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,15 +1,33 @@ -FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm +FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm -USER root +USER root -# kind (for local K8s clusters) +# kind (for local K8s clusters) RUN curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 \ - && chmod +x /usr/local/bin/kind - -# Claude Code -RUN curl -fsSL https://claude.ai/install.sh | bash + && chmod +x /usr/local/bin/kind -# Oh My Zsh -RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended +# Claude Code +RUN curl -fsSL https://claude.ai/install.sh | bash + +# claude-bedrock helper (runs Claude Code via AWS Bedrock) +RUN <<'EOF' +cat >> /etc/zshrc << 'SHELL_FUNC' + +# Claude / Bedrock helper function +claude-bedrock() { + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" +} +SHELL_FUNC +cat >> /etc/bash.bashrc << 'SHELL_FUNC' + +# Claude / Bedrock helper function +claude-bedrock() { + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" +} +SHELL_FUNC +EOF + +# Oh My Zsh +RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended USER vscode From ab2758682cd7a07b8252b81ee10f8f56128530e8 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 20:51:25 +0100 Subject: [PATCH 09/26] chore: run verify and update prpoerly and format --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 87ae5144..c2d9bb4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main,prod] + branches: [main, prod] pull_request: - branches: [main,prod] + branches: [main, prod] types: [opened, synchronize, reopened, labeled] merge_group: workflow_dispatch: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fc47b049..21580b60 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,7 +2,7 @@ name: Release on: push: - branches: [main,prod] + branches: [main, prod] tags: ["v*"] concurrency: From ebfd9a29385dc3dc9dba6ff22765a83f8e2e5e19 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 21:10:15 +0100 Subject: [PATCH 10/26] fix: push prod rather than main on each branch --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 21580b60..4fa1e240 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -50,7 +50,7 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" else - echo "version=main" >> "$GITHUB_OUTPUT" + echo "version=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" fi - name: Build images From 3d1c376161370e517679b763ae427a1608c5202d Mon Sep 17 00:00:00 2001 From: tmarshall Date: Tue, 24 Mar 2026 15:57:40 -0500 Subject: [PATCH 11/26] changes --- .devcontainer/Dockerfile | 80 +++++++++++++++++++++++++++++---- .devcontainer/devcontainer.json | 80 +++++++++++++++++---------------- .devcontainer/post-create.sh | 23 ++++++---- 3 files changed, 128 insertions(+), 55 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 96740e73..45f00cbc 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,29 +2,93 @@ FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm USER root +SHELL ["/bin/bash", "-euo", "pipefail", "-c"] + # kind (for local K8s clusters) RUN curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 \ - && chmod +x /usr/local/bin/kind + && chmod +x /usr/local/bin/kind # Claude Code RUN curl -fsSL https://claude.ai/install.sh | bash -# claude-bedrock helper (runs Claude Code via AWS Bedrock) +# asdf (for teleport-ent) +# renovate: datasource=github-releases depName=asdf-vm/asdf +ENV V_ASDF="0.18.1" +RUN <>/etc/zsh/zshrc + echo "export PATH=\"\$ASDF_DATA_DIR/shims:\$PATH\"" >>/etc/zsh/zshrc + echo "export ASDF_DATA_DIR='/root/.asdf'" >>/etc/bash.bashrc + echo "export PATH=\"\$ASDF_DATA_DIR/shims:\$PATH\"" >>/etc/bash.bashrc + asdf plugin add teleport-ent + asdf install teleport-ent 18.2.2 + asdf set --home teleport-ent 18.2.2 +EOF + +# AWS IAM Roles Anywhere +RUN < /root/.aws/config +[default] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere/svid.pem --private-key /opt/roles-anywhere/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/1c5babda-a4a9-4c75-9a16-88e143ab0233 --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportDeveloperAccess +output = json +[profile teleport-admin] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-admin/svid.pem --private-key /opt/roles-anywhere-admin/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/32163bf3-dd34-4b50-8ff2-bea86d8daf3b --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportAdministratorAccess +output = json +[profile sandbox] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-sandbox/svid.pem --private-key /opt/roles-anywhere-sandbox/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-east-1:774922483191:profile/bf98f431-d465-48d8-84b1-6d3090bb17aa --trust-anchor-arn +arn:aws:rolesanywhere:us-east-1:774922483191:trust-anchor/9f687ddc-5ae6-4459-bd1f-ed9c7d296f25 --role-arn arn:aws:iam::774922483191:role/TeleportRolesAnywhere +region = us-west-1 +output = json +[profile bd] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-bd/svid.pem --private-key /opt/roles-anywhere-bd/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/e61115ef-b5d7-415f-b249-6fd1f3814aa1 --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportBusinessDevelopmentAccess +output = json +[profile cs] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-cs/svid.pem --private-key /opt/roles-anywhere-cs/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-west-1:580663733917:profile/8bbcfc3e-f5e2-4d17-a7c4-fb10f648b10e --trust-anchor-arn +arn:aws:rolesanywhere:us-west-1:580663733917:trust-anchor/b15762fd-2a5f-4bcd-a09f-17f63f4f2f98 --role-arn arn:aws:iam::580663733917:role/TeleportCustomerSolutionsAccess +output = json +[profile gtm] +credential_process = /usr/local/bin/aws_signing_helper credential-process --certificate /opt/roles-anywhere-gtm/svid.pem --private-key /opt/roles-anywhere-gtm/svid_key.pem --profile-arn arn:aws:rolesanywhere:us-east-1:649126925216:profile/de329f63-770f-462c-9152-ea822877c9f3 --trust-anchor-arn +arn:aws:rolesanywhere:us-east-1:649126925216:trust-anchor/a784c1f4-8247-4b9d-b565-d430fec69f15 --role-arn arn:aws:iam::649126925216:role/TeleportAdminAccess +region = us-west-1 +output = json +EOF +EOB + +# claude-bedrock helper and gimme-creds aliases RUN <<'EOF' -cat >> /etc/zshrc << 'SHELL_FUNC' +cat >> /etc/zshrc << 'SHELL_RC' # Claude / Bedrock helper function claude-bedrock() { - CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" } -SHELL_FUNC -cat >> /etc/bash.bashrc << 'SHELL_FUNC' + +# Teleport credential helpers +alias gimme-creds="tsh svid issue --output /opt/roles-anywhere --svid-ttl 12h /svc/codespaces" +alias gimme-admin-creds="tsh svid issue --output /opt/roles-anywhere-admin --svid-ttl 12h /role/administrator" +alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox --svid-ttl 12h /cloud/aws-sandbox" +SHELL_RC +cat >> /etc/bash.bashrc << 'SHELL_RC' # Claude / Bedrock helper function claude-bedrock() { - CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" } -SHELL_FUNC + +# Teleport credential helpers +alias gimme-creds="tsh svid issue --output /opt/roles-anywhere --svid-ttl 12h /svc/codespaces" +alias gimme-admin-creds="tsh svid issue --output /opt/roles-anywhere-admin --svid-ttl 12h /role/administrator" +alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox --svid-ttl 12h /cloud/aws-sandbox" +SHELL_RC EOF # Oh My Zsh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index da8c72cb..a73d1bfd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,42 +1,44 @@ { - "name": "Kelos Dev", - "build": { + "name": "Kelos Dev", + "build": { "dockerfile": "Dockerfile" - }, - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { - "kubectl": "latest", - "helm": "latest", - "minikube": "none" - }, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/sshd:1": {} - }, - "customizations": { - "vscode": { - "settings": { - "go.toolsManagement.autoUpdate": true, - "go.useLanguageServer": true, - "go.lintTool": "golangci-lint", - "terminal.integrated.defaultProfile.linux": "zsh" - }, - "extensions": [ - "anthropic.claude-code", - "golang.go", - "eamodio.gitlens", - "redhat.vscode-yaml", - "ms-kubernetes-tools.vscode-kubernetes-tools" - ] - } - }, - "forwardPorts": [], - "postCreateCommand": "/bin/bash .devcontainer/post-create.sh", - "remoteEnv": { - "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" - }, - "hostRequirements": { - "cpus": 4, - "memory": "8gb" - } + }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "kubectl": "latest", + "helm": "latest", + "minikube": "none" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/tailscale/codespace/tailscale:1": {} + }, + "customizations": { + "vscode": { + "settings": { + "go.toolsManagement.autoUpdate": true, + "go.useLanguageServer": true, + "go.lintTool": "golangci-lint", + "terminal.integrated.defaultProfile.linux": "zsh" + }, + "extensions": [ + "anthropic.claude-code", + "golang.go", + "eamodio.gitlens", + "redhat.vscode-yaml", + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] + } + }, + "forwardPorts": [], + "postCreateCommand": "/bin/bash .devcontainer/post-create.sh", + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + "hostRequirements": { + "cpus": 4, + "memory": "8gb" + } } + \ No newline at end of file diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index e4877e1e..33614400 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,13 +1,20 @@ #!/bin/bash -set -euo pipefail +set -euo pipefail -echo "==> Installing Go tool dependencies..." -make -C /workspaces/kelos controller-gen envtest yamlfmt shfmt 2>/dev/null || true +echo "==> Installing Go tool dependencies..." +make -C /workspaces/kelos controller-gen envtest yamlfmt shfmt 2>/dev/null || true -echo "==> Downloading Go modules..." -cd /workspaces/kelos && go mod download +echo "==> Downloading Go modules..." +cd /workspaces/kelos && go mod download -echo "==> Building kelos CLI..." -make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true +echo "==> Building kelos CLI..." +make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true -echo "==> Done! Run 'make test' to verify your setup." +cat << 'MSG' + +==> Done! To get started: +1. tailscale up --accept-routes +2. tsh login --proxy=anomalo.teleport.sh:443 --auth=google +3. gimme-creds +4. claude-bedrock +MSG From 0c5e8f4c887d212466d94247a6a81e86968b947b Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 22:09:00 +0100 Subject: [PATCH 12/26] feat(ci): support multi-arch images by building in container --- .github/workflows/release.yaml | 13 +++---------- Makefile | 14 ++++++++++---- claude-code/Dockerfile | 9 ++++++++- cmd/kelos-controller/Dockerfile | 9 ++++++++- cmd/kelos-spawner/Dockerfile | 9 ++++++++- cmd/kelos-token-refresher/Dockerfile | 15 +-------------- codex/Dockerfile | 9 ++++++++- cursor/Dockerfile | 9 ++++++++- gemini/Dockerfile | 9 ++++++++- opencode/Dockerfile | 9 ++++++++- 10 files changed, 70 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4fa1e240..b6cffeec 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -53,21 +53,14 @@ jobs: echo "version=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" fi - - name: Build images + - name: Build and push multi-arch images env: VERSION: ${{ steps.version.outputs.version }} - run: make image VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - - - name: Push images - env: - VERSION: ${{ steps.version.outputs.version }} - run: make push VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + run: make push-multiarch VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Push latest tags for releases if: startsWith(github.ref, 'refs/tags/v') - run: | - make image VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - make push VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + run: make push-multiarch VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Build CLI binaries if: startsWith(github.ref, 'refs/tags/v') diff --git a/Makefile b/Makefile index e79564b7..4292366c 100644 --- a/Makefile +++ b/Makefile @@ -81,10 +81,6 @@ run: ## Run a controller from your host. .PHONY: image image: ## Build docker images (use WHAT to build specific image). - @for dir in $(filter cmd/%,$(or $(WHAT),$(IMAGE_DIRS))); do \ - GOOS=linux GOARCH=amd64 $(MAKE) build WHAT=$$dir; \ - done - @GOOS=linux GOARCH=amd64 $(MAKE) build WHAT=cmd/kelos-capture @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ docker build -t $(REGISTRY)/$$(basename $$dir):$(VERSION) -f $$dir/Dockerfile .; \ done @@ -95,6 +91,16 @@ push: ## Push docker images (use WHAT to push specific image). docker push $(REGISTRY)/$$(basename $$dir):$(VERSION); \ done +DOCKER_PLATFORMS ?= linux/amd64,linux/arm64 + +.PHONY: push-multiarch +push-multiarch: ## Build and push multi-arch docker images. + @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ + docker buildx build --platform $(DOCKER_PLATFORMS) \ + -t $(REGISTRY)/$$(basename $$dir):$(VERSION) \ + -f $$dir/Dockerfile --push .; \ + done + RELEASE_PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 .PHONY: release-binaries diff --git a/claude-code/Dockerfile b/claude-code/Dockerfile index 6e3352ef..b27e2e03 100644 --- a/claude-code/Dockerfile +++ b/claude-code/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} COPY claude-code/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash claude RUN mkdir -p /home/claude/.claude && chown -R claude:claude /home/claude diff --git a/cmd/kelos-controller/Dockerfile b/cmd/kelos-controller/Dockerfile index bbe8cb11..064ef76c 100644 --- a/cmd/kelos-controller/Dockerfile +++ b/cmd/kelos-controller/Dockerfile @@ -1,5 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-controller ./cmd/kelos-controller + FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY bin/kelos-controller . +COPY --from=builder /workspace/bin/kelos-controller . USER 65532:65532 ENTRYPOINT ["/kelos-controller"] diff --git a/cmd/kelos-spawner/Dockerfile b/cmd/kelos-spawner/Dockerfile index a1851bc0..1797ea87 100644 --- a/cmd/kelos-spawner/Dockerfile +++ b/cmd/kelos-spawner/Dockerfile @@ -1,5 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-spawner ./cmd/kelos-spawner + FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY bin/kelos-spawner . +COPY --from=builder /workspace/bin/kelos-spawner . USER 65532:65532 ENTRYPOINT ["/kelos-spawner"] diff --git a/cmd/kelos-token-refresher/Dockerfile b/cmd/kelos-token-refresher/Dockerfile index f012daec..d083bc82 100644 --- a/cmd/kelos-token-refresher/Dockerfile +++ b/cmd/kelos-token-refresher/Dockerfile @@ -1,25 +1,12 @@ -# Build stage FROM golang:1.25 AS builder - WORKDIR /workspace - -# Copy go mod files COPY go.mod go.sum ./ RUN go mod download - -# Copy source COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-token-refresher ./cmd/kelos-token-refresher -# Build -RUN make build WHAT=cmd/kelos-token-refresher - -# Runtime stage FROM gcr.io/distroless/static:nonroot - WORKDIR / - COPY --from=builder /workspace/bin/kelos-token-refresher . - USER 65532:65532 - ENTRYPOINT ["/kelos-token-refresher"] diff --git a/codex/Dockerfile b/codex/Dockerfile index 397dd250..9a7ed9ca 100644 --- a/codex/Dockerfile +++ b/codex/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @openai/codex@${CODEX_VERSION} COPY codex/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.codex && chown -R agent:agent /home/agent diff --git a/cursor/Dockerfile b/cursor/Dockerfile index 4bd0f8ae..24b4f01e 100644 --- a/cursor/Dockerfile +++ b/cursor/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -30,7 +37,7 @@ ENV PATH="/usr/local/go/bin:${PATH}" COPY cursor/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent diff --git a/gemini/Dockerfile b/gemini/Dockerfile index cb1a6b5d..c10f34bc 100644 --- a/gemini/Dockerfile +++ b/gemini/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} COPY gemini/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.gemini && chown -R agent:agent /home/agent diff --git a/opencode/Dockerfile b/opencode/Dockerfile index f1cb13fb..f877d97a 100644 --- a/opencode/Dockerfile +++ b/opencode/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g opencode-ai@${OPENCODE_VERSION} COPY opencode/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.opencode && chown -R agent:agent /home/agent From 93a11cddf6906e809ad6d006e55a2939fd5c4891 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Tue, 24 Mar 2026 22:14:04 +0100 Subject: [PATCH 13/26] feat(ci): allow for supporting multi-arch images to be built and shipped --- .github/workflows/release.yaml | 16 ++++++---------- Makefile | 14 ++++++++++---- claude-code/Dockerfile | 9 ++++++++- cmd/kelos-controller/Dockerfile | 9 ++++++++- cmd/kelos-spawner/Dockerfile | 9 ++++++++- codex/Dockerfile | 9 ++++++++- cursor/Dockerfile | 9 ++++++++- gemini/Dockerfile | 9 ++++++++- opencode/Dockerfile | 9 ++++++++- 9 files changed, 72 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ffb90d06..ba024ef5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,6 +24,9 @@ jobs: with: go-version-file: go.mod + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GHCR uses: docker/login-action@v3 with: @@ -40,21 +43,14 @@ jobs: echo "version=main" >> "$GITHUB_OUTPUT" fi - - name: Build images + - name: Build and push multi-arch images env: VERSION: ${{ steps.version.outputs.version }} - run: make image VERSION="$VERSION" - - - name: Push images - env: - VERSION: ${{ steps.version.outputs.version }} - run: make push VERSION="$VERSION" + run: make push-multiarch VERSION="$VERSION" - name: Push latest tags for releases if: startsWith(github.ref, 'refs/tags/v') - run: | - make image VERSION=latest - make push VERSION=latest + run: make push-multiarch VERSION=latest - name: Build CLI binaries if: startsWith(github.ref, 'refs/tags/v') diff --git a/Makefile b/Makefile index 19a60ac1..73cc9b0c 100644 --- a/Makefile +++ b/Makefile @@ -81,10 +81,6 @@ run: ## Run a controller from your host. .PHONY: image image: ## Build docker images (use WHAT to build specific image). - @for dir in $(filter cmd/%,$(or $(WHAT),$(IMAGE_DIRS))); do \ - GOOS=linux GOARCH=amd64 $(MAKE) build WHAT=$$dir; \ - done - @GOOS=linux GOARCH=amd64 $(MAKE) build WHAT=cmd/kelos-capture @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ docker build -t $(REGISTRY)/$$(basename $$dir):$(VERSION) -f $$dir/Dockerfile .; \ done @@ -95,6 +91,16 @@ push: ## Push docker images (use WHAT to push specific image). docker push $(REGISTRY)/$$(basename $$dir):$(VERSION); \ done +DOCKER_PLATFORMS ?= linux/amd64,linux/arm64 + +.PHONY: push-multiarch +push-multiarch: ## Build and push multi-arch docker images. + @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ + docker buildx build --platform $(DOCKER_PLATFORMS) \ + -t $(REGISTRY)/$$(basename $$dir):$(VERSION) \ + -f $$dir/Dockerfile --push .; \ + done + RELEASE_PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 .PHONY: release-binaries diff --git a/claude-code/Dockerfile b/claude-code/Dockerfile index 6e3352ef..b27e2e03 100644 --- a/claude-code/Dockerfile +++ b/claude-code/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} COPY claude-code/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash claude RUN mkdir -p /home/claude/.claude && chown -R claude:claude /home/claude diff --git a/cmd/kelos-controller/Dockerfile b/cmd/kelos-controller/Dockerfile index bbe8cb11..064ef76c 100644 --- a/cmd/kelos-controller/Dockerfile +++ b/cmd/kelos-controller/Dockerfile @@ -1,5 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-controller ./cmd/kelos-controller + FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY bin/kelos-controller . +COPY --from=builder /workspace/bin/kelos-controller . USER 65532:65532 ENTRYPOINT ["/kelos-controller"] diff --git a/cmd/kelos-spawner/Dockerfile b/cmd/kelos-spawner/Dockerfile index a1851bc0..1797ea87 100644 --- a/cmd/kelos-spawner/Dockerfile +++ b/cmd/kelos-spawner/Dockerfile @@ -1,5 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-spawner ./cmd/kelos-spawner + FROM gcr.io/distroless/static:nonroot WORKDIR / -COPY bin/kelos-spawner . +COPY --from=builder /workspace/bin/kelos-spawner . USER 65532:65532 ENTRYPOINT ["/kelos-spawner"] diff --git a/codex/Dockerfile b/codex/Dockerfile index 397dd250..9a7ed9ca 100644 --- a/codex/Dockerfile +++ b/codex/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @openai/codex@${CODEX_VERSION} COPY codex/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.codex && chown -R agent:agent /home/agent diff --git a/cursor/Dockerfile b/cursor/Dockerfile index 4bd0f8ae..24b4f01e 100644 --- a/cursor/Dockerfile +++ b/cursor/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -30,7 +37,7 @@ ENV PATH="/usr/local/go/bin:${PATH}" COPY cursor/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent diff --git a/gemini/Dockerfile b/gemini/Dockerfile index cb1a6b5d..c10f34bc 100644 --- a/gemini/Dockerfile +++ b/gemini/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} COPY gemini/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.gemini && chown -R agent:agent /home/agent diff --git a/opencode/Dockerfile b/opencode/Dockerfile index f1cb13fb..f877d97a 100644 --- a/opencode/Dockerfile +++ b/opencode/Dockerfile @@ -1,3 +1,10 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-capture ./cmd/kelos-capture + FROM ubuntu:24.04 ARG GO_VERSION=1.25.0 @@ -33,7 +40,7 @@ RUN npm install -g opencode-ai@${OPENCODE_VERSION} COPY opencode/kelos_entrypoint.sh /kelos_entrypoint.sh RUN chmod +x /kelos_entrypoint.sh -COPY bin/kelos-capture /kelos/kelos-capture +COPY --from=builder /workspace/bin/kelos-capture /kelos/kelos-capture RUN useradd -u 61100 -m -s /bin/bash agent RUN mkdir -p /home/agent/.opencode && chown -R agent:agent /home/agent From 6279fb4194fa0dcc9d993c8e39c8f1770878ac6b Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Wed, 25 Mar 2026 07:33:23 +0100 Subject: [PATCH 14/26] feat: swap to none type credential to make it more generic from the outset --- api/v1alpha1/task_types.go | 29 ++--- api/v1alpha1/taskspawner_types.go | 2 +- examples/09-bedrock-credentials/README.md | 52 ++------ .../09-bedrock-credentials/task-irsa.yaml | 9 +- examples/09-bedrock-credentials/task.yaml | 23 +++- internal/cli/config.go | 13 -- internal/cli/run.go | 101 +--------------- internal/controller/job_builder.go | 37 ++---- internal/controller/job_builder_test.go | 114 ++++++------------ internal/manifests/install-crd.yaml | 48 +++----- 10 files changed, 114 insertions(+), 314 deletions(-) diff --git a/api/v1alpha1/task_types.go b/api/v1alpha1/task_types.go index 90435c7c..33c6bd90 100644 --- a/api/v1alpha1/task_types.go +++ b/api/v1alpha1/task_types.go @@ -13,8 +13,9 @@ const ( CredentialTypeAPIKey CredentialType = "api-key" // CredentialTypeOAuth uses OAuth for authentication. CredentialTypeOAuth CredentialType = "oauth" - // CredentialTypeBedrock uses AWS credentials for Bedrock authentication. - CredentialTypeBedrock CredentialType = "bedrock" + // CredentialTypeNone disables built-in credential injection. + // Users supply their own credentials via PodOverrides.Env. + CredentialTypeNone CredentialType = "none" ) // TaskPhase represents the current phase of a Task. @@ -42,25 +43,13 @@ type SecretReference struct { // Credentials defines how to authenticate with the AI agent. type Credentials struct { // Type specifies the credential type. - // +kubebuilder:validation:Enum=api-key;oauth;bedrock + // +kubebuilder:validation:Enum=api-key;oauth;none Type CredentialType `json:"type"` // SecretRef references the Secret containing credentials. - // Required for api-key and oauth types. Optional for bedrock - // when using IAM Roles for Service Accounts (IRSA). + // Required for api-key and oauth types. Not used with none. // +optional SecretRef *SecretReference `json:"secretRef,omitempty"` - - // Region specifies the cloud provider region (e.g. AWS region for Bedrock). - // Used with bedrock credentials when secretRef is omitted (IRSA mode). - // +optional - Region string `json:"region,omitempty"` - - // ServiceAccountName overrides the pod's service account. - // Use with IAM Roles for Service Accounts (IRSA) on EKS to let - // the pod assume an IAM role without static credentials. - // +optional - ServiceAccountName string `json:"serviceAccountName,omitempty"` } // PodOverrides defines optional overrides for the agent pod. @@ -85,6 +74,12 @@ type PodOverrides struct { // NodeSelector constrains agent pods to nodes matching the given labels. // +optional NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // ServiceAccountName sets the pod's service account. + // Use with workload identity systems such as IRSA on EKS, GKE + // Workload Identity, or Azure Workload Identity. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` } // TaskSpec defines the desired state of Task. @@ -100,7 +95,7 @@ type TaskSpec struct { // Credentials specifies how to authenticate with the agent. // +kubebuilder:validation:Required - // +kubebuilder:validation:XValidation:rule="self.type == 'bedrock' || has(self.secretRef)",message="secretRef is required for api-key and oauth credential types" + // +kubebuilder:validation:XValidation:rule="self.type == 'none' || has(self.secretRef)",message="secretRef is required for api-key and oauth credential types" Credentials Credentials `json:"credentials"` // Model optionally overrides the default model. diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index 479f0cb8..f4dd0e7d 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -304,7 +304,7 @@ type TaskTemplate struct { // Credentials specifies how to authenticate with the agent. // +kubebuilder:validation:Required - // +kubebuilder:validation:XValidation:rule="self.type == 'bedrock' || has(self.secretRef)",message="secretRef is required for api-key and oauth credential types" + // +kubebuilder:validation:XValidation:rule="self.type == 'none' || has(self.secretRef)",message="secretRef is required for api-key and oauth credential types" Credentials Credentials `json:"credentials"` // Model optionally overrides the default model. diff --git a/examples/09-bedrock-credentials/README.md b/examples/09-bedrock-credentials/README.md index cce3e76f..e51f0402 100644 --- a/examples/09-bedrock-credentials/README.md +++ b/examples/09-bedrock-credentials/README.md @@ -1,11 +1,11 @@ # Bedrock Credentials -This example demonstrates running a Claude Code task using AWS Bedrock instead of the Anthropic API directly. +This example demonstrates running a Claude Code task using AWS Bedrock instead of the Anthropic API directly. It uses the `none` credential type with `podOverrides` to inject provider-specific environment variables. ## Prerequisites - AWS account with Bedrock access enabled for Claude models -- AWS IAM credentials with `bedrock:InvokeModel` permissions +- AWS IAM credentials with `bedrock:InvokeModel`, `bedrock:InvokeModelWithResponseStream`, and `bedrock:ListInferenceProfiles` permissions ## Option 1: Static Credentials (Secret) @@ -24,38 +24,13 @@ This example demonstrates running a Claude Code task using AWS Bedrock instead o kubectl apply -f task.yaml ``` -### Optional Secret Keys - -- `AWS_SESSION_TOKEN`: Required when using temporary credentials (e.g. from STS AssumeRole) -- `ANTHROPIC_BEDROCK_BASE_URL`: Custom Bedrock endpoint URL - -### CLI with Static Credentials - -```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 -``` - ## Option 2: IAM Roles for Service Accounts (IRSA) On EKS, you can use IRSA instead of static credentials. The AWS SDK automatically picks up credentials from the projected service account token — no Secret needed. ### Prerequisites -1. Create an IAM role with `bedrock:InvokeModel` permissions +1. Create an IAM role with Bedrock permissions 2. Create a Kubernetes ServiceAccount annotated with the IAM role: ```bash @@ -64,27 +39,14 @@ On EKS, you can use IRSA instead of static credentials. The AWS SDK automaticall eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/bedrock-agent-role ``` -3. Create the Task with `region` and `serviceAccountName` (no `secretRef`): +3. Create the Task: ```bash kubectl apply -f task-irsa.yaml ``` -### CLI with IRSA - -```yaml -# ~/.kelos/config.yaml -bedrock: - region: us-east-1 - serviceAccountName: bedrock-agent-sa -``` - -```bash -kelos run -p "Fix the bug" -``` +## How it works -Or with flags: +The `none` credential type tells Kelos not to inject any built-in credentials. Instead, you supply provider-specific env vars via `podOverrides.env` and optionally set `podOverrides.serviceAccountName` for workload identity. -```bash -kelos run -p "Fix the bug" --credential-type bedrock --region us-east-1 --service-account bedrock-agent-sa -``` +This pattern works for any provider (Bedrock, Vertex AI, Azure OpenAI, etc.) — just change the environment variables. diff --git a/examples/09-bedrock-credentials/task-irsa.yaml b/examples/09-bedrock-credentials/task-irsa.yaml index 96a21ee1..868a6293 100644 --- a/examples/09-bedrock-credentials/task-irsa.yaml +++ b/examples/09-bedrock-credentials/task-irsa.yaml @@ -6,6 +6,11 @@ spec: type: claude-code prompt: "Write a Python script that prints the first 20 Fibonacci numbers." credentials: - type: bedrock - region: us-east-1 + type: none + podOverrides: serviceAccountName: bedrock-agent-sa + env: + - name: CLAUDE_CODE_USE_BEDROCK + value: "1" + - name: AWS_REGION + value: us-east-1 diff --git a/examples/09-bedrock-credentials/task.yaml b/examples/09-bedrock-credentials/task.yaml index 6d16aa93..06fbc8f5 100644 --- a/examples/09-bedrock-credentials/task.yaml +++ b/examples/09-bedrock-credentials/task.yaml @@ -6,6 +6,23 @@ spec: type: claude-code prompt: "Write a Python script that prints the first 20 Fibonacci numbers." credentials: - type: bedrock - secretRef: - name: bedrock-credentials + type: none + podOverrides: + env: + - name: CLAUDE_CODE_USE_BEDROCK + value: "1" + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: bedrock-credentials + key: AWS_ACCESS_KEY_ID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: bedrock-credentials + key: AWS_SECRET_ACCESS_KEY + - name: AWS_REGION + valueFrom: + secretKeyRef: + name: bedrock-credentials + key: AWS_REGION diff --git a/internal/cli/config.go b/internal/cli/config.go index 9e43fe88..60ca0f62 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -19,19 +19,6 @@ 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. -// For IRSA mode, omit accessKeyID and secretAccessKey and set only region -// and serviceAccountName. -type BedrockConfig struct { - AccessKeyID string `json:"accessKeyID,omitempty"` - SecretAccessKey string `json:"secretAccessKey,omitempty"` - Region string `json:"region"` - SessionToken string `json:"sessionToken,omitempty"` - BaseURL string `json:"baseURL,omitempty"` - ServiceAccountName string `json:"serviceAccountName,omitempty"` } // WorkspaceConfig holds workspace-related configuration. diff --git a/internal/cli/run.go b/internal/cli/run.go index b52c25c0..86983431 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -50,8 +50,6 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { agentConfigRef string dependsOn []string branch string - region string - serviceAccount string ) cmd := &cobra.Command{ @@ -88,11 +86,8 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { 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") + return fmt.Errorf("config file must specify only one of oauthToken or apiKey") } if token := cfg.Config.OAuthToken; token != "" { resolved, err := resolveContent(token) @@ -120,33 +115,10 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { } secret = "kelos-credentials" credentialType = "api-key" - } else if br := cfg.Config.Bedrock; br != nil { - hasStaticCreds := br.AccessKeyID != "" || br.SecretAccessKey != "" - if hasStaticCreds { - if br.AccessKeyID == "" || br.SecretAccessKey == "" || br.Region == "" { - return fmt.Errorf("bedrock config requires accessKeyID, secretAccessKey, and region when using static credentials") - } - if !dryRun { - if err := ensureBedrockSecret(cfg, "kelos-credentials", br, yes); err != nil { - return err - } - } - secret = "kelos-credentials" - } else { - // IRSA mode — no secret needed, region is set on credentials directly. - if br.Region == "" { - return fmt.Errorf("bedrock config requires region") - } - region = br.Region - if br.ServiceAccountName != "" && serviceAccount == "" { - serviceAccount = br.ServiceAccountName - } - } - credentialType = "bedrock" } } - if secret == "" && credentialType != "bedrock" { + if secret == "" && credentialType != "none" { return fmt.Errorf("no credentials configured (set oauthToken/apiKey in config file, or use --secret flag)") } @@ -229,15 +201,11 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { } creds := kelosv1alpha1.Credentials{ - Type: kelosv1alpha1.CredentialType(credentialType), - Region: region, + Type: kelosv1alpha1.CredentialType(credentialType), } if secret != "" { creds.SecretRef = &kelosv1alpha1.SecretReference{Name: secret} } - if serviceAccount != "" { - creds.ServiceAccountName = serviceAccount - } task := &kelosv1alpha1.Task{ ObjectMeta: metav1.ObjectMeta{ @@ -329,9 +297,7 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { cmd.Flags().StringVarP(&prompt, "prompt", "p", "", "task prompt (required)") cmd.Flags().StringVarP(&agentType, "type", "t", "claude-code", "agent type (claude-code, codex, gemini, opencode, cursor)") cmd.Flags().StringVar(&secret, "secret", "", "secret name with credentials (overrides oauthToken/apiKey in config)") - cmd.Flags().StringVar(&credentialType, "credential-type", "api-key", "credential type (api-key, oauth, bedrock)") - cmd.Flags().StringVar(®ion, "region", "", "cloud provider region (e.g. us-east-1 for Bedrock IRSA)") - cmd.Flags().StringVar(&serviceAccount, "service-account", "", "pod service account name (e.g. for IRSA on EKS)") + cmd.Flags().StringVar(&credentialType, "credential-type", "api-key", "credential type (api-key, oauth, none)") cmd.Flags().StringVar(&model, "model", "", "model override") cmd.Flags().StringVar(&image, "image", "", "custom agent image (must implement agent image interface)") cmd.Flags().StringVar(&name, "name", "", "task name (auto-generated if omitted)") @@ -347,7 +313,7 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { cmd.MarkFlagRequired("prompt") - _ = cmd.RegisterFlagCompletionFunc("credential-type", cobra.FixedCompletions([]string{"api-key", "oauth", "bedrock"}, cobra.ShellCompDirectiveNoFileComp)) + _ = cmd.RegisterFlagCompletionFunc("credential-type", cobra.FixedCompletions([]string{"api-key", "oauth", "none"}, cobra.ShellCompDirectiveNoFileComp)) _ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions([]string{"claude-code", "codex", "gemini", "opencode", "cursor"}, cobra.ShellCompDirectiveNoFileComp)) return cmd @@ -521,60 +487,3 @@ 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 c10c5548..5c3fc7cc 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -200,28 +200,10 @@ func credentialEnvVars(creds kelosv1alpha1.Credentials, agentType string) []core tokenName := oauthEnvVar(agentType) return []corev1.EnvVar{secretEnvRef(tokenName, false)} - case kelosv1alpha1.CredentialTypeBedrock: - envs := []corev1.EnvVar{ - {Name: "CLAUDE_CODE_USE_BEDROCK", Value: "1"}, - } - if secretName != "" { - // Static credentials from a Secret. - envs = append(envs, - secretEnvRef("AWS_ACCESS_KEY_ID", false), - secretEnvRef("AWS_SECRET_ACCESS_KEY", false), - secretEnvRef("AWS_REGION", false), - secretEnvRef("AWS_SESSION_TOKEN", true), - secretEnvRef("ANTHROPIC_BEDROCK_BASE_URL", true), - ) - } else if creds.Region != "" { - // IRSA mode — SDK picks up credentials from the projected - // service account token; only the region is needed. - envs = append(envs, corev1.EnvVar{ - Name: "AWS_REGION", - Value: creds.Region, - }) - } - return envs + case kelosv1alpha1.CredentialTypeNone: + // No built-in credential injection; users supply their own + // credentials via PodOverrides.Env. + return nil default: return nil @@ -583,14 +565,9 @@ func (b *JobBuilder) buildAgentJob(task *kelosv1alpha1.Task, workspace *kelosv1a } } - // ServiceAccountName from credentials (e.g. IRSA for Bedrock). - var serviceAccountName string - if task.Spec.Credentials.ServiceAccountName != "" { - serviceAccountName = task.Spec.Credentials.ServiceAccountName - } - // Apply PodOverrides before constructing the Job so all overrides // are reflected in the final spec. + var serviceAccountName string var activeDeadlineSeconds *int64 var nodeSelector map[string]string @@ -620,6 +597,10 @@ func (b *JobBuilder) buildAgentJob(task *kelosv1alpha1.Task, workspace *kelosv1a if po.NodeSelector != nil { nodeSelector = po.NodeSelector } + + if po.ServiceAccountName != "" { + serviceAccountName = po.ServiceAccountName + } } // PodFailurePolicy ensures only pod disruptions (e.g. node scale-down, diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 7a2e0194..69e5b783 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -4247,19 +4247,24 @@ func TestBuildJob_UpstreamRepoSpecWithoutRemote(t *testing.T) { } } -func TestBuildClaudeCodeJob_BedrockCredentials(t *testing.T) { +func TestBuildJob_NoneCredentials(t *testing.T) { builder := NewJobBuilder() task := &kelosv1alpha1.Task{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-bedrock", + Name: "test-none-creds", Namespace: "default", }, Spec: kelosv1alpha1.TaskSpec{ Type: AgentTypeClaudeCode, Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ - Type: kelosv1alpha1.CredentialTypeBedrock, - SecretRef: &kelosv1alpha1.SecretReference{Name: "bedrock-creds"}, + Type: kelosv1alpha1.CredentialTypeNone, + }, + PodOverrides: &kelosv1alpha1.PodOverrides{ + Env: []corev1.EnvVar{ + {Name: "CLAUDE_CODE_USE_BEDROCK", Value: "1"}, + {Name: "AWS_REGION", Value: "us-east-1"}, + }, }, }, } @@ -4277,76 +4282,47 @@ func TestBuildClaudeCodeJob_BedrockCredentials(t *testing.T) { envMap[env.Name] = env } - // CLAUDE_CODE_USE_BEDROCK should be set as a literal value. + // User-supplied env vars from PodOverrides should be present. if env, ok := envMap["CLAUDE_CODE_USE_BEDROCK"]; !ok { - t.Error("Expected CLAUDE_CODE_USE_BEDROCK env var") + t.Error("Expected CLAUDE_CODE_USE_BEDROCK env var from PodOverrides") } 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) - } + if env, ok := envMap["AWS_REGION"]; !ok { + t.Error("Expected AWS_REGION env var from PodOverrides") + } else if env.Value != "us-east-1" { + t.Errorf("AWS_REGION = %q, want %q", env.Value, "us-east-1") } - // ANTHROPIC_API_KEY should NOT be set for bedrock credential type. + // No built-in credential env vars should be set. if _, ok := envMap["ANTHROPIC_API_KEY"]; ok { - t.Error("ANTHROPIC_API_KEY should not be set for bedrock credential type") + t.Error("ANTHROPIC_API_KEY should not be set for none 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") + t.Error("CLAUDE_CODE_OAUTH_TOKEN should not be set for none credential type") } } -func TestBuildClaudeCodeJob_BedrockIRSA(t *testing.T) { +func TestBuildJob_NoneCredentials_ServiceAccountName(t *testing.T) { builder := NewJobBuilder() task := &kelosv1alpha1.Task{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-bedrock-irsa", + Name: "test-none-sa", Namespace: "default", }, Spec: kelosv1alpha1.TaskSpec{ Type: AgentTypeClaudeCode, Prompt: "Fix the bug", Credentials: kelosv1alpha1.Credentials{ - Type: kelosv1alpha1.CredentialTypeBedrock, - Region: "us-west-2", + Type: kelosv1alpha1.CredentialTypeNone, + }, + PodOverrides: &kelosv1alpha1.PodOverrides{ ServiceAccountName: "bedrock-agent-sa", + Env: []corev1.EnvVar{ + {Name: "CLAUDE_CODE_USE_BEDROCK", Value: "1"}, + {Name: "AWS_REGION", Value: "us-west-2"}, + }, }, }, } @@ -4356,47 +4332,27 @@ func TestBuildClaudeCodeJob_BedrockIRSA(t *testing.T) { t.Fatalf("Build() returned error: %v", err) } - container := job.Spec.Template.Spec.Containers[0] + // ServiceAccountName should be set on the pod spec from PodOverrides. + if job.Spec.Template.Spec.ServiceAccountName != "bedrock-agent-sa" { + t.Errorf("ServiceAccountName = %q, want %q", job.Spec.Template.Spec.ServiceAccountName, "bedrock-agent-sa") + } - // Collect env vars by name. + container := job.Spec.Template.Spec.Containers[0] 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. + // User-supplied env vars should be present. 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") } - // AWS_REGION should be set as a literal value (not from a secret). if env, ok := envMap["AWS_REGION"]; !ok { t.Error("Expected AWS_REGION env var") - } else { - if env.Value != "us-west-2" { - t.Errorf("AWS_REGION = %q, want %q", env.Value, "us-west-2") - } - if env.ValueFrom != nil { - t.Error("AWS_REGION should be a literal value, not a secret reference") - } - } - - // Static AWS credentials should NOT be set in IRSA mode. - for _, key := range []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "ANTHROPIC_BEDROCK_BASE_URL"} { - if _, ok := envMap[key]; ok { - t.Errorf("%s should not be set in IRSA mode (no secretRef)", key) - } - } - - // ANTHROPIC_API_KEY should NOT be set. - if _, ok := envMap["ANTHROPIC_API_KEY"]; ok { - t.Error("ANTHROPIC_API_KEY should not be set for bedrock credential type") - } - - // ServiceAccountName should be set on the pod spec. - if job.Spec.Template.Spec.ServiceAccountName != "bedrock-agent-sa" { - t.Errorf("ServiceAccountName = %q, want %q", job.Spec.Template.Spec.ServiceAccountName, "bedrock-agent-sa") + } else if env.Value != "us-west-2" { + t.Errorf("AWS_REGION = %q, want %q", env.Value, "us-west-2") } } diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 30b14269..af40d00d 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -303,16 +303,10 @@ spec: credentials: description: Credentials specifies how to authenticate with the agent. properties: - region: - description: |- - Region specifies the cloud provider region (e.g. AWS region for Bedrock). - Used with bedrock credentials when secretRef is omitted (IRSA mode). - type: string secretRef: description: |- SecretRef references the Secret containing credentials. - Required for api-key and oauth types. Optional for bedrock - when using IAM Roles for Service Accounts (IRSA). + Required for api-key and oauth types. Not used with none. properties: name: description: Name is the name of the secret. @@ -320,18 +314,12 @@ spec: required: - name type: object - serviceAccountName: - description: |- - ServiceAccountName overrides the pod's service account. - Use with IAM Roles for Service Accounts (IRSA) on EKS to let - the pod assume an IAM role without static credentials. - type: string type: description: Type specifies the credential type. enum: - api-key - oauth - - bedrock + - none type: string required: - type @@ -339,7 +327,7 @@ spec: x-kubernetes-validations: - message: secretRef is required for api-key and oauth credential types - rule: self.type == 'bedrock' || has(self.secretRef) + rule: self.type == 'none' || has(self.secretRef) dependsOn: description: DependsOn lists Task names that must succeed before this Task starts. @@ -593,6 +581,12 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + serviceAccountName: + description: |- + ServiceAccountName sets the pod's service account. + Use with workload identity systems such as IRSA on EKS, GKE + Workload Identity, or Azure Workload Identity. + type: string type: object prompt: description: Prompt is the task prompt to send to the agent. @@ -808,16 +802,10 @@ spec: description: Credentials specifies how to authenticate with the agent. properties: - region: - description: |- - Region specifies the cloud provider region (e.g. AWS region for Bedrock). - Used with bedrock credentials when secretRef is omitted (IRSA mode). - type: string secretRef: description: |- SecretRef references the Secret containing credentials. - Required for api-key and oauth types. Optional for bedrock - when using IAM Roles for Service Accounts (IRSA). + Required for api-key and oauth types. Not used with none. properties: name: description: Name is the name of the secret. @@ -825,18 +813,12 @@ spec: required: - name type: object - serviceAccountName: - description: |- - ServiceAccountName overrides the pod's service account. - Use with IAM Roles for Service Accounts (IRSA) on EKS to let - the pod assume an IAM role without static credentials. - type: string type: description: Type specifies the credential type. enum: - api-key - oauth - - bedrock + - none type: string required: - type @@ -844,7 +826,7 @@ spec: x-kubernetes-validations: - message: secretRef is required for api-key and oauth credential types - rule: self.type == 'bedrock' || has(self.secretRef) + rule: self.type == 'none' || has(self.secretRef) dependsOn: description: DependsOn lists Task names that spawned Tasks depend on. @@ -1099,6 +1081,12 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + serviceAccountName: + description: |- + ServiceAccountName sets the pod's service account. + Use with workload identity systems such as IRSA on EKS, GKE + Workload Identity, or Azure Workload Identity. + type: string type: object promptTemplate: description: |- From 2eb1f73c7d086cf6f2053f30918be50ab8a687b1 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Wed, 25 Mar 2026 07:43:26 +0100 Subject: [PATCH 15/26] fix: add dry run test for none credentials --- internal/cli/dryrun_test.go | 135 ++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/internal/cli/dryrun_test.go b/internal/cli/dryrun_test.go index 77c43749..74747e84 100644 --- a/internal/cli/dryrun_test.go +++ b/internal/cli/dryrun_test.go @@ -828,6 +828,141 @@ func TestRunCommand_DryRun_CodexOAuthToken_FileRef(t *testing.T) { } } +func TestRunCommand_DryRun_NoneCredentialType(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{ + "run", + "--config", cfgPath, + "--dry-run", + "--prompt", "hello", + "--name", "none-cred-task", + "--namespace", "test-ns", + "--credential-type", "none", + }) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + if err := cmd.Execute(); err != nil { + w.Close() + os.Stdout = old + t.Fatalf("unexpected error: %v", err) + } + + w.Close() + os.Stdout = old + var out bytes.Buffer + out.ReadFrom(r) + output := out.String() + + if !strings.Contains(output, "kind: Task") { + t.Errorf("expected 'kind: Task' in output, got:\n%s", output) + } + if !strings.Contains(output, "type: none") { + t.Errorf("expected credential 'type: none' in output, got:\n%s", output) + } + // none credential type should not produce a secretRef. + if strings.Contains(output, "secretRef") { + t.Errorf("none credential type should not include secretRef, got:\n%s", output) + } +} + +func TestRunCommand_DryRun_NoneCredentialType_WithEnv(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{ + "run", + "--config", cfgPath, + "--dry-run", + "--prompt", "hello", + "--name", "none-env-task", + "--namespace", "test-ns", + "--credential-type", "none", + "--env", "CLAUDE_CODE_USE_BEDROCK=1", + "--env", "AWS_REGION=us-east-1", + }) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + if err := cmd.Execute(); err != nil { + w.Close() + os.Stdout = old + t.Fatalf("unexpected error: %v", err) + } + + w.Close() + os.Stdout = old + var out bytes.Buffer + out.ReadFrom(r) + output := out.String() + + if !strings.Contains(output, "type: none") { + t.Errorf("expected credential 'type: none' in output, got:\n%s", output) + } + if !strings.Contains(output, "CLAUDE_CODE_USE_BEDROCK") { + t.Errorf("expected CLAUDE_CODE_USE_BEDROCK env var in output, got:\n%s", output) + } + if !strings.Contains(output, "AWS_REGION") { + t.Errorf("expected AWS_REGION env var in output, got:\n%s", output) + } +} + +func TestRunCommand_DryRun_NoneCredentialType_ConfigFile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + cfg := "credentialType: none\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{ + "run", + "--config", cfgPath, + "--dry-run", + "--prompt", "hello", + "--name", "none-cfg-task", + "--namespace", "test-ns", + }) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + if err := cmd.Execute(); err != nil { + w.Close() + os.Stdout = old + t.Fatalf("unexpected error: %v", err) + } + + w.Close() + os.Stdout = old + var out bytes.Buffer + out.ReadFrom(r) + output := out.String() + + if !strings.Contains(output, "type: none") { + t.Errorf("expected credential 'type: none' in output, got:\n%s", output) + } + if strings.Contains(output, "secretRef") { + t.Errorf("none credential type should not include secretRef, got:\n%s", output) + } +} + func TestResolveContent(t *testing.T) { t.Run("empty", func(t *testing.T) { got, err := resolveContent("") From bb4c335d9fe2cd58a947888ae2daf03e01f29075 Mon Sep 17 00:00:00 2001 From: tmarshall Date: Wed, 25 Mar 2026 09:21:08 -0500 Subject: [PATCH 16/26] tweaks --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 83 ++++++++++++++++----------------- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 45f00cbc..0e14a377 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -81,7 +81,7 @@ cat >> /etc/bash.bashrc << 'SHELL_RC' # Claude / Bedrock helper function claude-bedrock() { - CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" } # Teleport credential helpers diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a73d1bfd..a1b2a52d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,44 +1,43 @@ { - "name": "Kelos Dev", - "build": { - "dockerfile": "Dockerfile" - }, - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { - "kubectl": "latest", - "helm": "latest", - "minikube": "none" - }, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/sshd:1": {}, - "ghcr.io/tailscale/codespace/tailscale:1": {} - }, - "customizations": { - "vscode": { - "settings": { - "go.toolsManagement.autoUpdate": true, - "go.useLanguageServer": true, - "go.lintTool": "golangci-lint", - "terminal.integrated.defaultProfile.linux": "zsh" - }, - "extensions": [ - "anthropic.claude-code", - "golang.go", - "eamodio.gitlens", - "redhat.vscode-yaml", - "ms-kubernetes-tools.vscode-kubernetes-tools" - ] - } - }, - "forwardPorts": [], - "postCreateCommand": "/bin/bash .devcontainer/post-create.sh", - "remoteEnv": { - "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" - }, - "hostRequirements": { - "cpus": 4, - "memory": "8gb" - } + "name": "Kelos Dev", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "kubectl": "latest", + "helm": "latest", + "minikube": "none" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/tailscale/codespace/tailscale:1": {} + }, + "customizations": { + "vscode": { + "settings": { + "go.toolsManagement.autoUpdate": true, + "go.useLanguageServer": true, + "go.lintTool": "golangci-lint", + "terminal.integrated.defaultProfile.linux": "zsh" + }, + "extensions": [ + "anthropic.claude-code", + "golang.go", + "eamodio.gitlens", + "redhat.vscode-yaml", + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] + } + }, + "forwardPorts": [], + "postCreateCommand": "/bin/bash .devcontainer/post-create.sh", + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + "hostRequirements": { + "cpus": 4, + "memory": "8gb" } - \ No newline at end of file +} \ No newline at end of file From 000559a3ae5441548258bc6800cbecb8cbc8de7f Mon Sep 17 00:00:00 2001 From: tmarshall Date: Wed, 25 Mar 2026 11:20:44 -0500 Subject: [PATCH 17/26] ... --- .devcontainer/post-create.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 33614400..c7737c1e 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,4 +1,5 @@ #!/bin/bash + set -euo pipefail echo "==> Installing Go tool dependencies..." From 25747d33faaaf3d745d009a6a1183a98acfe9d72 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Wed, 25 Mar 2026 11:59:01 -0500 Subject: [PATCH 18/26] Fix syntax error in post-create.sh --- .devcontainer/post-create.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c7737c1e..59a60efd 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -11,7 +11,7 @@ cd /workspaces/kelos && go mod download echo "==> Building kelos CLI..." make -C /workspaces/kelos build WHAT=cmd/kelos 2>/dev/null || true -cat << 'MSG' +cat <<'MSG' ==> Done! To get started: 1. tailscale up --accept-routes From 835d0f5a559e61af81f8fe9ba3926851db9e554f Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Wed, 25 Mar 2026 13:58:47 -0500 Subject: [PATCH 19/26] Remove Oh My Zsh installation step Removed Oh My Zsh installation from Dockerfile. --- .devcontainer/Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0e14a377..dcffbc45 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -91,7 +91,4 @@ alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox - SHELL_RC EOF -# Oh My Zsh -RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended - USER vscode From 3b745783566adae1894e9b11922983cf0a40c108 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Wed, 25 Mar 2026 14:27:52 -0500 Subject: [PATCH 20/26] Remain as root user --- .devcontainer/Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index dcffbc45..e7823554 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -90,5 +90,3 @@ alias gimme-admin-creds="tsh svid issue --output /opt/roles-anywhere-admin --svi alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox --svid-ttl 12h /cloud/aws-sandbox" SHELL_RC EOF - -USER vscode From e795fb821eecfa736489576b8f09c61916f76666 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Wed, 25 Mar 2026 14:34:51 -0500 Subject: [PATCH 21/26] Update Dockerfile to configure Teleport settings Add environment variables for Teleport authentication and proxy. --- .devcontainer/Dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e7823554..a4f0047a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -69,7 +69,7 @@ cat >> /etc/zshrc << 'SHELL_RC' # Claude / Bedrock helper function claude-bedrock() { - CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" + CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=us-west-2 claude "$@" } # Teleport credential helpers @@ -90,3 +90,11 @@ alias gimme-admin-creds="tsh svid issue --output /opt/roles-anywhere-admin --svi alias gimme-sandbox-creds="tsh svid issue --output /opt/roles-anywhere-sandbox --svid-ttl 12h /cloud/aws-sandbox" SHELL_RC EOF + +# Needed for teleport & credentials +RUN <>/etc/zshrc + echo 'export TELEPORT_PROXY=anomalo.teleport.sh:443' >>/etc/zshrc + echo 'export TELEPORT_AUTH=google' >>/etc/bash.bashrc + echo 'export TELEPORT_PROXY=anomalo.teleport.sh:443' >>/etc/bash.bashrc +EOF From 50267c175f02750647e9894de3eb132c96aba123 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Wed, 25 Mar 2026 15:40:17 -0500 Subject: [PATCH 22/26] Add remoteUser configuration to devcontainer.json --- .devcontainer/devcontainer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a1b2a52d..d6be13ef 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "build": { "dockerfile": "Dockerfile" }, + "remoteUser": "root", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { @@ -40,4 +41,4 @@ "cpus": 4, "memory": "8gb" } -} \ No newline at end of file +} From 43f1590f22f56632d0cec8bb221d6b91ac033b25 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Thu, 26 Mar 2026 17:22:19 +0100 Subject: [PATCH 23/26] feat: add secrets pre-commit hook (#17) * feat(ci): add pre-commit hook that checks for secrets * fix: more keys * chore: format --- .githooks/check-secrets.sh | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100755 .githooks/check-secrets.sh diff --git a/.githooks/check-secrets.sh b/.githooks/check-secrets.sh new file mode 100755 index 00000000..361e9f15 --- /dev/null +++ b/.githooks/check-secrets.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Pre-commit hook: check staged files for secrets or sensitive information + +set -euo pipefail + +RED='\033[0;31m' +NC='\033[0m' + +# Patterns that suggest secrets or sensitive info +PATTERNS=( + 'AKIA[0-9A-Z]{16}' # AWS Access Key ID + '["\x27]sk-[a-zA-Z0-9]{20,}' # OpenAI / Stripe secret keys + 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access token + 'github_pat_[a-zA-Z0-9_]{22,}' # GitHub fine-grained PAT + 'glpat-[a-zA-Z0-9\-]{20,}' # GitLab PAT + 'xox[bpors]-[a-zA-Z0-9\-]+' # Slack tokens + '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----' # Private keys + 'password\s*[:=]\s*["\x27][^"\x27]{4,}' # password assignments + 'secret\s*[:=]\s*["\x27][^"\x27]{4,}' # secret assignments + 'api[_-]?key\s*[:=]\s*["\x27][^"\x27]{4,}' # API key assignments + 'token\s*[:=]\s*["\x27][^"\x27]{4,}' # token assignments + 'AIza[0-9A-Za-z\-_]{35}' # Google API key + '[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com' # Google OAuth client ID + 'sk-ant-api[0-9]{2}-[a-zA-Z0-9\-_]{80,}' # Anthropic API key + 'sk-ant-[a-zA-Z0-9\-_]{40,}' # Anthropic API key (older format) + 'sk-proj-[a-zA-Z0-9\-_]{40,}' # OpenAI project API key + 'sk-[a-zA-Z0-9]{48}' # OpenAI API key (legacy) + 'ya29\.[a-zA-Z0-9_\-]{50,}' # Google/Vertex OAuth access token +) + +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true) + +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +FOUND=0 + +for file in $STAGED_FILES; do + # Skip binary files and common non-secret files + if [[ "$file" =~ \.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|pdf|zip|tar|gz)$ ]]; then + continue + fi + # Skip this hook script itself and test fixtures + if [[ "$file" == *"check-secrets"* ]] || [[ "$file" == *"testdata"* ]] || [[ "$file" == *"test/fixtures"* ]]; then + continue + fi + + CONTENT=$(git show ":$file" 2>/dev/null || true) + if [ -z "$CONTENT" ]; then + continue + fi + + for pattern in "${PATTERNS[@]}"; do + MATCHES=$(echo "$CONTENT" | grep -nEi "$pattern" 2>/dev/null || true) + if [ -n "$MATCHES" ]; then + echo -e "${RED}Possible secret found in ${file}:${NC}" + echo "$MATCHES" | head -5 + echo "" + FOUND=1 + fi + done +done + +# Check for common sensitive filenames +SENSITIVE_FILES=( + '.env' + '.env.local' + '.env.production' + 'credentials.json' + 'service-account.json' + 'id_rsa' + 'id_ed25519' + '.npmrc' + '.pypirc' + 'kubeconfig' +) + +for file in $STAGED_FILES; do + basename=$(basename "$file") + for sensitive in "${SENSITIVE_FILES[@]}"; do + if [ "$basename" = "$sensitive" ]; then + echo -e "${RED}Sensitive file staged for commit: ${file}${NC}" + FOUND=1 + fi + done +done + +if [ "$FOUND" -eq 1 ]; then + echo -e "${RED}Commit blocked: potential secrets detected.${NC}" + echo "If these are false positives, commit with --no-verify to bypass." + exit 1 +fi + +exit 0 From 5bd12a7aaa6e274727570a84cd9f9911b1bc90d4 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Thu, 26 Mar 2026 11:30:22 -0500 Subject: [PATCH 24/26] feat: add webhook support for GitHub events (AIE-13) (#15) * session: add webhook support plan * feat: add WebhookEvent CRD type definition Defines WebhookEvent custom resource for storing incoming webhooks. Uses CRD-based queue pattern consistent with Kelos architecture. Note: Requires 'make update' in Go environment to generate deepcopy and CRD manifests. * feat: add webhook receiver HTTP server Implements HTTP endpoint at /webhook/:source that: - Receives webhook payloads - Validates GitHub signatures via HMAC-SHA256 - Creates WebhookEvent CRD instances - Returns 202 Accepted Supports multiple sources (github, slack, linear, etc.) via URL path. * feat: add GitHub webhook source implementation Implements Source interface for webhook-based discovery: - Reads unprocessed WebhookEvent CRDs with source=github - Parses GitHub issue and pull_request webhook payloads - Converts to WorkItem format - Marks events as processed after discovery - Supports label-based filtering (client-side) * feat: add GitHubWebhook to TaskSpawner When options Adds githubWebhook field to TaskSpawner CRD allowing webhook-based discovery as an alternative to API polling. The spawner will watch WebhookEvent resources in the specified namespace and convert GitHub webhook payloads to tasks. * feat: integrate GitHubWebhookSource into spawner Updates spawner to create GitHubWebhookSource instances when TaskSpawner has githubWebhook configured. Passes k8s client to source so it can list and update WebhookEvent resources. * docs: add webhook documentation and examples Includes: - Complete TaskSpawner example using githubWebhook - Webhook receiver deployment manifests - Architecture documentation explaining CRD-based queue - GitHub webhook setup instructions - Comparison with API polling approach * session: update plan with completion status Marks completed phases and notes what still needs to be done: - CRD manifest generation (needs Go environment) - Unit and integration tests - Live cluster testing * test: add unit tests for webhook receiver and source Webhook receiver tests: - Valid/invalid GitHub signature validation - Missing signature header handling - No secret configured (dev mode) - HTTP method validation - Missing source in path GitHub webhook source tests: - Issue payload parsing - Pull request payload parsing - Closed issues/PRs are skipped - Label filtering (required, excluded, multiple) - End-to-end Discover with fake k8s client - Events marked as processed after discovery * session: mark unit tests as complete in plan * fix: resolve test compilation errors - Fix logger type in webhook receiver (logr.Logger) - Add missing parameters to buildSource calls in tests - Remove field selectors for fake client compatibility Co-Authored-By: Claude Sonnet 4.5 * fix: correct test expectations - Fix HMAC-SHA256 signature in webhook receiver test - Enable status subresource in fake client for webhook source test Co-Authored-By: Claude Sonnet 4.5 * make update * feat: add RBAC permissions for WebhookEvent resources - Add webhookevents permissions to kelos-spawner-role - Create kelos-webhook-receiver-role for webhook receiver Co-Authored-By: Claude Sonnet 4.5 * docs: focus webhook documentation on GitHub support - Remove mentions of future sources (Slack, Linear, etc.) - Remove comparison table with API polling - Keep documentation focused on current implementation Co-Authored-By: Claude Sonnet 4.5 * test: add integration tests for webhook flow - Test WebhookEvent discovery and processing - Test label filtering (required and excluded labels) - Test issue and pull request payloads - Test source filtering (github vs other sources) - Test skipping closed issues/PRs - Verify events are marked as processed after discovery Co-Authored-By: Claude Sonnet 4.5 * docs: update webhook support plan Mark all tasks as completed: - RBAC permissions added - Integration tests implemented - All tests passing Co-Authored-By: Claude Sonnet 4.5 * session: remove webhook support plan * fix: use DeepCopy for status updates in webhook discovery - Change from &eventList.Items[i] to DeepCopy() to fix status updates - Mark events as processed even when filtered out or invalid - Ensures all processed events have status updated in integration tests Co-Authored-By: Claude Sonnet 4.5 * feat: add helm chart support for webhooks * fix: add webhook receiver docker --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Hans Knecht --- Makefile | 2 +- api/v1alpha1/taskspawner_types.go | 27 ++ api/v1alpha1/webhookevent_types.go | 66 +++ api/v1alpha1/zz_generated.deepcopy.go | 134 +++++ cmd/kelos-spawner/main.go | 14 +- cmd/kelos-spawner/main_test.go | 18 +- cmd/kelos-webhook-receiver/Dockerfile | 12 + cmd/kelos-webhook-receiver/main.go | 177 +++++++ cmd/kelos-webhook-receiver/main_test.go | 93 ++++ docs/webhooks.md | 105 ++++ examples/taskspawner-github-webhook.yaml | 126 +++++ .../charts/kelos/templates/rbac.yaml | 43 ++ .../kelos/templates/webhook-receiver.yaml | 166 +++++++ internal/manifests/charts/kelos/values.yaml | 26 + internal/manifests/install-crd.yaml | 125 +++++ internal/source/github_webhook.go | 211 ++++++++ internal/source/github_webhook_test.go | 278 +++++++++++ .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../api/v1alpha1/fake/fake_webhookevent.go | 52 ++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../typed/api/v1alpha1/webhookevent.go | 70 +++ .../api/v1alpha1/interface.go | 7 + .../api/v1alpha1/webhookevent.go | 102 ++++ .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 8 + .../listers/api/v1alpha1/webhookevent.go | 70 +++ test/integration/suite_test.go | 3 + test/integration/webhook_test.go | 456 ++++++++++++++++++ 29 files changed, 2392 insertions(+), 12 deletions(-) create mode 100644 api/v1alpha1/webhookevent_types.go create mode 100644 cmd/kelos-webhook-receiver/Dockerfile create mode 100644 cmd/kelos-webhook-receiver/main.go create mode 100644 cmd/kelos-webhook-receiver/main_test.go create mode 100644 docs/webhooks.md create mode 100644 examples/taskspawner-github-webhook.yaml create mode 100644 internal/manifests/charts/kelos/templates/webhook-receiver.yaml create mode 100644 internal/source/github_webhook.go create mode 100644 internal/source/github_webhook_test.go create mode 100644 pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go create mode 100644 pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go create mode 100644 pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go create mode 100644 pkg/generated/listers/api/v1alpha1/webhookevent.go create mode 100644 test/integration/webhook_test.go diff --git a/Makefile b/Makefile index 4292366c..916ad6eb 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Image configuration REGISTRY ?= public.ecr.aws/anomalo/kelos VERSION ?= latest -IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher claude-code codex gemini opencode cursor +IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/kelos-token-refresher cmd/kelos-webhook-receiver claude-code codex gemini opencode cursor # Version injection for the kelos CLI – only set ldflags when an explicit # version is given so that dev builds fall through to runtime/debug info. diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index f4dd0e7d..89f3b4a1 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -29,6 +29,10 @@ type When struct { // +optional GitHubPullRequests *GitHubPullRequests `json:"githubPullRequests,omitempty"` + // GitHubWebhook discovers issues and pull requests from GitHub webhooks. + // +optional + GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` + // Cron triggers task spawning on a cron schedule. // +optional Cron *Cron `json:"cron,omitempty"` @@ -260,6 +264,29 @@ type GitHubPullRequests struct { PollInterval string `json:"pollInterval,omitempty"` } +// GitHubWebhook discovers issues and pull requests from GitHub webhook events. +// Instead of polling the GitHub API, work items are discovered from webhook +// payloads received by the kelos-webhook-receiver and stored as WebhookEvent +// custom resources. +type GitHubWebhook struct { + // Namespace is the Kubernetes namespace where WebhookEvent resources are created. + // The spawner will watch for GitHub webhook events in this namespace. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` + + // Labels filters issues/PRs by labels (applied client-side to webhook payloads). + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels filters out issues/PRs that have any of these labels (client-side). + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` + + // Reporting configures status reporting back to GitHub. + // +optional + Reporting *GitHubReporting `json:"reporting,omitempty"` +} + // Jira discovers issues from a Jira project. // Authentication is provided via a Secret referenced in the TaskSpawner's // namespace. The secret must contain a "JIRA_TOKEN" key. For Jira Cloud, diff --git a/api/v1alpha1/webhookevent_types.go b/api/v1alpha1/webhookevent_types.go new file mode 100644 index 00000000..46154a7c --- /dev/null +++ b/api/v1alpha1/webhookevent_types.go @@ -0,0 +1,66 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WebhookEventSpec defines the desired state of WebhookEvent. +type WebhookEventSpec struct { + // Source is the webhook source type (e.g., "github", "slack", "linear"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Source string `json:"source"` + + // Payload is the raw webhook payload as JSON bytes. + // +kubebuilder:validation:Required + Payload []byte `json:"payload"` + + // ReceivedAt is the timestamp when the webhook was received. + // +kubebuilder:validation:Required + ReceivedAt metav1.Time `json:"receivedAt"` +} + +// WebhookEventStatus defines the observed state of WebhookEvent. +type WebhookEventStatus struct { + // Processed indicates whether this event has been processed by a source. + // +optional + Processed bool `json:"processed,omitempty"` + + // ProcessedAt is the timestamp when the event was processed. + // +optional + ProcessedAt *metav1.Time `json:"processedAt,omitempty"` + + // Message provides additional information about processing. + // +optional + Message string `json:"message,omitempty"` +} + +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.source` +// +kubebuilder:printcolumn:name="Processed",type=boolean,JSONPath=`.status.processed` +// +kubebuilder:printcolumn:name="ReceivedAt",type=date,JSONPath=`.spec.receivedAt` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// WebhookEvent is the Schema for the webhookevents API. +type WebhookEvent struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WebhookEventSpec `json:"spec,omitempty"` + Status WebhookEventStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// WebhookEventList contains a list of WebhookEvent. +type WebhookEventList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []WebhookEvent `json:"items"` +} + +func init() { + SchemeBuilder.Register(&WebhookEvent{}, &WebhookEventList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 83ace025..07755166 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -328,6 +328,36 @@ func (in *GitHubReporting) DeepCopy() *GitHubReporting { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitHubWebhook) DeepCopyInto(out *GitHubWebhook) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Reporting != nil { + in, out := &in.Reporting, &out.Reporting + *out = new(GitHubReporting) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubWebhook. +func (in *GitHubWebhook) DeepCopy() *GitHubWebhook { + if in == nil { + return nil + } + out := new(GitHubWebhook) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitRemote) DeepCopyInto(out *GitRemote) { *out = *in @@ -821,6 +851,105 @@ func (in *TaskTemplate) DeepCopy() *TaskTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEvent) DeepCopyInto(out *WebhookEvent) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEvent. +func (in *WebhookEvent) DeepCopy() *WebhookEvent { + if in == nil { + return nil + } + out := new(WebhookEvent) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WebhookEvent) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEventList) DeepCopyInto(out *WebhookEventList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]WebhookEvent, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEventList. +func (in *WebhookEventList) DeepCopy() *WebhookEventList { + if in == nil { + return nil + } + out := new(WebhookEventList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WebhookEventList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEventSpec) DeepCopyInto(out *WebhookEventSpec) { + *out = *in + if in.Payload != nil { + in, out := &in.Payload, &out.Payload + *out = make([]byte, len(*in)) + copy(*out, *in) + } + in.ReceivedAt.DeepCopyInto(&out.ReceivedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEventSpec. +func (in *WebhookEventSpec) DeepCopy() *WebhookEventSpec { + if in == nil { + return nil + } + out := new(WebhookEventSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookEventStatus) DeepCopyInto(out *WebhookEventStatus) { + *out = *in + if in.ProcessedAt != nil { + in, out := &in.ProcessedAt, &out.ProcessedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookEventStatus. +func (in *WebhookEventStatus) DeepCopy() *WebhookEventStatus { + if in == nil { + return nil + } + out := new(WebhookEventStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *When) DeepCopyInto(out *When) { *out = *in @@ -834,6 +963,11 @@ func (in *When) DeepCopyInto(out *When) { *out = new(GitHubPullRequests) (*in).DeepCopyInto(*out) } + if in.GitHubWebhook != nil { + in, out := &in.GitHubWebhook, &out.GitHubWebhook + *out = new(GitHubWebhook) + (*in).DeepCopyInto(*out) + } if in.Cron != nil { in, out := &in.Cron, &out.Cron *out = new(Cron) diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 70f45487..5b03f4d4 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -174,7 +174,7 @@ func runCycle(ctx context.Context, cl client.Client, key types.NamespacedName, g return fmt.Errorf("fetching TaskSpawner: %w", err) } - src, err := buildSource(&ts, githubOwner, githubRepo, githubAPIBaseURL, githubTokenFile, jiraBaseURL, jiraProject, jiraJQL, httpClient) + src, err := buildSource(&ts, githubOwner, githubRepo, githubAPIBaseURL, githubTokenFile, jiraBaseURL, jiraProject, jiraJQL, httpClient, cl) if err != nil { return fmt.Errorf("building source: %w", err) } @@ -505,7 +505,7 @@ func resolveGitHubCommentPolicy(policy *kelosv1alpha1.GitHubCommentPolicy, legac }, nil } -func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFile, jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client) (source.Source, error) { +func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFile, jiraBaseURL, jiraProject, jiraJQL string, httpClient *http.Client, k8sClient client.Client) (source.Source, error) { if ts.Spec.When.GitHubIssues != nil { gh := ts.Spec.When.GitHubIssues token, err := readGitHubToken(tokenFile) @@ -569,6 +569,16 @@ func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFi }, nil } + if ts.Spec.When.GitHubWebhook != nil { + webhook := ts.Spec.When.GitHubWebhook + return &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: webhook.Namespace, + Labels: webhook.Labels, + ExcludeLabels: webhook.ExcludeLabels, + }, nil + } + if ts.Spec.When.Jira != nil { user := os.Getenv("JIRA_USER") token := os.Getenv("JIRA_TOKEN") diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index 5b4cb21f..be9efe3b 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -112,7 +112,7 @@ func newTask(name, namespace, spawnerName string, phase kelosv1alpha1.TaskPhase) func TestBuildSource_GitHubIssuesWithBaseURL(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) - src, err := buildSource(ts, "my-org", "my-repo", "https://github.example.com/api/v3", "", "", "", "", nil) + src, err := buildSource(ts, "my-org", "my-repo", "https://github.example.com/api/v3", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -135,7 +135,7 @@ func TestBuildSource_GitHubIssuesWithBaseURL(t *testing.T) { func TestBuildSource_GitHubIssuesDefaultBaseURL(t *testing.T) { ts := newTaskSpawner("spawner", "default", nil) - src, err := buildSource(ts, "kelos-dev", "kelos", "", "", "", "", "", nil) + src, err := buildSource(ts, "kelos-dev", "kelos", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -161,7 +161,7 @@ func TestBuildSource_GitHubPullRequests(t *testing.T) { }, } - src, err := buildSource(ts, "kelos-dev", "kelos", "https://github.example.com/api/v3", "", "", "", "", nil) + src, err := buildSource(ts, "kelos-dev", "kelos", "https://github.example.com/api/v3", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -221,7 +221,7 @@ func TestBuildSource_Jira(t *testing.T) { t.Setenv("JIRA_USER", "user@example.com") t.Setenv("JIRA_TOKEN", "jira-api-token") - src, err := buildSource(ts, "", "", "", "", "https://mycompany.atlassian.net", "PROJ", "status = Open", nil) + src, err := buildSource(ts, "", "", "", "", "https://mycompany.atlassian.net", "PROJ", "status = Open", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1002,7 +1002,7 @@ func TestBuildSource_PriorityLabelsPassedToSource(t *testing.T) { "priority/imporant-soon", } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1029,7 +1029,7 @@ func TestRunCycleWithSource_CommentFieldsPassedToSource(t *testing.T) { ExcludeComments: []string{"/kelos needs-input"}, } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1058,7 +1058,7 @@ func TestBuildSource_CommentPolicyPassedToIssueSource(t *testing.T) { }, } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1098,7 +1098,7 @@ func TestBuildSource_CommentPolicyPassedToPullRequestSource(t *testing.T) { }, } - src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil) + src, err := buildSource(ts, "owner", "repo", "", "", "", "", "", nil, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1163,7 +1163,7 @@ func TestBuildSource_CommentPolicyRejectsMixedConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := buildSource(tt.ts, "owner", "repo", "", "", "", "", "", nil) + _, err := buildSource(tt.ts, "owner", "repo", "", "", "", "", "", nil, nil) if err == nil { t.Fatal("Expected error for mixed legacy and commentPolicy config") } diff --git a/cmd/kelos-webhook-receiver/Dockerfile b/cmd/kelos-webhook-receiver/Dockerfile new file mode 100644 index 00000000..357acf09 --- /dev/null +++ b/cmd/kelos-webhook-receiver/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o bin/kelos-webhook-receiver ./cmd/kelos-webhook-receiver + +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/bin/kelos-webhook-receiver . +USER 65532:65532 +ENTRYPOINT ["/kelos-webhook-receiver"] diff --git a/cmd/kelos-webhook-receiver/main.go b/cmd/kelos-webhook-receiver/main.go new file mode 100644 index 00000000..881bfbad --- /dev/null +++ b/cmd/kelos-webhook-receiver/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/logging" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(kelosv1alpha1.AddToScheme(scheme)) +} + +func main() { + var namespace string + var port int + + flag.StringVar(&namespace, "namespace", "default", "Namespace to create WebhookEvent resources in") + flag.IntVar(&port, "port", 8080, "HTTP server port") + + opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine) + flag.Parse() + + if err := applyVerbosity(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + logger := zap.New(zap.UseFlagOptions(opts)) + ctrl.SetLogger(logger) + log := ctrl.Log.WithName("webhook-receiver") + + cfg, err := ctrl.GetConfig() + if err != nil { + log.Error(err, "unable to get kubeconfig") + os.Exit(1) + } + + cl, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + log.Error(err, "unable to create client") + os.Exit(1) + } + + handler := &webhookHandler{ + client: cl, + namespace: namespace, + log: log, + } + + http.HandleFunc("/webhook/", handler.handle) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + addr := fmt.Sprintf(":%d", port) + log.Info("Starting webhook receiver", "address", addr, "namespace", namespace) + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Error(err, "HTTP server failed") + os.Exit(1) + } +} + +type webhookHandler struct { + client client.Client + namespace string + log logr.Logger +} + +func (h *webhookHandler) handle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract source from path: /webhook/{source} + path := strings.TrimPrefix(r.URL.Path, "/webhook/") + source := strings.Trim(path, "/") + + if source == "" { + http.Error(w, "Source not specified in path", http.StatusBadRequest) + return + } + + // Read payload + body, err := io.ReadAll(r.Body) + if err != nil { + h.log.Error(err, "Failed to read request body") + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Validate signature for GitHub webhooks + if source == "github" { + if err := validateGitHubSignature(r.Header, body); err != nil { + h.log.Error(err, "GitHub signature validation failed") + http.Error(w, "Signature validation failed", http.StatusUnauthorized) + return + } + } + + // Create WebhookEvent CRD + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-webhook-", source), + Namespace: h.namespace, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: source, + Payload: body, + ReceivedAt: metav1.Now(), + }, + } + + ctx := context.Background() + if err := h.client.Create(ctx, event); err != nil { + h.log.Error(err, "Failed to create WebhookEvent", "source", source) + http.Error(w, "Failed to store event", http.StatusInternalServerError) + return + } + + h.log.Info("Webhook received and stored", "source", source, "event", event.Name) + + w.WriteHeader(http.StatusAccepted) + w.Write([]byte("Webhook received")) +} + +// validateGitHubSignature validates the X-Hub-Signature-256 header against the payload. +// The secret is read from the GITHUB_WEBHOOK_SECRET environment variable. +func validateGitHubSignature(headers http.Header, payload []byte) error { + signature := headers.Get("X-Hub-Signature-256") + if signature == "" { + return fmt.Errorf("missing X-Hub-Signature-256 header") + } + + secret := os.Getenv("GITHUB_WEBHOOK_SECRET") + if secret == "" { + // If no secret is configured, skip validation (development mode) + return nil + } + + // Compute expected signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + expectedSignature := "sha256=" + hex.EncodeToString(expectedMAC) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + return fmt.Errorf("signature mismatch") + } + + return nil +} diff --git a/cmd/kelos-webhook-receiver/main_test.go b/cmd/kelos-webhook-receiver/main_test.go new file mode 100644 index 00000000..b503b13a --- /dev/null +++ b/cmd/kelos-webhook-receiver/main_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestValidateGitHubSignature_ValidSignature(t *testing.T) { + // Set up environment + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"opened"}`) + + // Compute expected signature + // echo -n '{"action":"opened"}' | openssl dgst -sha256 -hmac 'test-secret' + // Result: sha256=6e939b5b3d3e8eba83ff81dde0030a8f2190d965e8bec7a17842863e979c4d7d + expectedSig := "sha256=6e939b5b3d3e8eba83ff81dde0030a8f2190d965e8bec7a17842863e979c4d7d" + + headers := http.Header{} + headers.Set("X-Hub-Signature-256", expectedSig) + + err := validateGitHubSignature(headers, payload) + if err != nil { + t.Errorf("Expected valid signature, got error: %v", err) + } +} + +func TestValidateGitHubSignature_InvalidSignature(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"opened"}`) + + headers := http.Header{} + headers.Set("X-Hub-Signature-256", "sha256=wrongsignature") + + err := validateGitHubSignature(headers, payload) + if err == nil { + t.Error("Expected error for invalid signature, got nil") + } +} + +func TestValidateGitHubSignature_MissingHeader(t *testing.T) { + t.Setenv("GITHUB_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"opened"}`) + headers := http.Header{} + + err := validateGitHubSignature(headers, payload) + if err == nil { + t.Error("Expected error for missing signature header, got nil") + } +} + +func TestValidateGitHubSignature_NoSecretConfigured(t *testing.T) { + // Don't set GITHUB_WEBHOOK_SECRET - should skip validation + t.Setenv("GITHUB_WEBHOOK_SECRET", "") + + payload := []byte(`{"action":"opened"}`) + headers := http.Header{} + headers.Set("X-Hub-Signature-256", "sha256=anysignature") + + err := validateGitHubSignature(headers, payload) + if err != nil { + t.Errorf("Expected no error when secret not configured, got: %v", err) + } +} + +func TestWebhookHandler_MethodNotAllowed(t *testing.T) { + handler := &webhookHandler{} + + req := httptest.NewRequest(http.MethodGet, "/webhook/github", nil) + w := httptest.NewRecorder() + + handler.handle(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } +} + +func TestWebhookHandler_MissingSource(t *testing.T) { + handler := &webhookHandler{} + + req := httptest.NewRequest(http.MethodPost, "/webhook/", nil) + w := httptest.NewRecorder() + + handler.handle(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 00000000..732fca89 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,105 @@ +# GitHub Webhook Support + +Kelos supports GitHub webhooks for work item discovery. Instead of polling the GitHub API, Kelos can receive push notifications when issues or pull requests are created or updated. + +## Architecture + +The webhook system consists of three components: + +### 1. WebhookEvent CRD + +Webhook payloads are stored as `WebhookEvent` custom resources in Kubernetes, providing: + +- **Persistence**: Events survive pod restarts (stored in etcd) +- **Auditability**: All events are visible via `kubectl get webhookevents` +- **Processing tracking**: Events are marked as processed after discovery + +### 2. Webhook Receiver (kelos-webhook-receiver) + +An HTTP server that: +- Listens on `/webhook/github` +- Validates GitHub webhook signatures (HMAC-SHA256) +- Creates `WebhookEvent` CRD instances +- Returns 202 Accepted + +Deploy as a Deployment with a LoadBalancer Service to expose it publicly. + +### 3. GitHubWebhookSource + +The `GitHubWebhookSource` implementation: +- Lists unprocessed `WebhookEvent` resources +- Parses GitHub webhook payloads into `WorkItem` format +- Applies filters (labels, state, etc.) +- Marks events as processed + +## GitHub Webhook Setup + +### 1. Deploy the webhook receiver + +```bash +kubectl apply -f examples/taskspawner-github-webhook.yaml +``` + +This creates: +- `kelos-webhook-receiver` Deployment +- LoadBalancer Service +- RBAC for creating WebhookEvent resources + +### 2. Get the external URL + +```bash +kubectl get service kelos-webhook-receiver -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +``` + +### 3. Configure GitHub webhook + +In your GitHub repository settings: +1. Go to Settings → Webhooks → Add webhook +2. **Payload URL**: `http:///webhook/github` +3. **Content type**: `application/json` +4. **Secret**: Set a secret and store it in the `github-webhook-secret` Secret +5. **Events**: Select "Issues" and "Pull requests" +6. Click "Add webhook" + +### 4. Create a TaskSpawner with githubWebhook + +```yaml +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: my-webhook-spawner +spec: + when: + githubWebhook: + namespace: default + labels: + - "kelos-task" + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + {{ .Body }} +``` + +## Webhook Signature Validation + +For GitHub webhooks, the receiver validates the `X-Hub-Signature-256` header using HMAC-SHA256. + +Set the `GITHUB_WEBHOOK_SECRET` environment variable on the webhook receiver to enable validation: + +```yaml +env: +- name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: github-webhook-secret + key: secret +``` + +If the secret is not set, signature validation is skipped (development mode only). diff --git a/examples/taskspawner-github-webhook.yaml b/examples/taskspawner-github-webhook.yaml new file mode 100644 index 00000000..a2db8b28 --- /dev/null +++ b/examples/taskspawner-github-webhook.yaml @@ -0,0 +1,126 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: example-webhook + namespace: default +spec: + when: + githubWebhook: + # Namespace where kelos-webhook-receiver creates WebhookEvent resources + namespace: default + # Optional: filter by labels (applied client-side to webhook payloads) + labels: + - "kelos-task" + # Optional: exclude items with these labels + excludeLabels: + - "skip-kelos" + # Optional: post status comments back to GitHub + reporting: + enabled: true + + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + + {{ .Body }} + + Please investigate and resolve this {{ .Kind }}. + +--- +# Webhook receiver deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kelos-webhook-receiver + template: + metadata: + labels: + app: kelos-webhook-receiver + spec: + serviceAccountName: kelos-webhook-receiver + containers: + - name: receiver + image: ghcr.io/kelos-dev/kelos-webhook-receiver:latest + args: + - --namespace=default + - --port=8080 + ports: + - containerPort: 8080 + name: http + env: + # Optional: GitHub webhook secret for signature validation + - name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: github-webhook-secret + key: secret + livenessProbe: + httpGet: + path: /health + port: 8080 + readinessProbe: + httpGet: + path: /health + port: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + selector: + app: kelos-webhook-receiver + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: LoadBalancer + +--- +# ServiceAccount and RBAC for webhook receiver +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-receiver + namespace: default + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kelos-webhook-receiver + namespace: default +rules: + - apiGroups: ["kelos.dev"] + resources: ["webhookevents"] + verbs: ["create", "get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kelos-webhook-receiver + namespace: default +subjects: + - kind: ServiceAccount + name: kelos-webhook-receiver + namespace: default +roleRef: + kind: Role + name: kelos-webhook-receiver + apiGroup: rbac.authorization.k8s.io diff --git a/internal/manifests/charts/kelos/templates/rbac.yaml b/internal/manifests/charts/kelos/templates/rbac.yaml index 2d1a6e1d..0adfef26 100644 --- a/internal/manifests/charts/kelos/templates/rbac.yaml +++ b/internal/manifests/charts/kelos/templates/rbac.yaml @@ -152,6 +152,49 @@ rules: - patch - update - watch + - apiGroups: + - kelos.dev + resources: + - webhookevents + verbs: + - get + - list + - watch + - apiGroups: + - kelos.dev + resources: + - webhookevents/status + verbs: + - get + - update + - patch +{{- if .Values.webhook.enabled }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kelos-webhook-receiver-role +rules: + - apiGroups: + - kelos.dev + resources: + - webhookevents + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kelos-webhook-receiver-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kelos-webhook-receiver-role +subjects: + - kind: ServiceAccount + name: kelos-webhook-receiver + namespace: kelos-system +{{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/internal/manifests/charts/kelos/templates/webhook-receiver.yaml b/internal/manifests/charts/kelos/templates/webhook-receiver.yaml new file mode 100644 index 00000000..a2fb0548 --- /dev/null +++ b/internal/manifests/charts/kelos/templates/webhook-receiver.yaml @@ -0,0 +1,166 @@ +{{- if .Values.webhook.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} +spec: + replicas: {{ .Values.webhook.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + template: + metadata: + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + spec: + serviceAccountName: kelos-webhook-receiver + securityContext: + runAsNonRoot: true + containers: + - name: receiver + image: {{ .Values.webhook.image }}{{- if .Values.image.tag }}:{{ .Values.image.tag }}{{- end }} + {{- if .Values.image.pullPolicy }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- end }} + args: + - --namespace={{ .Values.webhook.eventsNamespace }} + - --port={{ .Values.webhook.port }} + ports: + - containerPort: {{ .Values.webhook.port }} + name: http + {{- if or .Values.webhook.githubWebhookSecretName .Values.webhook.extraEnv }} + env: + {{- if .Values.webhook.githubWebhookSecretName }} + - name: GITHUB_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.webhook.githubWebhookSecretName }} + key: secret + {{- end }} + {{- range .Values.webhook.extraEnv }} + - {{ . | toYaml | nindent 14 | trim }} + {{- end }} + {{- end }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /health + port: {{ .Values.webhook.port }} + readinessProbe: + httpGet: + path: /health + port: {{ .Values.webhook.port }} + {{- if or .Values.webhook.resources.requests .Values.webhook.resources.limits }} + resources: + {{- if .Values.webhook.resources.limits }} + limits: + {{- range $k, $v := .Values.webhook.resources.limits }} + {{ $k }}: {{ $v }} + {{- end }} + {{- end }} + {{- if .Values.webhook.resources.requests }} + requests: + {{- range $k, $v := .Values.webhook.resources.requests }} + {{ $k }}: {{ $v }} + {{- end }} + {{- end }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} +spec: + selector: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + ports: + - port: {{ .Values.webhook.service.port }} + targetPort: {{ .Values.webhook.port }} + protocol: TCP + type: {{ .Values.webhook.service.type }} +{{- if .Values.webhook.ingress.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kelos-webhook-receiver + namespace: kelos-system + labels: + app.kubernetes.io/name: kelos + app.kubernetes.io/component: webhook-receiver + {{- range $k, $v := .Values.webhook.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- range $k, $v := .Values.webhook.ingress.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- if .Values.webhook.ingress.annotations }} + annotations: + {{- range $k, $v := .Values.webhook.ingress.annotations }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- end }} +spec: + {{- if .Values.webhook.ingress.ingressClassName }} + ingressClassName: {{ .Values.webhook.ingress.ingressClassName }} + {{- end }} + {{- if .Values.webhook.ingress.tls }} + tls: + {{- range .Values.webhook.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + - host: {{ .Values.webhook.ingress.host | quote }} + http: + paths: + - path: {{ .Values.webhook.ingress.path }} + pathType: Prefix + backend: + service: + name: kelos-webhook-receiver + port: + number: {{ .Values.webhook.service.port }} +{{- end }} +{{- end }} diff --git a/internal/manifests/charts/kelos/values.yaml b/internal/manifests/charts/kelos/values.yaml index f659955e..0b0cc744 100644 --- a/internal/manifests/charts/kelos/values.yaml +++ b/internal/manifests/charts/kelos/values.yaml @@ -21,3 +21,29 @@ controller: resources: requests: {} limits: {} + +webhook: + enabled: false + labels: {} + image: public.ecr.aws/anomalo/kelos/kelos-webhook-receiver + replicas: 1 + port: 8080 + service: + type: ClusterIP + port: 80 + ingress: + enabled: false + ingressClassName: "" + host: "" + path: / + labels: {} + annotations: {} + tls: [] + eventsNamespace: kelos-system + extraEnv: [] + # Name of an existing Secret containing the GitHub webhook secret + # The secret must have a key named "secret" + githubWebhookSecretName: "" + resources: + requests: {} + limits: {} diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index af40d00d..2ad6b89c 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -1448,6 +1448,39 @@ spec: rule: '!(has(self.commentPolicy) && ((has(self.triggerComment) && size(self.triggerComment) > 0) || (has(self.excludeComments) && size(self.excludeComments) > 0)))' + githubWebhook: + description: GitHubWebhook discovers issues and pull requests + from GitHub webhooks. + properties: + excludeLabels: + description: ExcludeLabels filters out issues/PRs that have + any of these labels (client-side). + items: + type: string + type: array + labels: + description: Labels filters issues/PRs by labels (applied + client-side to webhook payloads). + items: + type: string + type: array + namespace: + description: |- + Namespace is the Kubernetes namespace where WebhookEvent resources are created. + The spawner will watch for GitHub webhook events in this namespace. + type: string + reporting: + description: Reporting configures status reporting back to + GitHub. + properties: + enabled: + description: Enabled posts standard status comments back + to the originating GitHub issue or PR. + type: boolean + type: object + required: + - namespace + type: object jira: description: Jira discovers issues from a Jira project. properties: @@ -1600,6 +1633,98 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: webhookevents.kelos.dev +spec: + group: kelos.dev + names: + kind: WebhookEvent + listKind: WebhookEventList + plural: webhookevents + singular: webhookevent + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source + name: Source + type: string + - jsonPath: .status.processed + name: Processed + type: boolean + - jsonPath: .spec.receivedAt + name: ReceivedAt + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: WebhookEvent is the Schema for the webhookevents API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WebhookEventSpec defines the desired state of WebhookEvent. + properties: + payload: + description: Payload is the raw webhook payload as JSON bytes. + format: byte + type: string + receivedAt: + description: ReceivedAt is the timestamp when the webhook was received. + format: date-time + type: string + source: + description: Source is the webhook source type (e.g., "github", "slack", + "linear"). + minLength: 1 + type: string + required: + - payload + - receivedAt + - source + type: object + status: + description: WebhookEventStatus defines the observed state of WebhookEvent. + properties: + message: + description: Message provides additional information about processing. + type: string + processed: + description: Processed indicates whether this event has been processed + by a source. + type: boolean + processedAt: + description: ProcessedAt is the timestamp when the event was processed. + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.20.0 diff --git a/internal/source/github_webhook.go b/internal/source/github_webhook.go new file mode 100644 index 00000000..2e6dde0d --- /dev/null +++ b/internal/source/github_webhook.go @@ -0,0 +1,211 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// GitHubWebhookSource discovers work items from GitHub webhook events stored +// as WebhookEvent custom resources. This replaces polling the GitHub API with +// push-based webhook notifications. +type GitHubWebhookSource struct { + Client client.Client + Namespace string + + // Labels filters issues/PRs by labels (applied client-side to webhook payloads) + Labels []string + // ExcludeLabels filters out items with these labels (applied client-side) + ExcludeLabels []string +} + +// GitHubWebhookPayload represents the relevant fields from a GitHub webhook payload. +// This handles both issue and pull_request events. +type GitHubWebhookPayload struct { + Action string `json:"action"` // "opened", "reopened", "labeled", etc. + Issue *struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // "open" or "closed" + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + } `json:"issue,omitempty"` + PullRequest *struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + State string `json:"state"` // "open" or "closed" + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Head struct { + Ref string `json:"ref"` // branch name + } `json:"head"` + } `json:"pull_request,omitempty"` +} + +// Discover fetches unprocessed GitHub webhook events and converts them to WorkItems. +func (s *GitHubWebhookSource) Discover(ctx context.Context) ([]WorkItem, error) { + var eventList kelosv1alpha1.WebhookEventList + + // List all webhook events in namespace + // Field selectors are not supported by fake clients in tests, so filter client-side + if err := s.Client.List(ctx, &eventList, + client.InNamespace(s.Namespace), + ); err != nil { + return nil, fmt.Errorf("listing webhook events: %w", err) + } + + var items []WorkItem + + for i := range eventList.Items { + event := eventList.Items[i].DeepCopy() + + // Filter by source and processed status client-side + if event.Spec.Source != "github" || event.Status.Processed { + continue + } + + // Parse webhook payload + var payload GitHubWebhookPayload + if err := json.Unmarshal(event.Spec.Payload, &payload); err != nil { + // Skip malformed payloads + continue + } + + // Convert to WorkItem + item, ok := s.payloadToWorkItem(payload) + if !ok { + // Mark event as processed even if payload couldn't be converted + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + _ = s.Client.Status().Update(ctx, event) + continue + } + + // Apply label filters + if !s.matchesLabels(item.Labels) { + // Mark event as processed even if it was filtered out + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + _ = s.Client.Status().Update(ctx, event) + continue + } + + items = append(items, item) + + // Mark event as processed + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + if err := s.Client.Status().Update(ctx, event); err != nil { + // Log but continue with other events + continue + } + } + + return items, nil +} + +// payloadToWorkItem converts a GitHub webhook payload to a WorkItem. +// Returns false if the payload should be skipped. +func (s *GitHubWebhookSource) payloadToWorkItem(payload GitHubWebhookPayload) (WorkItem, bool) { + // Handle issue webhooks + if payload.Issue != nil { + issue := payload.Issue + + // Only process open issues + if issue.State != "open" { + return WorkItem{}, false + } + + labels := make([]string, len(issue.Labels)) + for i, l := range issue.Labels { + labels[i] = l.Name + } + + return WorkItem{ + ID: fmt.Sprintf("issue-%d", issue.Number), + Number: issue.Number, + Title: issue.Title, + Body: issue.Body, + URL: issue.HTMLURL, + Labels: labels, + Kind: "Issue", + }, true + } + + // Handle pull request webhooks + if payload.PullRequest != nil { + pr := payload.PullRequest + + // Only process open PRs + if pr.State != "open" { + return WorkItem{}, false + } + + labels := make([]string, len(pr.Labels)) + for i, l := range pr.Labels { + labels[i] = l.Name + } + + return WorkItem{ + ID: fmt.Sprintf("pr-%d", pr.Number), + Number: pr.Number, + Title: pr.Title, + Body: pr.Body, + URL: pr.HTMLURL, + Labels: labels, + Kind: "PR", + Branch: pr.Head.Ref, + }, true + } + + return WorkItem{}, false +} + +// matchesLabels returns true if the item matches the configured label filters. +func (s *GitHubWebhookSource) matchesLabels(itemLabels []string) bool { + // Check required labels (if configured) + if len(s.Labels) > 0 { + hasAllRequired := true + for _, required := range s.Labels { + found := false + for _, label := range itemLabels { + if label == required { + found = true + break + } + } + if !found { + hasAllRequired = false + break + } + } + if !hasAllRequired { + return false + } + } + + // Check excluded labels + for _, excluded := range s.ExcludeLabels { + for _, label := range itemLabels { + if label == excluded { + return false + } + } + } + + return true +} diff --git a/internal/source/github_webhook_test.go b/internal/source/github_webhook_test.go new file mode 100644 index 00000000..951261b4 --- /dev/null +++ b/internal/source/github_webhook_test.go @@ -0,0 +1,278 @@ +package source + +import ( + "context" + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestGitHubWebhookSource_ParseIssuePayload(t *testing.T) { + payload := []byte(`{ + "action": "opened", + "issue": { + "number": 123, + "title": "Test Issue", + "body": "Issue body", + "html_url": "https://github.com/test/repo/issues/123", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "kelos-task"} + ] + } + }`) + + var parsed GitHubWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &GitHubWebhookSource{} + item, ok := source.payloadToWorkItem(parsed) + + if !ok { + t.Fatal("Expected payload to be converted to WorkItem") + } + + if item.Number != 123 { + t.Errorf("Expected number 123, got %d", item.Number) + } + if item.Title != "Test Issue" { + t.Errorf("Expected title 'Test Issue', got %s", item.Title) + } + if item.Kind != "Issue" { + t.Errorf("Expected kind 'Issue', got %s", item.Kind) + } + if len(item.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(item.Labels)) + } +} + +func TestGitHubWebhookSource_ParsePullRequestPayload(t *testing.T) { + payload := []byte(`{ + "action": "opened", + "pull_request": { + "number": 456, + "title": "Test PR", + "body": "PR body", + "html_url": "https://github.com/test/repo/pull/456", + "state": "open", + "labels": [ + {"name": "enhancement"} + ], + "head": { + "ref": "feature-branch" + } + } + }`) + + var parsed GitHubWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &GitHubWebhookSource{} + item, ok := source.payloadToWorkItem(parsed) + + if !ok { + t.Fatal("Expected payload to be converted to WorkItem") + } + + if item.Number != 456 { + t.Errorf("Expected number 456, got %d", item.Number) + } + if item.Kind != "PR" { + t.Errorf("Expected kind 'PR', got %s", item.Kind) + } + if item.Branch != "feature-branch" { + t.Errorf("Expected branch 'feature-branch', got %s", item.Branch) + } +} + +func TestGitHubWebhookSource_SkipClosedIssues(t *testing.T) { + payload := []byte(`{ + "action": "closed", + "issue": { + "number": 123, + "title": "Closed Issue", + "state": "closed" + } + }`) + + var parsed GitHubWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &GitHubWebhookSource{} + _, ok := source.payloadToWorkItem(parsed) + + if ok { + t.Error("Expected closed issue to be skipped") + } +} + +func TestGitHubWebhookSource_LabelFiltering(t *testing.T) { + tests := []struct { + name string + itemLabels []string + requiredLabels []string + excludeLabels []string + expectedMatch bool + }{ + { + name: "No filters - matches", + itemLabels: []string{"bug"}, + requiredLabels: nil, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label present", + itemLabels: []string{"bug", "kelos-task"}, + requiredLabels: []string{"kelos-task"}, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label missing", + itemLabels: []string{"bug"}, + requiredLabels: []string{"kelos-task"}, + excludeLabels: nil, + expectedMatch: false, + }, + { + name: "Excluded label present", + itemLabels: []string{"bug", "skip"}, + requiredLabels: nil, + excludeLabels: []string{"skip"}, + expectedMatch: false, + }, + { + name: "Multiple required labels", + itemLabels: []string{"bug", "kelos-task", "high-priority"}, + requiredLabels: []string{"kelos-task", "high-priority"}, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required present but also excluded", + itemLabels: []string{"kelos-task", "skip"}, + requiredLabels: []string{"kelos-task"}, + excludeLabels: []string{"skip"}, + expectedMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := &GitHubWebhookSource{ + Labels: tt.requiredLabels, + ExcludeLabels: tt.excludeLabels, + } + + matches := source.matchesLabels(tt.itemLabels) + if matches != tt.expectedMatch { + t.Errorf("Expected match=%v, got %v", tt.expectedMatch, matches) + } + }) + } +} + +func TestGitHubWebhookSource_Discover(t *testing.T) { + scheme := runtime.NewScheme() + _ = kelosv1alpha1.AddToScheme(scheme) + + // Create fake client with webhook events + event1 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-1", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 100, + "title": "First Issue", + "body": "Body", + "html_url": "https://github.com/test/repo/issues/100", + "state": "open", + "labels": [{"name": "bug"}] + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + event2 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-2", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 200, + "title": "Second Issue", + "state": "closed" + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(event1, event2). + WithStatusSubresource(&kelosv1alpha1.WebhookEvent{}). + Build() + + source := &GitHubWebhookSource{ + Client: fakeClient, + Namespace: "default", + } + + items, err := source.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + + // Should only get event1 (event2 has closed issue) + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } + + if len(items) > 0 && items[0].Number != 100 { + t.Errorf("Expected issue 100, got %d", items[0].Number) + } + + // Verify events were marked as processed + var updatedEvent kelosv1alpha1.WebhookEvent + if err := fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "event-1", + Namespace: "default", + }, &updatedEvent); err != nil { + t.Fatalf("Failed to get updated event: %v", err) + } + + if !updatedEvent.Status.Processed { + t.Error("Expected event to be marked as processed") + } +} diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go index d1460567..f82097cf 100644 --- a/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/api_client.go @@ -31,6 +31,7 @@ type ApiV1alpha1Interface interface { AgentConfigsGetter TasksGetter TaskSpawnersGetter + WebhookEventsGetter WorkspacesGetter } @@ -51,6 +52,10 @@ func (c *ApiV1alpha1Client) TaskSpawners(namespace string) TaskSpawnerInterface return newTaskSpawners(c, namespace) } +func (c *ApiV1alpha1Client) WebhookEvents(namespace string) WebhookEventInterface { + return newWebhookEvents(c, namespace) +} + func (c *ApiV1alpha1Client) Workspaces(namespace string) WorkspaceInterface { return newWorkspaces(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go index bb808159..b08ae727 100644 --- a/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go @@ -40,6 +40,10 @@ func (c *FakeApiV1alpha1) TaskSpawners(namespace string) v1alpha1.TaskSpawnerInt return newFakeTaskSpawners(c, namespace) } +func (c *FakeApiV1alpha1) WebhookEvents(namespace string) v1alpha1.WebhookEventInterface { + return newFakeWebhookEvents(c, namespace) +} + func (c *FakeApiV1alpha1) Workspaces(namespace string) v1alpha1.WorkspaceInterface { return newFakeWorkspaces(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go new file mode 100644 index 00000000..72fdd1fc --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/fake/fake_webhookevent.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + apiv1alpha1 "github.com/kelos-dev/kelos/pkg/generated/clientset/versioned/typed/api/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeWebhookEvents implements WebhookEventInterface +type fakeWebhookEvents struct { + *gentype.FakeClientWithList[*v1alpha1.WebhookEvent, *v1alpha1.WebhookEventList] + Fake *FakeApiV1alpha1 +} + +func newFakeWebhookEvents(fake *FakeApiV1alpha1, namespace string) apiv1alpha1.WebhookEventInterface { + return &fakeWebhookEvents{ + gentype.NewFakeClientWithList[*v1alpha1.WebhookEvent, *v1alpha1.WebhookEventList]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("webhookevents"), + v1alpha1.SchemeGroupVersion.WithKind("WebhookEvent"), + func() *v1alpha1.WebhookEvent { return &v1alpha1.WebhookEvent{} }, + func() *v1alpha1.WebhookEventList { return &v1alpha1.WebhookEventList{} }, + func(dst, src *v1alpha1.WebhookEventList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.WebhookEventList) []*v1alpha1.WebhookEvent { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.WebhookEventList, items []*v1alpha1.WebhookEvent) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index 57b3ddd3..d67e10b8 100644 --- a/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/generated_expansion.go @@ -24,4 +24,6 @@ type TaskExpansion interface{} type TaskSpawnerExpansion interface{} +type WebhookEventExpansion interface{} + type WorkspaceExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go b/pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go new file mode 100644 index 00000000..5529d03d --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/api/v1alpha1/webhookevent.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + apiv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + scheme "github.com/kelos-dev/kelos/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// WebhookEventsGetter has a method to return a WebhookEventInterface. +// A group's client should implement this interface. +type WebhookEventsGetter interface { + WebhookEvents(namespace string) WebhookEventInterface +} + +// WebhookEventInterface has methods to work with WebhookEvent resources. +type WebhookEventInterface interface { + Create(ctx context.Context, webhookEvent *apiv1alpha1.WebhookEvent, opts v1.CreateOptions) (*apiv1alpha1.WebhookEvent, error) + Update(ctx context.Context, webhookEvent *apiv1alpha1.WebhookEvent, opts v1.UpdateOptions) (*apiv1alpha1.WebhookEvent, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, webhookEvent *apiv1alpha1.WebhookEvent, opts v1.UpdateOptions) (*apiv1alpha1.WebhookEvent, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.WebhookEvent, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.WebhookEventList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.WebhookEvent, err error) + WebhookEventExpansion +} + +// webhookEvents implements WebhookEventInterface +type webhookEvents struct { + *gentype.ClientWithList[*apiv1alpha1.WebhookEvent, *apiv1alpha1.WebhookEventList] +} + +// newWebhookEvents returns a WebhookEvents +func newWebhookEvents(c *ApiV1alpha1Client, namespace string) *webhookEvents { + return &webhookEvents{ + gentype.NewClientWithList[*apiv1alpha1.WebhookEvent, *apiv1alpha1.WebhookEventList]( + "webhookevents", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha1.WebhookEvent { return &apiv1alpha1.WebhookEvent{} }, + func() *apiv1alpha1.WebhookEventList { return &apiv1alpha1.WebhookEventList{} }, + ), + } +} diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go index 7e166f1b..177a8f8b 100644 --- a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go +++ b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go @@ -30,6 +30,8 @@ type Interface interface { Tasks() TaskInformer // TaskSpawners returns a TaskSpawnerInformer. TaskSpawners() TaskSpawnerInformer + // WebhookEvents returns a WebhookEventInformer. + WebhookEvents() WebhookEventInformer // Workspaces returns a WorkspaceInformer. Workspaces() WorkspaceInformer } @@ -60,6 +62,11 @@ func (v *version) TaskSpawners() TaskSpawnerInformer { return &taskSpawnerInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// WebhookEvents returns a WebhookEventInformer. +func (v *version) WebhookEvents() WebhookEventInformer { + return &webhookEventInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // Workspaces returns a WorkspaceInformer. func (v *version) Workspaces() WorkspaceInformer { return &workspaceInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go b/pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go new file mode 100644 index 00000000..7492c2cb --- /dev/null +++ b/pkg/generated/informers/externalversions/api/v1alpha1/webhookevent.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + kelosapiv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + versioned "github.com/kelos-dev/kelos/pkg/generated/clientset/versioned" + internalinterfaces "github.com/kelos-dev/kelos/pkg/generated/informers/externalversions/internalinterfaces" + apiv1alpha1 "github.com/kelos-dev/kelos/pkg/generated/listers/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// WebhookEventInformer provides access to a shared informer and lister for +// WebhookEvents. +type WebhookEventInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.WebhookEventLister +} + +type webhookEventInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewWebhookEventInformer constructs a new informer for WebhookEvent type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewWebhookEventInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredWebhookEventInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredWebhookEventInformer constructs a new informer for WebhookEvent type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredWebhookEventInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApiV1alpha1().WebhookEvents(namespace).Watch(ctx, options) + }, + }, client), + &kelosapiv1alpha1.WebhookEvent{}, + resyncPeriod, + indexers, + ) +} + +func (f *webhookEventInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredWebhookEventInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *webhookEventInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&kelosapiv1alpha1.WebhookEvent{}, f.defaultInformer) +} + +func (f *webhookEventInformer) Lister() apiv1alpha1.WebhookEventLister { + return apiv1alpha1.NewWebhookEventLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 2a4670ac..411a17c0 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -59,6 +59,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().Tasks().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("taskspawners"): return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().TaskSpawners().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("webhookevents"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().WebhookEvents().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("workspaces"): return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().Workspaces().Informer()}, nil diff --git a/pkg/generated/listers/api/v1alpha1/expansion_generated.go b/pkg/generated/listers/api/v1alpha1/expansion_generated.go index eea188a9..511044fd 100644 --- a/pkg/generated/listers/api/v1alpha1/expansion_generated.go +++ b/pkg/generated/listers/api/v1alpha1/expansion_generated.go @@ -42,6 +42,14 @@ type TaskSpawnerListerExpansion interface{} // TaskSpawnerNamespaceLister. type TaskSpawnerNamespaceListerExpansion interface{} +// WebhookEventListerExpansion allows custom methods to be added to +// WebhookEventLister. +type WebhookEventListerExpansion interface{} + +// WebhookEventNamespaceListerExpansion allows custom methods to be added to +// WebhookEventNamespaceLister. +type WebhookEventNamespaceListerExpansion interface{} + // WorkspaceListerExpansion allows custom methods to be added to // WorkspaceLister. type WorkspaceListerExpansion interface{} diff --git a/pkg/generated/listers/api/v1alpha1/webhookevent.go b/pkg/generated/listers/api/v1alpha1/webhookevent.go new file mode 100644 index 00000000..7ec537c0 --- /dev/null +++ b/pkg/generated/listers/api/v1alpha1/webhookevent.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Gunju Kim + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + apiv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// WebhookEventLister helps list WebhookEvents. +// All objects returned here must be treated as read-only. +type WebhookEventLister interface { + // List lists all WebhookEvents in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.WebhookEvent, err error) + // WebhookEvents returns an object that can list and get WebhookEvents. + WebhookEvents(namespace string) WebhookEventNamespaceLister + WebhookEventListerExpansion +} + +// webhookEventLister implements the WebhookEventLister interface. +type webhookEventLister struct { + listers.ResourceIndexer[*apiv1alpha1.WebhookEvent] +} + +// NewWebhookEventLister returns a new WebhookEventLister. +func NewWebhookEventLister(indexer cache.Indexer) WebhookEventLister { + return &webhookEventLister{listers.New[*apiv1alpha1.WebhookEvent](indexer, apiv1alpha1.Resource("webhookevent"))} +} + +// WebhookEvents returns an object that can list and get WebhookEvents. +func (s *webhookEventLister) WebhookEvents(namespace string) WebhookEventNamespaceLister { + return webhookEventNamespaceLister{listers.NewNamespaced[*apiv1alpha1.WebhookEvent](s.ResourceIndexer, namespace)} +} + +// WebhookEventNamespaceLister helps list and get WebhookEvents. +// All objects returned here must be treated as read-only. +type WebhookEventNamespaceLister interface { + // List lists all WebhookEvents in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.WebhookEvent, err error) + // Get retrieves the WebhookEvent from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.WebhookEvent, error) + WebhookEventNamespaceListerExpansion +} + +// webhookEventNamespaceLister implements the WebhookEventNamespaceLister +// interface. +type webhookEventNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha1.WebhookEvent] +} diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 73d58536..6562f2af 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -117,6 +117,9 @@ var _ = BeforeSuite(func() { Eventually(func() error { return k8sClient.List(ctx, &kelosv1alpha1.WorkspaceList{}) }, 30*time.Second, 100*time.Millisecond).Should(Succeed()) + Eventually(func() error { + return k8sClient.List(ctx, &kelosv1alpha1.WebhookEventList{}) + }, 30*time.Second, 100*time.Millisecond).Should(Succeed()) }) var _ = AfterSuite(func() { diff --git a/test/integration/webhook_test.go b/test/integration/webhook_test.go new file mode 100644 index 00000000..aaed584b --- /dev/null +++ b/test/integration/webhook_test.go @@ -0,0 +1,456 @@ +package integration + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/source" +) + +var _ = Describe("Webhook Integration", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When receiving GitHub webhook events", func() { + It("Should discover and process WebhookEvent CRDs", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an open issue") + issuePayload := []byte(`{ + "action": "opened", + "issue": { + "number": 42, + "title": "Test Issue", + "body": "This is a test issue", + "html_url": "https://github.com/test/repo/issues/42", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "kelos-task"} + ] + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: issuePayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + Labels: []string{"kelos-task"}, + } + + By("Discovering work items from the webhook") + var items []source.WorkItem + Eventually(func() int { + var err error + items, err = webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return len(items) + }, timeout, interval).Should(Equal(1)) + + By("Verifying the discovered work item") + Expect(items[0].Number).To(Equal(42)) + Expect(items[0].Title).To(Equal("Test Issue")) + Expect(items[0].Kind).To(Equal("Issue")) + Expect(items[0].Labels).To(ContainElement("bug")) + Expect(items[0].Labels).To(ContainElement("kelos-task")) + + By("Verifying the WebhookEvent was marked as processed") + updatedEvent := &kelosv1alpha1.WebhookEvent{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + if err != nil { + return false + } + return updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Expect(updatedEvent.Status.ProcessedAt).NotTo(BeNil()) + + By("Verifying subsequent discoveries return no items (already processed)") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(items).To(BeEmpty()) + }) + + It("Should filter WebhookEvents by labels", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-filter-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an issue without required label") + eventWithoutLabel := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 100, + "title": "Issue Without Label", + "state": "open", + "labels": [{"name": "bug"}] + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, eventWithoutLabel)).Should(Succeed()) + + By("Creating a WebhookEvent for an issue with required label") + eventWithLabel := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 200, + "title": "Issue With Label", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "kelos-task"} + ] + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, eventWithLabel)).Should(Succeed()) + + By("Creating a GitHubWebhookSource with label filter") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + Labels: []string{"kelos-task"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the issue with the required label was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(200)) + + By("Verifying both events were marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: eventWithLabel.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: eventWithoutLabel.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should handle pull request webhooks", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-pr-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an open pull request") + prPayload := []byte(`{ + "action": "opened", + "pull_request": { + "number": 123, + "title": "Test PR", + "body": "This is a test PR", + "html_url": "https://github.com/test/repo/pull/123", + "state": "open", + "labels": [ + {"name": "enhancement"} + ], + "head": { + "ref": "feature-branch" + } + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: prPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering the pull request") + var items []source.WorkItem + Eventually(func() int { + var err error + items, err = webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return len(items) + }, timeout, interval).Should(Equal(1)) + + By("Verifying the discovered work item") + Expect(items[0].Number).To(Equal(123)) + Expect(items[0].Title).To(Equal("Test PR")) + Expect(items[0].Kind).To(Equal("PR")) + Expect(items[0].Branch).To(Equal("feature-branch")) + Expect(items[0].Labels).To(ContainElement("enhancement")) + }) + + It("Should skip closed issues and pull requests", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-closed-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for a closed issue") + closedPayload := []byte(`{ + "action": "closed", + "issue": { + "number": 999, + "title": "Closed Issue", + "state": "closed", + "labels": [] + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: closedPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying no items were discovered") + Expect(items).To(BeEmpty()) + + By("Verifying the event was still marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should handle excludeLabels filter", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-exclude-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for an issue with excluded label") + excludedPayload := []byte(`{ + "action": "opened", + "issue": { + "number": 300, + "title": "Excluded Issue", + "state": "open", + "labels": [ + {"name": "bug"}, + {"name": "skip"} + ] + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: excludedPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a GitHubWebhookSource with excludeLabels") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + ExcludeLabels: []string{"skip"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the issue was filtered out") + Expect(items).To(BeEmpty()) + + By("Verifying the event was still marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should only process events with source=github", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-webhook-source-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent with source=slack") + slackEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "slack-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "slack", + Payload: []byte(`{"event": "message"}`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, slackEvent)).Should(Succeed()) + + By("Creating a WebhookEvent with source=github") + githubEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": { + "number": 500, + "title": "GitHub Issue", + "state": "open", + "labels": [] + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, githubEvent)).Should(Succeed()) + + By("Creating a GitHubWebhookSource") + webhookSource := &source.GitHubWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the GitHub event was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(500)) + + By("Verifying only the GitHub event was marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: githubEvent.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + slackUpdated := &kelosv1alpha1.WebhookEvent{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: slackEvent.Name, + Namespace: ns.Name, + }, slackUpdated) + Expect(err).NotTo(HaveOccurred()) + Expect(slackUpdated.Status.Processed).To(BeFalse()) + }) + }) +}) From 2b0138f4cfe77ccb57cfc6d53699ffddd1204707 Mon Sep 17 00:00:00 2001 From: Tim Marshall Date: Thu, 26 Mar 2026 11:43:06 -0500 Subject: [PATCH 25/26] feat: add Linear webhook support (#16) * session: add webhook support plan * feat: add WebhookEvent CRD type definition Defines WebhookEvent custom resource for storing incoming webhooks. Uses CRD-based queue pattern consistent with Kelos architecture. Note: Requires 'make update' in Go environment to generate deepcopy and CRD manifests. * feat: add webhook receiver HTTP server Implements HTTP endpoint at /webhook/:source that: - Receives webhook payloads - Validates GitHub signatures via HMAC-SHA256 - Creates WebhookEvent CRD instances - Returns 202 Accepted Supports multiple sources (github, slack, linear, etc.) via URL path. * feat: add GitHub webhook source implementation Implements Source interface for webhook-based discovery: - Reads unprocessed WebhookEvent CRDs with source=github - Parses GitHub issue and pull_request webhook payloads - Converts to WorkItem format - Marks events as processed after discovery - Supports label-based filtering (client-side) * feat: add GitHubWebhook to TaskSpawner When options Adds githubWebhook field to TaskSpawner CRD allowing webhook-based discovery as an alternative to API polling. The spawner will watch WebhookEvent resources in the specified namespace and convert GitHub webhook payloads to tasks. * feat: integrate GitHubWebhookSource into spawner Updates spawner to create GitHubWebhookSource instances when TaskSpawner has githubWebhook configured. Passes k8s client to source so it can list and update WebhookEvent resources. * docs: add webhook documentation and examples Includes: - Complete TaskSpawner example using githubWebhook - Webhook receiver deployment manifests - Architecture documentation explaining CRD-based queue - GitHub webhook setup instructions - Comparison with API polling approach * session: update plan with completion status Marks completed phases and notes what still needs to be done: - CRD manifest generation (needs Go environment) - Unit and integration tests - Live cluster testing * test: add unit tests for webhook receiver and source Webhook receiver tests: - Valid/invalid GitHub signature validation - Missing signature header handling - No secret configured (dev mode) - HTTP method validation - Missing source in path GitHub webhook source tests: - Issue payload parsing - Pull request payload parsing - Closed issues/PRs are skipped - Label filtering (required, excluded, multiple) - End-to-end Discover with fake k8s client - Events marked as processed after discovery * session: mark unit tests as complete in plan * fix: resolve test compilation errors - Fix logger type in webhook receiver (logr.Logger) - Add missing parameters to buildSource calls in tests - Remove field selectors for fake client compatibility Co-Authored-By: Claude Sonnet 4.5 * fix: correct test expectations - Fix HMAC-SHA256 signature in webhook receiver test - Enable status subresource in fake client for webhook source test Co-Authored-By: Claude Sonnet 4.5 * make update * feat: add RBAC permissions for WebhookEvent resources - Add webhookevents permissions to kelos-spawner-role - Create kelos-webhook-receiver-role for webhook receiver Co-Authored-By: Claude Sonnet 4.5 * docs: focus webhook documentation on GitHub support - Remove mentions of future sources (Slack, Linear, etc.) - Remove comparison table with API polling - Keep documentation focused on current implementation Co-Authored-By: Claude Sonnet 4.5 * test: add integration tests for webhook flow - Test WebhookEvent discovery and processing - Test label filtering (required and excluded labels) - Test issue and pull request payloads - Test source filtering (github vs other sources) - Test skipping closed issues/PRs - Verify events are marked as processed after discovery Co-Authored-By: Claude Sonnet 4.5 * docs: update webhook support plan Mark all tasks as completed: - RBAC permissions added - Integration tests implemented - All tests passing Co-Authored-By: Claude Sonnet 4.5 * session: remove webhook support plan * fix: use DeepCopy for status updates in webhook discovery - Change from &eventList.Items[i] to DeepCopy() to fix status updates - Mark events as processed even when filtered out or invalid - Ensures all processed events have status updated in integration tests Co-Authored-By: Claude Sonnet 4.5 * session: add Linear webhook support plan * feat: add Linear webhook signature validation - Add validateLinearSignature() function with HMAC-SHA256 - Optional validation via LINEAR_WEBHOOK_SECRET env var - Validate X-Linear-Signature header (no sha256= prefix) - Add unit tests for valid/invalid/missing signatures Co-Authored-By: Claude Sonnet 4.5 * feat: implement LinearWebhookSource for webhook discovery - Add LinearWebhookSource implementing Source interface - Parse Linear Issue webhooks (create/update actions) - Filter by states, labels, and excludeLabels - Exclude terminal states (completed/canceled) by default - Use DeepCopy pattern for status updates - Add comprehensive unit tests Co-Authored-By: Claude Sonnet 4.5 * feat: add LinearWebhook type to TaskSpawner CRD - Add LinearWebhook struct with namespace, states, labels filters - Add When.LinearWebhook field for webhook-based Linear discovery - Mirror GitHub webhook pattern for consistency Co-Authored-By: Claude Sonnet 4.5 * feat: add LinearWebhook support to spawner - Extend buildSource() to create LinearWebhookSource - Pass k8s client, namespace, and filters to source Co-Authored-By: Claude Sonnet 4.5 * test: add integration tests for Linear webhook flow * docs: add Linear webhook documentation and example configuration * session: note Go environment requirements for Phase 7 * session: remove pr-session plan * chore: make update * fix; passing tests --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Hans Knecht --- api/v1alpha1/taskspawner_types.go | 28 ++ api/v1alpha1/zz_generated.deepcopy.go | 35 ++ cmd/kelos-spawner/main.go | 11 + cmd/kelos-webhook-receiver/main.go | 36 ++ cmd/kelos-webhook-receiver/main_test.go | 58 +++ docs/webhooks.md | 166 +++++++- examples/taskspawner-linear-webhook.yaml | 145 +++++++ internal/manifests/install-crd.yaml | 30 ++ internal/source/linear_webhook.go | 209 +++++++++++ internal/source/linear_webhook_test.go | 433 +++++++++++++++++++++ test/integration/linear_webhook_test.go | 458 +++++++++++++++++++++++ 11 files changed, 1607 insertions(+), 2 deletions(-) create mode 100644 examples/taskspawner-linear-webhook.yaml create mode 100644 internal/source/linear_webhook.go create mode 100644 internal/source/linear_webhook_test.go create mode 100644 test/integration/linear_webhook_test.go diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index 89f3b4a1..88f3439c 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -33,6 +33,10 @@ type When struct { // +optional GitHubWebhook *GitHubWebhook `json:"githubWebhook,omitempty"` + // LinearWebhook discovers issues from Linear webhooks. + // +optional + LinearWebhook *LinearWebhook `json:"linearWebhook,omitempty"` + // Cron triggers task spawning on a cron schedule. // +optional Cron *Cron `json:"cron,omitempty"` @@ -287,6 +291,30 @@ type GitHubWebhook struct { Reporting *GitHubReporting `json:"reporting,omitempty"` } +// LinearWebhook discovers issues from Linear webhooks. +// Linear webhooks must be configured to POST to the kelos-webhook-receiver +// endpoint at /webhook/linear. The webhook receiver creates WebhookEvent +// CRDs that the spawner processes. +type LinearWebhook struct { + // Namespace is the Kubernetes namespace where WebhookEvent resources are created. + // The spawner will watch for Linear webhook events in this namespace. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` + + // States filters issues by workflow state names (e.g., ["Todo", "In Progress"]). + // When empty, all non-terminal states are processed (excludes "Done", "Canceled"). + // +optional + States []string `json:"states,omitempty"` + + // Labels filters issues by labels (applied client-side to webhook payloads). + // +optional + Labels []string `json:"labels,omitempty"` + + // ExcludeLabels filters out issues that have any of these labels (client-side). + // +optional + ExcludeLabels []string `json:"excludeLabels,omitempty"` +} + // Jira discovers issues from a Jira project. // Authentication is provided via a Secret referenced in the TaskSpawner's // namespace. The secret must contain a "JIRA_TOKEN" key. For Jira Cloud, diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 07755166..ed75683b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -389,6 +389,36 @@ func (in *Jira) DeepCopy() *Jira { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinearWebhook) DeepCopyInto(out *LinearWebhook) { + *out = *in + if in.States != nil { + in, out := &in.States, &out.States + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeLabels != nil { + in, out := &in.ExcludeLabels, &out.ExcludeLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinearWebhook. +func (in *LinearWebhook) DeepCopy() *LinearWebhook { + if in == nil { + return nil + } + out := new(LinearWebhook) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = *in @@ -968,6 +998,11 @@ func (in *When) DeepCopyInto(out *When) { *out = new(GitHubWebhook) (*in).DeepCopyInto(*out) } + if in.LinearWebhook != nil { + in, out := &in.LinearWebhook, &out.LinearWebhook + *out = new(LinearWebhook) + (*in).DeepCopyInto(*out) + } if in.Cron != nil { in, out := &in.Cron, &out.Cron *out = new(Cron) diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 5b03f4d4..b91408f9 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -579,6 +579,17 @@ func buildSource(ts *kelosv1alpha1.TaskSpawner, owner, repo, apiBaseURL, tokenFi }, nil } + if ts.Spec.When.LinearWebhook != nil { + webhook := ts.Spec.When.LinearWebhook + return &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: webhook.Namespace, + States: webhook.States, + Labels: webhook.Labels, + ExcludeLabels: webhook.ExcludeLabels, + }, nil + } + if ts.Spec.When.Jira != nil { user := os.Getenv("JIRA_USER") token := os.Getenv("JIRA_TOKEN") diff --git a/cmd/kelos-webhook-receiver/main.go b/cmd/kelos-webhook-receiver/main.go index 881bfbad..aa652865 100644 --- a/cmd/kelos-webhook-receiver/main.go +++ b/cmd/kelos-webhook-receiver/main.go @@ -123,6 +123,15 @@ func (h *webhookHandler) handle(w http.ResponseWriter, r *http.Request) { } } + // Validate signature for Linear webhooks + if source == "linear" { + if err := validateLinearSignature(r.Header, body); err != nil { + h.log.Error(err, "Linear signature validation failed") + http.Error(w, "Signature validation failed", http.StatusUnauthorized) + return + } + } + // Create WebhookEvent CRD event := &kelosv1alpha1.WebhookEvent{ ObjectMeta: metav1.ObjectMeta{ @@ -175,3 +184,30 @@ func validateGitHubSignature(headers http.Header, payload []byte) error { return nil } + +// validateLinearSignature validates the X-Linear-Signature header against the payload. +// The secret is read from the LINEAR_WEBHOOK_SECRET environment variable. +func validateLinearSignature(headers http.Header, payload []byte) error { + signature := headers.Get("X-Linear-Signature") + if signature == "" { + return fmt.Errorf("missing X-Linear-Signature header") + } + + secret := os.Getenv("LINEAR_WEBHOOK_SECRET") + if secret == "" { + // If no secret is configured, skip validation (development mode) + return nil + } + + // Compute expected signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + expectedSignature := hex.EncodeToString(expectedMAC) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + return fmt.Errorf("signature mismatch") + } + + return nil +} diff --git a/cmd/kelos-webhook-receiver/main_test.go b/cmd/kelos-webhook-receiver/main_test.go index b503b13a..46e1aebd 100644 --- a/cmd/kelos-webhook-receiver/main_test.go +++ b/cmd/kelos-webhook-receiver/main_test.go @@ -91,3 +91,61 @@ func TestWebhookHandler_MissingSource(t *testing.T) { t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) } } + +func TestValidateLinearSignature_ValidSignature(t *testing.T) { + t.Setenv("LINEAR_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"create","type":"Issue"}`) + + // Compute expected signature + // echo -n '{"action":"create","type":"Issue"}' | openssl dgst -sha256 -hmac 'test-secret' + expectedSig := "3b4c0e7668708bcb65b6103de3d28cae0bead64460615aaa232f645b96568741" + + headers := http.Header{} + headers.Set("X-Linear-Signature", expectedSig) + + err := validateLinearSignature(headers, payload) + if err != nil { + t.Errorf("Expected valid signature, got error: %v", err) + } +} + +func TestValidateLinearSignature_InvalidSignature(t *testing.T) { + t.Setenv("LINEAR_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"create","type":"Issue"}`) + + headers := http.Header{} + headers.Set("X-Linear-Signature", "wrongsignature") + + err := validateLinearSignature(headers, payload) + if err == nil { + t.Error("Expected error for invalid signature, got nil") + } +} + +func TestValidateLinearSignature_MissingHeader(t *testing.T) { + t.Setenv("LINEAR_WEBHOOK_SECRET", "test-secret") + + payload := []byte(`{"action":"create","type":"Issue"}`) + headers := http.Header{} + + err := validateLinearSignature(headers, payload) + if err == nil { + t.Error("Expected error for missing signature header, got nil") + } +} + +func TestValidateLinearSignature_NoSecretConfigured(t *testing.T) { + // Don't set LINEAR_WEBHOOK_SECRET - should skip validation + t.Setenv("LINEAR_WEBHOOK_SECRET", "") + + payload := []byte(`{"action":"create","type":"Issue"}`) + headers := http.Header{} + headers.Set("X-Linear-Signature", "anysignature") + + err := validateLinearSignature(headers, payload) + if err != nil { + t.Errorf("Expected no error when secret not configured, got: %v", err) + } +} diff --git a/docs/webhooks.md b/docs/webhooks.md index 732fca89..aea3e936 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,6 +1,10 @@ -# GitHub Webhook Support +# Webhook Support -Kelos supports GitHub webhooks for work item discovery. Instead of polling the GitHub API, Kelos can receive push notifications when issues or pull requests are created or updated. +Kelos supports webhooks for work item discovery. Instead of polling external APIs, Kelos can receive push notifications when issues or pull requests are created or updated. + +Supported webhook sources: +- **GitHub**: Issues and Pull Requests +- **Linear**: Issues ## Architecture @@ -103,3 +107,161 @@ env: ``` If the secret is not set, signature validation is skipped (development mode only). + +--- + +# Linear Webhook Support + +Kelos supports Linear webhooks for work item discovery. Instead of polling the Linear API, Kelos can receive push notifications when issues are created or updated. + +## Architecture + +The Linear webhook system uses the same architecture as GitHub webhooks: + +### 1. WebhookEvent CRD + +Webhook payloads are stored as `WebhookEvent` custom resources with `source: linear`. + +### 2. Webhook Receiver (kelos-webhook-receiver) + +The HTTP server: +- Listens on `/webhook/linear` +- Validates Linear webhook signatures (HMAC-SHA256) +- Creates `WebhookEvent` CRD instances with `source: linear` +- Returns 202 Accepted + +### 3. LinearWebhookSource + +The `LinearWebhookSource` implementation: +- Lists unprocessed `WebhookEvent` resources with `source: linear` +- Parses Linear webhook payloads (Issue create/update events) +- Applies filters (states, labels, excludeLabels) +- Excludes terminal states (completed, canceled) by default +- Marks events as processed + +## Linear Webhook Setup + +### 1. Deploy the webhook receiver + +The same webhook receiver handles both GitHub and Linear webhooks: + +```bash +kubectl apply -f examples/taskspawner-linear-webhook.yaml +``` + +This creates: +- `kelos-webhook-receiver` Deployment +- LoadBalancer Service +- RBAC for creating WebhookEvent resources + +### 2. Get the external URL + +```bash +kubectl get service kelos-webhook-receiver -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +``` + +### 3. Configure Linear webhook + +In your Linear workspace settings: +1. Go to Settings → API → Webhooks +2. Click "Create webhook" +3. **URL**: `http:///webhook/linear` +4. **Secret**: Set a secret and store it in the `linear-webhook-secret` Secret +5. **Events**: Select "Issue" events (create, update) +6. Click "Create" + +### 4. Create a TaskSpawner with linearWebhook + +```yaml +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: my-linear-spawner +spec: + when: + linearWebhook: + namespace: default + states: + - "Todo" + - "In Progress" + labels: + - "kelos-task" + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + {{ .Body }} +``` + +## Linear Webhook Configuration + +### State Filtering + +Control which Linear issue states are processed: + +```yaml +when: + linearWebhook: + namespace: default + states: + - "Todo" + - "In Progress" +``` + +**Default behavior** (when `states` is not specified): +- Accepts all non-terminal states +- Excludes `completed` and `canceled` states + +### Label Filtering + +Require specific labels: + +```yaml +when: + linearWebhook: + namespace: default + labels: + - "bug" + - "high-priority" +``` + +Exclude specific labels: + +```yaml +when: + linearWebhook: + namespace: default + excludeLabels: + - "wont-fix" +``` + +## Webhook Signature Validation + +The receiver validates the `X-Linear-Signature` header using HMAC-SHA256. + +Set the `LINEAR_WEBHOOK_SECRET` environment variable to enable validation: + +```yaml +env: +- name: LINEAR_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: linear-webhook-secret + key: secret +``` + +If the secret is not set, signature validation is skipped (development mode only). + +## Event Types + +Linear webhook events include: +- **Issue create**: New issues matching your filters will be discovered +- **Issue update**: State changes and label updates will re-trigger discovery + +Only Issue events are processed. Comment and other event types are ignored. diff --git a/examples/taskspawner-linear-webhook.yaml b/examples/taskspawner-linear-webhook.yaml new file mode 100644 index 00000000..49415a60 --- /dev/null +++ b/examples/taskspawner-linear-webhook.yaml @@ -0,0 +1,145 @@ +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: example-linear-webhook + namespace: default +spec: + when: + linearWebhook: + # Namespace where kelos-webhook-receiver creates WebhookEvent resources + namespace: default + # Optional: filter by Linear issue states + # If not specified, all non-terminal states are accepted (excludes 'completed' and 'canceled') + states: + - "Todo" + - "In Progress" + # Optional: filter by labels (applied client-side to webhook payloads) + labels: + - "kelos-task" + # Optional: exclude items with these labels + excludeLabels: + - "skip-kelos" + + taskTemplate: + type: claude-code + credentials: + type: api-key + secretRef: + name: anthropic-api-key + workspaceRef: + name: my-workspace + promptTemplate: | + {{ .Title }} + + {{ .Body }} + + Please investigate and resolve this {{ .Kind }}. + +--- +# Webhook receiver deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: kelos-webhook-receiver + template: + metadata: + labels: + app: kelos-webhook-receiver + spec: + serviceAccountName: kelos-webhook-receiver + containers: + - name: receiver + image: ghcr.io/kelos-dev/kelos-webhook-receiver:latest + args: + - --namespace=default + - --port=8080 + ports: + - containerPort: 8080 + name: http + env: + # Optional: Linear webhook secret for signature validation + # If not set, signature validation is skipped (development mode only) + - name: LINEAR_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: linear-webhook-secret + key: secret + livenessProbe: + httpGet: + path: /health + port: 8080 + readinessProbe: + httpGet: + path: /health + port: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: kelos-webhook-receiver + namespace: default +spec: + selector: + app: kelos-webhook-receiver + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: LoadBalancer + +--- +# ServiceAccount and RBAC for webhook receiver +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kelos-webhook-receiver + namespace: default + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kelos-webhook-receiver + namespace: default +rules: + - apiGroups: ["kelos.dev"] + resources: ["webhookevents"] + verbs: ["create", "get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kelos-webhook-receiver + namespace: default +subjects: + - kind: ServiceAccount + name: kelos-webhook-receiver + namespace: default +roleRef: + kind: Role + name: kelos-webhook-receiver + apiGroup: rbac.authorization.k8s.io + +--- +# Example Secret for Linear webhook signature validation +# Create this with your actual Linear webhook secret: +# +# kubectl create secret generic linear-webhook-secret \ +# --from-literal=secret=your-linear-webhook-secret-here +# +apiVersion: v1 +kind: Secret +metadata: + name: linear-webhook-secret + namespace: default +type: Opaque +stringData: + secret: "your-linear-webhook-secret-here" diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 2ad6b89c..6d702af4 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -1520,6 +1520,36 @@ spec: - project - secretRef type: object + linearWebhook: + description: LinearWebhook discovers issues from Linear webhooks. + properties: + excludeLabels: + description: ExcludeLabels filters out issues that have any + of these labels (client-side). + items: + type: string + type: array + labels: + description: Labels filters issues by labels (applied client-side + to webhook payloads). + items: + type: string + type: array + namespace: + description: |- + Namespace is the Kubernetes namespace where WebhookEvent resources are created. + The spawner will watch for Linear webhook events in this namespace. + type: string + states: + description: |- + States filters issues by workflow state names (e.g., ["Todo", "In Progress"]). + When empty, all non-terminal states are processed (excludes "Done", "Canceled"). + items: + type: string + type: array + required: + - namespace + type: object type: object required: - taskTemplate diff --git a/internal/source/linear_webhook.go b/internal/source/linear_webhook.go new file mode 100644 index 00000000..86dc1c23 --- /dev/null +++ b/internal/source/linear_webhook.go @@ -0,0 +1,209 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +// LinearWebhookSource discovers work items from Linear webhook events stored +// as WebhookEvent custom resources. +type LinearWebhookSource struct { + Client client.Client + Namespace string + + // States filters issues by workflow state names (e.g., ["Todo", "In Progress"]) + // When empty, all non-terminal states are processed (excludes "Done", "Canceled") + States []string + // Labels filters issues by labels (applied client-side to webhook payloads) + Labels []string + // ExcludeLabels filters out items with these labels (applied client-side) + ExcludeLabels []string +} + +// LinearWebhookPayload represents the relevant fields from a Linear webhook payload. +type LinearWebhookPayload struct { + Type string `json:"type"` // "Issue" or "Comment" + Action string `json:"action"` // "create", "update", "remove" + Data struct { + ID string `json:"id"` + Identifier string `json:"identifier"` // "TEAM-123" format + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + State struct { + Name string `json:"name"` // "Todo", "In Progress", "Done", etc. + Type string `json:"type"` // "triage", "backlog", "unstarted", "started", "completed", "canceled" + } `json:"state"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Team struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"team"` + } `json:"data"` +} + +// Discover fetches unprocessed Linear webhook events and converts them to WorkItems. +func (s *LinearWebhookSource) Discover(ctx context.Context) ([]WorkItem, error) { + var eventList kelosv1alpha1.WebhookEventList + + // List all webhook events in namespace + if err := s.Client.List(ctx, &eventList, + client.InNamespace(s.Namespace), + ); err != nil { + return nil, fmt.Errorf("listing webhook events: %w", err) + } + + var items []WorkItem + + for i := range eventList.Items { + event := eventList.Items[i].DeepCopy() + + // Filter by source and processed status client-side + if event.Spec.Source != "linear" || event.Status.Processed { + continue + } + + // Parse webhook payload + var payload LinearWebhookPayload + if err := json.Unmarshal(event.Spec.Payload, &payload); err != nil { + // Mark event as processed even if payload is malformed + s.markProcessed(ctx, event) + continue + } + + // Convert to WorkItem + item, ok := s.payloadToWorkItem(payload) + if !ok { + // Mark event as processed even if it can't be converted + s.markProcessed(ctx, event) + continue + } + + // Apply filters + if !s.matchesFilters(item, payload) { + // Mark event as processed even if filtered out + s.markProcessed(ctx, event) + continue + } + + items = append(items, item) + + // Mark event as processed + s.markProcessed(ctx, event) + } + + return items, nil +} + +// payloadToWorkItem converts a Linear webhook payload to a WorkItem. +// Returns false if the payload should be skipped. +func (s *LinearWebhookSource) payloadToWorkItem(payload LinearWebhookPayload) (WorkItem, bool) { + // Only process Issue events + if payload.Type != "Issue" { + return WorkItem{}, false + } + + // Only process create and update actions + if payload.Action != "create" && payload.Action != "update" { + return WorkItem{}, false + } + + // Skip if no data + if payload.Data.Identifier == "" { + return WorkItem{}, false + } + + // Extract labels + labels := make([]string, len(payload.Data.Labels)) + for i, l := range payload.Data.Labels { + labels[i] = l.Name + } + + return WorkItem{ + ID: payload.Data.Identifier, // e.g., "ENG-42" + Number: payload.Data.Number, + Title: payload.Data.Title, + Body: payload.Data.Description, + URL: payload.Data.URL, + Labels: labels, + Kind: payload.Data.State.Name, // e.g., "Todo", "In Progress" + }, true +} + +// matchesFilters returns true if the item matches the configured filters. +func (s *LinearWebhookSource) matchesFilters(item WorkItem, payload LinearWebhookPayload) bool { + // Check state filter + if !s.matchesState(payload.Data.State) { + return false + } + + // Check required labels + if len(s.Labels) > 0 { + hasAllRequired := true + for _, required := range s.Labels { + found := false + for _, label := range item.Labels { + if label == required { + found = true + break + } + } + if !found { + hasAllRequired = false + break + } + } + if !hasAllRequired { + return false + } + } + + // Check excluded labels + for _, excluded := range s.ExcludeLabels { + for _, label := range item.Labels { + if label == excluded { + return false + } + } + } + + return true +} + +// matchesState returns true if the issue state matches the configured state filter. +func (s *LinearWebhookSource) matchesState(state struct { + Name string `json:"name"` + Type string `json:"type"` +}) bool { + // If no states configured, exclude terminal states by default + if len(s.States) == 0 { + // Terminal states: completed, canceled + return state.Type != "completed" && state.Type != "canceled" + } + + // Check if state name is in the configured list + for _, allowed := range s.States { + if state.Name == allowed { + return true + } + } + + return false +} + +// markProcessed marks an event as processed. +func (s *LinearWebhookSource) markProcessed(ctx context.Context, event *kelosv1alpha1.WebhookEvent) { + event.Status.Processed = true + now := metav1.Now() + event.Status.ProcessedAt = &now + _ = s.Client.Status().Update(ctx, event) +} diff --git a/internal/source/linear_webhook_test.go b/internal/source/linear_webhook_test.go new file mode 100644 index 00000000..27a0188c --- /dev/null +++ b/internal/source/linear_webhook_test.go @@ -0,0 +1,433 @@ +package source + +import ( + "context" + "encoding/json" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" +) + +func TestLinearWebhookSource_ParseIssuePayload(t *testing.T) { + payload := []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "id": "abc-123", + "identifier": "ENG-42", + "number": 42, + "title": "Fix login bug", + "description": "Users cannot log in after password reset", + "url": "https://linear.app/myteam/issue/ENG-42", + "state": { + "name": "Todo", + "type": "unstarted" + }, + "labels": [ + {"name": "bug"}, + {"name": "high-priority"} + ], + "team": { + "key": "ENG", + "name": "Engineering" + } + } + }`) + + var parsed LinearWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &LinearWebhookSource{} + item, ok := source.payloadToWorkItem(parsed) + + if !ok { + t.Fatal("Expected payload to be converted to WorkItem") + } + + if item.ID != "ENG-42" { + t.Errorf("Expected ID 'ENG-42', got %s", item.ID) + } + if item.Number != 42 { + t.Errorf("Expected number 42, got %d", item.Number) + } + if item.Title != "Fix login bug" { + t.Errorf("Expected title 'Fix login bug', got %s", item.Title) + } + if item.Kind != "Todo" { + t.Errorf("Expected kind 'Todo', got %s", item.Kind) + } + if len(item.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(item.Labels)) + } +} + +func TestLinearWebhookSource_SkipNonIssueEvents(t *testing.T) { + payload := []byte(`{ + "type": "Comment", + "action": "create", + "data": { + "body": "This is a comment" + } + }`) + + var parsed LinearWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &LinearWebhookSource{} + _, ok := source.payloadToWorkItem(parsed) + + if ok { + t.Error("Expected Comment event to be skipped") + } +} + +func TestLinearWebhookSource_SkipRemoveAction(t *testing.T) { + payload := []byte(`{ + "type": "Issue", + "action": "remove", + "data": { + "identifier": "ENG-42", + "number": 42, + "title": "Deleted Issue" + } + }`) + + var parsed LinearWebhookPayload + if err := json.Unmarshal(payload, &parsed); err != nil { + t.Fatalf("Failed to parse payload: %v", err) + } + + source := &LinearWebhookSource{} + _, ok := source.payloadToWorkItem(parsed) + + if ok { + t.Error("Expected remove action to be skipped") + } +} + +func TestLinearWebhookSource_StateFiltering(t *testing.T) { + tests := []struct { + name string + states []string + stateName string + stateType string + expectedMatch bool + }{ + { + name: "No filter - accepts non-terminal state", + states: nil, + stateName: "In Progress", + stateType: "started", + expectedMatch: true, + }, + { + name: "No filter - excludes completed", + states: nil, + stateName: "Done", + stateType: "completed", + expectedMatch: false, + }, + { + name: "No filter - excludes canceled", + states: nil, + stateName: "Canceled", + stateType: "canceled", + expectedMatch: false, + }, + { + name: "Filter matches state name", + states: []string{"Todo", "In Progress"}, + stateName: "Todo", + stateType: "unstarted", + expectedMatch: true, + }, + { + name: "Filter does not match state name", + states: []string{"Todo"}, + stateName: "In Progress", + stateType: "started", + expectedMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := &LinearWebhookSource{ + States: tt.states, + } + + state := struct { + Name string `json:"name"` + Type string `json:"type"` + }{ + Name: tt.stateName, + Type: tt.stateType, + } + + matches := source.matchesState(state) + if matches != tt.expectedMatch { + t.Errorf("Expected match=%v, got %v", tt.expectedMatch, matches) + } + }) + } +} + +func TestLinearWebhookSource_LabelFiltering(t *testing.T) { + tests := []struct { + name string + itemLabels []string + requiredLabels []string + excludeLabels []string + expectedMatch bool + }{ + { + name: "No filters - matches", + itemLabels: []string{"bug"}, + requiredLabels: nil, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label present", + itemLabels: []string{"bug", "high-priority"}, + requiredLabels: []string{"high-priority"}, + excludeLabels: nil, + expectedMatch: true, + }, + { + name: "Required label missing", + itemLabels: []string{"bug"}, + requiredLabels: []string{"high-priority"}, + excludeLabels: nil, + expectedMatch: false, + }, + { + name: "Excluded label present", + itemLabels: []string{"bug", "wont-fix"}, + requiredLabels: nil, + excludeLabels: []string{"wont-fix"}, + expectedMatch: false, + }, + { + name: "Multiple required labels", + itemLabels: []string{"bug", "high-priority", "backend"}, + requiredLabels: []string{"high-priority", "backend"}, + excludeLabels: nil, + expectedMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := &LinearWebhookSource{ + Labels: tt.requiredLabels, + ExcludeLabels: tt.excludeLabels, + } + + item := WorkItem{Labels: tt.itemLabels} + payload := LinearWebhookPayload{} + payload.Data.State.Type = "unstarted" // Non-terminal state + + matches := source.matchesFilters(item, payload) + if matches != tt.expectedMatch { + t.Errorf("Expected match=%v, got %v", tt.expectedMatch, matches) + } + }) + } +} + +func TestLinearWebhookSource_Discover(t *testing.T) { + scheme := runtime.NewScheme() + _ = kelosv1alpha1.AddToScheme(scheme) + + // Create fake client with webhook events + event1 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-1", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-100", + "number": 100, + "title": "First Issue", + "description": "Test description", + "url": "https://linear.app/myteam/issue/ENG-100", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [{"name": "bug"}], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + event2 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "event-2", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "update", + "data": { + "identifier": "ENG-200", + "number": 200, + "title": "Second Issue", + "state": {"name": "Done", "type": "completed"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(event1, event2). + WithStatusSubresource(&kelosv1alpha1.WebhookEvent{}). + Build() + + source := &LinearWebhookSource{ + Client: fakeClient, + Namespace: "default", + } + + items, err := source.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + + // Should only get event1 (event2 has completed state) + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } + + if len(items) > 0 && items[0].Number != 100 { + t.Errorf("Expected issue 100, got %d", items[0].Number) + } + + // Verify events were marked as processed + var updatedEvent kelosv1alpha1.WebhookEvent + if err := fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "event-1", + Namespace: "default", + }, &updatedEvent); err != nil { + t.Fatalf("Failed to get updated event: %v", err) + } + + if !updatedEvent.Status.Processed { + t.Error("Expected event to be marked as processed") + } +} + +func TestLinearWebhookSource_OnlyProcessLinearSource(t *testing.T) { + scheme := runtime.NewScheme() + _ = kelosv1alpha1.AddToScheme(scheme) + + // Create a GitHub webhook event (should be ignored) + githubEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "github-event", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": {"number": 1} + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + // Create a Linear webhook event + linearEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "linear-event", + Namespace: "default", + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-300", + "number": 300, + "title": "Linear Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + Status: kelosv1alpha1.WebhookEventStatus{ + Processed: false, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(githubEvent, linearEvent). + WithStatusSubresource(&kelosv1alpha1.WebhookEvent{}). + Build() + + source := &LinearWebhookSource{ + Client: fakeClient, + Namespace: "default", + } + + items, err := source.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + + // Should only get the Linear event + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } + + if len(items) > 0 && items[0].Number != 300 { + t.Errorf("Expected issue 300, got %d", items[0].Number) + } + + // Verify GitHub event was not processed + var githubUpdated kelosv1alpha1.WebhookEvent + if err := fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "github-event", + Namespace: "default", + }, &githubUpdated); err != nil { + t.Fatalf("Failed to get GitHub event: %v", err) + } + + if githubUpdated.Status.Processed { + t.Error("Expected GitHub event to not be processed by Linear source") + } +} diff --git a/test/integration/linear_webhook_test.go b/test/integration/linear_webhook_test.go new file mode 100644 index 00000000..729d2b60 --- /dev/null +++ b/test/integration/linear_webhook_test.go @@ -0,0 +1,458 @@ +package integration + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1" + "github.com/kelos-dev/kelos/internal/source" +) + +var _ = Describe("Linear Webhook Integration", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When receiving Linear webhook events", func() { + It("Should discover and process WebhookEvent CRDs", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-webhook-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for a Linear issue") + issuePayload := []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "id": "abc-123", + "identifier": "ENG-42", + "number": 42, + "title": "Fix authentication bug", + "description": "Users cannot log in after password reset", + "url": "https://linear.app/myteam/issue/ENG-42", + "state": { + "name": "Todo", + "type": "unstarted" + }, + "labels": [ + {"name": "bug"}, + {"name": "high-priority"} + ], + "team": { + "key": "ENG", + "name": "Engineering" + } + } + }`) + + event := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: issuePayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event)).Should(Succeed()) + + By("Creating a LinearWebhookSource") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items from the webhook") + var items []source.WorkItem + Eventually(func() int { + var err error + items, err = webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return len(items) + }, timeout, interval).Should(Equal(1)) + + By("Verifying the discovered work item") + Expect(items[0].ID).To(Equal("ENG-42")) + Expect(items[0].Number).To(Equal(42)) + Expect(items[0].Title).To(Equal("Fix authentication bug")) + Expect(items[0].Kind).To(Equal("Todo")) + Expect(items[0].Labels).To(ContainElement("bug")) + Expect(items[0].Labels).To(ContainElement("high-priority")) + + By("Verifying the WebhookEvent was marked as processed") + updatedEvent := &kelosv1alpha1.WebhookEvent{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event.Name, + Namespace: ns.Name, + }, updatedEvent) + if err != nil { + return false + } + return updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Expect(updatedEvent.Status.ProcessedAt).NotTo(BeNil()) + }) + + It("Should filter WebhookEvents by state", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-state-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent for a completed issue") + completedPayload := []byte(`{ + "type": "Issue", + "action": "update", + "data": { + "identifier": "ENG-100", + "number": 100, + "title": "Completed Issue", + "state": { + "name": "Done", + "type": "completed" + }, + "labels": [], + "team": {"key": "ENG"} + } + }`) + + event1 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: completedPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event1)).Should(Succeed()) + + By("Creating a WebhookEvent for an open issue") + openPayload := []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-200", + "number": 200, + "title": "Open Issue", + "state": { + "name": "In Progress", + "type": "started" + }, + "labels": [], + "team": {"key": "ENG"} + } + }`) + + event2 := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: openPayload, + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, event2)).Should(Succeed()) + + By("Creating a LinearWebhookSource with no state filter") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the non-terminal issue was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(200)) + + By("Verifying both events were marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event1.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: event2.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should filter by configured states", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-states-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating WebhookEvents with different states") + todoEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-300", + "number": 300, + "title": "Todo Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, todoEvent)).Should(Succeed()) + + inProgressEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-400", + "number": 400, + "title": "In Progress Issue", + "state": {"name": "In Progress", "type": "started"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, inProgressEvent)).Should(Succeed()) + + By("Creating a LinearWebhookSource with state filter") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + States: []string{"Todo"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only Todo state was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(300)) + Expect(items[0].Kind).To(Equal("Todo")) + }) + + It("Should filter by labels", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-labels-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a WebhookEvent with matching labels") + matchingEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-500", + "number": 500, + "title": "Bug Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [{"name": "bug"}, {"name": "backend"}], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, matchingEvent)).Should(Succeed()) + + By("Creating a WebhookEvent without matching labels") + nonMatchingEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-600", + "number": 600, + "title": "Feature Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [{"name": "feature"}], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, nonMatchingEvent)).Should(Succeed()) + + By("Creating a LinearWebhookSource with label filter") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + Labels: []string{"bug"}, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the bug-labeled issue was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(500)) + + By("Verifying both events were marked as processed") + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: matchingEvent.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + + Eventually(func() bool { + updatedEvent := &kelosv1alpha1.WebhookEvent{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: nonMatchingEvent.Name, + Namespace: ns.Name, + }, updatedEvent) + return err == nil && updatedEvent.Status.Processed + }, timeout, interval).Should(BeTrue()) + }) + + It("Should only process events with source=linear", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-linear-source-", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a GitHub webhook event (should be ignored)") + githubEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "github-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "github", + Payload: []byte(`{ + "action": "opened", + "issue": {"number": 1} + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, githubEvent)).Should(Succeed()) + + By("Creating a Linear webhook event") + linearEvent := &kelosv1alpha1.WebhookEvent{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "linear-webhook-", + Namespace: ns.Name, + }, + Spec: kelosv1alpha1.WebhookEventSpec{ + Source: "linear", + Payload: []byte(`{ + "type": "Issue", + "action": "create", + "data": { + "identifier": "ENG-700", + "number": 700, + "title": "Linear Issue", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": [], + "team": {"key": "ENG"} + } + }`), + ReceivedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, linearEvent)).Should(Succeed()) + + By("Creating a LinearWebhookSource") + webhookSource := &source.LinearWebhookSource{ + Client: k8sClient, + Namespace: ns.Name, + } + + By("Discovering work items") + items, err := webhookSource.Discover(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying only the Linear event was discovered") + Expect(items).To(HaveLen(1)) + Expect(items[0].Number).To(Equal(700)) + + By("Verifying GitHub event was not processed") + githubUpdated := &kelosv1alpha1.WebhookEvent{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: githubEvent.Name, + Namespace: ns.Name, + }, githubUpdated) + Expect(err).NotTo(HaveOccurred()) + Expect(githubUpdated.Status.Processed).To(BeFalse()) + }) + }) +}) From eefcbeb7a1d406efd5c4c5fd5e0e99aa684cba14 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Thu, 26 Mar 2026 17:50:49 +0100 Subject: [PATCH 26/26] fix: use caching for improving build speed --- .github/workflows/release.yaml | 7 +++++-- Makefile | 3 +++ cmd/kelos-controller/Dockerfile | 3 ++- cmd/kelos-spawner/Dockerfile | 3 ++- cmd/kelos-token-refresher/Dockerfile | 3 +++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ba024ef5..10bdccbb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -46,11 +46,14 @@ jobs: - name: Build and push multi-arch images env: VERSION: ${{ steps.version.outputs.version }} - run: make push-multiarch VERSION="$VERSION" + BUILDX_CACHE: --cache-from type=gha --cache-to type=gha,mode=max + run: make push-multiarch VERSION="$VERSION" REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Push latest tags for releases if: startsWith(github.ref, 'refs/tags/v') - run: make push-multiarch VERSION=latest + env: + BUILDX_CACHE: --cache-from type=gha + run: make push-multiarch VERSION=latest REGISTRY="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - name: Build CLI binaries if: startsWith(github.ref, 'refs/tags/v') diff --git a/Makefile b/Makefile index 73cc9b0c..87958daf 100644 --- a/Makefile +++ b/Makefile @@ -93,10 +93,13 @@ push: ## Push docker images (use WHAT to push specific image). DOCKER_PLATFORMS ?= linux/amd64,linux/arm64 +BUILDX_CACHE ?= + .PHONY: push-multiarch push-multiarch: ## Build and push multi-arch docker images. @for dir in $(or $(WHAT),$(IMAGE_DIRS)); do \ docker buildx build --platform $(DOCKER_PLATFORMS) \ + $(BUILDX_CACHE) \ -t $(REGISTRY)/$$(basename $$dir):$(VERSION) \ -f $$dir/Dockerfile --push .; \ done diff --git a/cmd/kelos-controller/Dockerfile b/cmd/kelos-controller/Dockerfile index 064ef76c..12b1af87 100644 --- a/cmd/kelos-controller/Dockerfile +++ b/cmd/kelos-controller/Dockerfile @@ -3,7 +3,8 @@ WORKDIR /workspace COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o bin/kelos-controller ./cmd/kelos-controller +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -o bin/kelos-controller ./cmd/kelos-controller FROM gcr.io/distroless/static:nonroot WORKDIR / diff --git a/cmd/kelos-spawner/Dockerfile b/cmd/kelos-spawner/Dockerfile index 1797ea87..36a0a39b 100644 --- a/cmd/kelos-spawner/Dockerfile +++ b/cmd/kelos-spawner/Dockerfile @@ -3,7 +3,8 @@ WORKDIR /workspace COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o bin/kelos-spawner ./cmd/kelos-spawner +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -o bin/kelos-spawner ./cmd/kelos-spawner FROM gcr.io/distroless/static:nonroot WORKDIR / diff --git a/cmd/kelos-token-refresher/Dockerfile b/cmd/kelos-token-refresher/Dockerfile index f012daec..66014b7e 100644 --- a/cmd/kelos-token-refresher/Dockerfile +++ b/cmd/kelos-token-refresher/Dockerfile @@ -6,6 +6,9 @@ WORKDIR /workspace # Copy go mod files COPY go.mod go.sum ./ RUN go mod download +COPY . . +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -o bin/kelos-token-refresher ./cmd/kelos-token-refresher # Copy source COPY . .