diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 962af0561..a86c3c65b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,18 +68,22 @@ jobs: with: cluster_name: kind - - name: Build controller image - run: make image WHAT=cmd/axon-controller VERSION=e2e + - name: Build images + run: | + make image WHAT=cmd/axon-controller VERSION=e2e + make image WHAT=cmd/axon-spawner VERSION=e2e - name: Load images into kind - run: kind load docker-image gjkim42/axon-controller:e2e + run: | + kind load docker-image gjkim42/axon-controller:e2e + kind load docker-image gjkim42/axon-spawner:e2e - name: Deploy controller run: | kubectl apply -f install-crd.yaml kubectl apply -f install.yaml kubectl set image deployment/axon-controller-manager manager=gjkim42/axon-controller:e2e -n axon-system - kubectl patch deployment axon-controller-manager -n axon-system -p '{"spec":{"template":{"spec":{"containers":[{"name":"manager","imagePullPolicy":"Never"}]}}}}' + kubectl patch deployment axon-controller-manager -n axon-system -p '{"spec":{"template":{"spec":{"containers":[{"name":"manager","imagePullPolicy":"Never","args":["--leader-elect","--spawner-image=gjkim42/axon-spawner:e2e","--spawner-image-pull-policy=Never"]}]}}}}' kubectl wait --for=condition=available deployment/axon-controller-manager -n axon-system --timeout=120s - name: Build CLI @@ -88,5 +92,6 @@ jobs: - name: E2E Test env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} AXON_BIN: ${{ github.workspace }}/bin/axon run: make test-e2e diff --git a/CLAUDE.md b/CLAUDE.md index 05d5c162a..27297d56c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ - **Use Makefile targets** instead of discovering build/test commands yourself. - **Keep changes minimal.** Do not refactor, reorganize, or 'improve' code beyond what was explicitly requested. - **For CI/release workflows**, always use existing Makefile targets rather than reimplementing build logic in YAML. -- **Better tests** Always try to add or improve tests(including integration, e2e) when modifying code. +- **Better tests.** Always try to add or improve tests(including integration, e2e) when modifying code. +- **Logging conventions.** Start log messages with capital letters and do not end with punctuation. ## Key Makefile Targets - `make verify` — run all verification checks (lint, fmt, vet, etc.) diff --git a/Makefile b/Makefile index 29f4dbcd6..5c98dd34b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Image configuration REGISTRY ?= gjkim42 VERSION ?= latest -IMAGE_DIRS ?= cmd/axon-controller claude-code +IMAGE_DIRS ?= cmd/axon-controller cmd/axon-spawner claude-code # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.31.0 diff --git a/README.md b/README.md index 41424ec47..4cb35f8c1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,20 @@ flowchart LR AC --> J3[Job 3] --> P3["Pod 3 (--dangerously-skip-permissions)"] --> F3[Failed] ``` +### TaskSpawner — Automatic Task Creation from External Sources + +TaskSpawner watches external sources (e.g., GitHub Issues) and automatically creates Tasks for each discovered item. + +```mermaid +flowchart LR + TS["TaskSpawner"] -- "polls" --> GH["GitHub Issues"] + GH -- "new issues" --> TS + TS -- "creates" --> T1["Task: fix-bugs-1"] + TS -- "creates" --> T2["Task: fix-bugs-2"] +``` + +--- + 1. You **apply a Task** manifest with a prompt, agent type, credential reference, and optionally a git repo to clone. 2. The **Axon controller** creates a Kubernetes **Job**. 3. If a workspace is specified, an **init container clones the repo**. Then the agent Pod starts with `--dangerously-skip-permissions`. @@ -163,6 +177,7 @@ Or pass `--secret` to `axon run` with a pre-created secret (api-key is the defau | CI-Native | Trigger agents from any pipeline via `kubectl`, Helm, Argo, or your own tooling | | Git Workspace | Clone a repo into the agent's working directory via `spec.workspace` | | Config File | Set token, model, namespace, and workspace in `~/.axon/config.yaml` — secrets are auto-created | +| TaskSpawner | Automatically create Tasks from GitHub Issues (or other sources) via a long-running spawner | | CLI | `axon init`, `axon run`, `axon get`, `axon logs`, `axon delete` — manage tasks without writing YAML | | Full Lifecycle | `Pending` → `Running` → `Succeeded` / `Failed` | | Owner References | Delete a Task and its Job + Pod are automatically cleaned up | @@ -195,6 +210,51 @@ Or pass `--secret` to `axon run` with a pre-created secret (api-key is the defau | `spec.workspace.repo` | Git repository URL to clone (HTTPS, git://, or SSH) | No | | `spec.workspace.ref` | Branch, tag, or commit SHA to checkout (defaults to repo's default branch) | No | +### TaskSpawner Spec + +| Field | Description | Required | +|-------|-------------|----------| +| `spec.when.githubIssues.owner` | GitHub repository owner | Yes | +| `spec.when.githubIssues.repo` | GitHub repository name | Yes | +| `spec.when.githubIssues.labels` | Filter issues by labels | No | +| `spec.when.githubIssues.state` | Filter by state: `open`, `closed`, `all` (default: `open`) | No | +| `spec.when.githubIssues.tokenSecretRef.name` | Secret containing `GITHUB_TOKEN` | No | +| `spec.taskTemplate.type` | Agent type (`claude-code`) | Yes | +| `spec.taskTemplate.credentials` | Credentials for the agent (same as Task) | Yes | +| `spec.taskTemplate.model` | Model override | No | +| `spec.taskTemplate.workspace` | Git workspace for spawned Tasks | No | +| `spec.taskTemplate.promptTemplate` | Go text/template for prompt (`{{.Title}}`, `{{.Body}}`, `{{.Number}}`, etc.) | No | +| `spec.pollInterval` | How often to poll the source (default: `5m`) | No | + +
+Example TaskSpawner YAML + +```yaml +apiVersion: axon.io/v1alpha1 +kind: TaskSpawner +metadata: + name: fix-bugs +spec: + when: + githubIssues: + owner: your-org + repo: your-repo + labels: [bug] + state: open + tokenSecretRef: + name: github-token + taskTemplate: + type: claude-code + credentials: + type: oauth + secretRef: + name: claude-credentials + promptTemplate: "Fix: {{.Title}}\n{{.Body}}" + pollInterval: 5m +``` + +
+ ### Task Status | Field | Description | @@ -255,6 +315,9 @@ axon run -p "Fix bug" --secret other-secret --credential-type api-key # List tasks axon get tasks +# List task spawners +axon get taskspawners + # View logs (follow mode) axon logs my-task -f diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go new file mode 100644 index 000000000..5122eab5e --- /dev/null +++ b/api/v1alpha1/taskspawner_types.go @@ -0,0 +1,148 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TaskSpawnerPhase represents the current phase of a TaskSpawner. +type TaskSpawnerPhase string + +const ( + // TaskSpawnerPhasePending means the TaskSpawner has been accepted but the spawner is not yet running. + TaskSpawnerPhasePending TaskSpawnerPhase = "Pending" + // TaskSpawnerPhaseRunning means the spawner is actively polling and creating tasks. + TaskSpawnerPhaseRunning TaskSpawnerPhase = "Running" + // TaskSpawnerPhaseFailed means the spawner has failed. + TaskSpawnerPhaseFailed TaskSpawnerPhase = "Failed" +) + +// When defines the conditions that trigger task spawning. +// Exactly one field must be set. +type When struct { + // GitHubIssues discovers issues from a GitHub repository. + // +optional + GitHubIssues *GitHubIssues `json:"githubIssues,omitempty"` +} + +// GitHubIssues discovers issues from a GitHub repository. +type GitHubIssues struct { + // Owner is the GitHub repository owner. + // +kubebuilder:validation:Required + Owner string `json:"owner"` + + // Repo is the GitHub repository name. + // +kubebuilder:validation:Required + Repo string `json:"repo"` + + // Labels filters issues by labels. + // +optional + Labels []string `json:"labels,omitempty"` + + // State filters issues by state (open, closed, all). Defaults to open. + // +kubebuilder:validation:Enum=open;closed;all + // +kubebuilder:default=open + // +optional + State string `json:"state,omitempty"` + + // TokenSecretRef references a Secret containing a GitHub token. + // The secret must have a key named GITHUB_TOKEN. + // +optional + TokenSecretRef *SecretReference `json:"tokenSecretRef,omitempty"` +} + +// TaskTemplate defines the template for spawned Tasks. +type TaskTemplate struct { + // Type specifies the agent type (e.g., claude-code). + // +kubebuilder:validation:Required + Type string `json:"type"` + + // Credentials specifies how to authenticate with the agent. + // +kubebuilder:validation:Required + Credentials Credentials `json:"credentials"` + + // Model optionally overrides the default model. + // +optional + Model string `json:"model,omitempty"` + + // Workspace optionally specifies a git repository for the agent to work in. + // +optional + Workspace *Workspace `json:"workspace,omitempty"` + + // PromptTemplate is a Go text/template for rendering the task prompt. + // Available variables: {{.Number}}, {{.Title}}, {{.Body}}, {{.URL}}, {{.Comments}}, {{.Labels}}. + // +optional + PromptTemplate string `json:"promptTemplate,omitempty"` +} + +// TaskSpawnerSpec defines the desired state of TaskSpawner. +type TaskSpawnerSpec struct { + // When defines the conditions that trigger task spawning. + // +kubebuilder:validation:Required + When When `json:"when"` + + // TaskTemplate defines the template for spawned Tasks. + // +kubebuilder:validation:Required + TaskTemplate TaskTemplate `json:"taskTemplate"` + + // PollInterval is how often to poll the source for new items (e.g., "5m"). Defaults to "5m". + // +kubebuilder:default="5m" + // +optional + PollInterval string `json:"pollInterval,omitempty"` +} + +// TaskSpawnerStatus defines the observed state of TaskSpawner. +type TaskSpawnerStatus struct { + // Phase represents the current phase of the TaskSpawner. + // +optional + Phase TaskSpawnerPhase `json:"phase,omitempty"` + + // DeploymentName is the name of the Deployment running the spawner. + // +optional + DeploymentName string `json:"deploymentName,omitempty"` + + // TotalDiscovered is the total number of work items discovered. + // +optional + TotalDiscovered int `json:"totalDiscovered,omitempty"` + + // TotalTasksCreated is the total number of Tasks created. + // +optional + TotalTasksCreated int `json:"totalTasksCreated,omitempty"` + + // LastDiscoveryTime is the last time the source was polled. + // +optional + LastDiscoveryTime *metav1.Time `json:"lastDiscoveryTime,omitempty"` + + // Message provides additional information about the current status. + // +optional + Message string `json:"message,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.when.githubIssues.repo` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Discovered",type=integer,JSONPath=`.status.totalDiscovered` +// +kubebuilder:printcolumn:name="Tasks",type=integer,JSONPath=`.status.totalTasksCreated` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// TaskSpawner is the Schema for the taskspawners API. +type TaskSpawner struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TaskSpawnerSpec `json:"spec,omitempty"` + Status TaskSpawnerStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// TaskSpawnerList contains a list of TaskSpawner. +type TaskSpawnerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TaskSpawner `json:"items"` +} + +func init() { + SchemeBuilder.Register(&TaskSpawner{}, &TaskSpawnerList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ae785c41a..6d6c78d0a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -40,6 +40,31 @@ func (in *Credentials) DeepCopy() *Credentials { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitHubIssues) DeepCopyInto(out *GitHubIssues) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TokenSecretRef != nil { + in, out := &in.TokenSecretRef, &out.TokenSecretRef + *out = new(SecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitHubIssues. +func (in *GitHubIssues) DeepCopy() *GitHubIssues { + if in == nil { + return nil + } + out := new(GitHubIssues) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretReference) DeepCopyInto(out *SecretReference) { *out = *in @@ -114,6 +139,101 @@ func (in *TaskList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskSpawner) DeepCopyInto(out *TaskSpawner) { + *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 TaskSpawner. +func (in *TaskSpawner) DeepCopy() *TaskSpawner { + if in == nil { + return nil + } + out := new(TaskSpawner) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TaskSpawner) 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 *TaskSpawnerList) DeepCopyInto(out *TaskSpawnerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TaskSpawner, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpawnerList. +func (in *TaskSpawnerList) DeepCopy() *TaskSpawnerList { + if in == nil { + return nil + } + out := new(TaskSpawnerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TaskSpawnerList) 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 *TaskSpawnerSpec) DeepCopyInto(out *TaskSpawnerSpec) { + *out = *in + in.When.DeepCopyInto(&out.When) + in.TaskTemplate.DeepCopyInto(&out.TaskTemplate) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpawnerSpec. +func (in *TaskSpawnerSpec) DeepCopy() *TaskSpawnerSpec { + if in == nil { + return nil + } + out := new(TaskSpawnerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskSpawnerStatus) DeepCopyInto(out *TaskSpawnerStatus) { + *out = *in + if in.LastDiscoveryTime != nil { + in, out := &in.LastDiscoveryTime, &out.LastDiscoveryTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpawnerStatus. +func (in *TaskSpawnerStatus) DeepCopy() *TaskSpawnerStatus { + if in == nil { + return nil + } + out := new(TaskSpawnerStatus) + in.DeepCopyInto(out) + return out +} + // 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 @@ -158,6 +278,47 @@ func (in *TaskStatus) DeepCopy() *TaskStatus { return out } +// 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 + if in.Workspace != nil { + in, out := &in.Workspace, &out.Workspace + *out = new(Workspace) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskTemplate. +func (in *TaskTemplate) DeepCopy() *TaskTemplate { + if in == nil { + return nil + } + out := new(TaskTemplate) + 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 + if in.GitHubIssues != nil { + in, out := &in.GitHubIssues, &out.GitHubIssues + *out = new(GitHubIssues) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new When. +func (in *When) DeepCopy() *When { + if in == nil { + return nil + } + out := new(When) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Workspace) DeepCopyInto(out *Workspace) { *out = *in diff --git a/cmd/axon-controller/main.go b/cmd/axon-controller/main.go index 67c102011..397d928e1 100644 --- a/cmd/axon-controller/main.go +++ b/cmd/axon-controller/main.go @@ -4,6 +4,7 @@ import ( "flag" "os" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -29,12 +30,16 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var spawnerImage string + var spawnerImagePullPolicy string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&spawnerImage, "spawner-image", controller.DefaultSpawnerImage, "The image to use for spawner Deployments.") + flag.StringVar(&spawnerImagePullPolicy, "spawner-image-pull-policy", "", "The image pull policy for spawner Deployments (e.g., Always, Never, IfNotPresent).") opts := zap.Options{ Development: true, @@ -64,6 +69,18 @@ func main() { os.Exit(1) } + deploymentBuilder := controller.NewDeploymentBuilder() + deploymentBuilder.SpawnerImage = spawnerImage + deploymentBuilder.SpawnerImagePullPolicy = corev1.PullPolicy(spawnerImagePullPolicy) + if err = (&controller.TaskSpawnerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + DeploymentBuilder: deploymentBuilder, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "TaskSpawner") + os.Exit(1) + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) diff --git a/cmd/axon-spawner/Dockerfile b/cmd/axon-spawner/Dockerfile new file mode 100644 index 000000000..e443911c6 --- /dev/null +++ b/cmd/axon-spawner/Dockerfile @@ -0,0 +1,25 @@ +# 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 . . + +# Build +RUN make build WHAT=cmd/axon-spawner + +# Runtime stage +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /workspace/bin/axon-spawner . + +USER 65532:65532 + +ENTRYPOINT ["/axon-spawner"] diff --git a/cmd/axon-spawner/main.go b/cmd/axon-spawner/main.go new file mode 100644 index 000000000..e186d16af --- /dev/null +++ b/cmd/axon-spawner/main.go @@ -0,0 +1,229 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strconv" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + 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" + + axonv1alpha1 "github.com/gjkim42/axon/api/v1alpha1" + "github.com/gjkim42/axon/internal/source" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(axonv1alpha1.AddToScheme(scheme)) +} + +func main() { + var name string + var namespace string + + flag.StringVar(&name, "taskspawner-name", "", "Name of the TaskSpawner to manage") + flag.StringVar(&namespace, "taskspawner-namespace", "", "Namespace of the TaskSpawner") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + logger := zap.New(zap.UseFlagOptions(&opts)) + ctrl.SetLogger(logger) + log := ctrl.Log.WithName("spawner") + + if name == "" || namespace == "" { + log.Error(fmt.Errorf("--taskspawner-name and --taskspawner-namespace are required"), "invalid flags") + os.Exit(1) + } + + 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) + } + + ctx := ctrl.SetupSignalHandler() + key := types.NamespacedName{Name: name, Namespace: namespace} + + log.Info("starting spawner", "taskspawner", key) + + for { + if err := runCycle(ctx, cl, key); err != nil { + log.Error(err, "discovery cycle failed") + } + + // Re-read the TaskSpawner to get the current poll interval + var ts axonv1alpha1.TaskSpawner + if err := cl.Get(ctx, key, &ts); err != nil { + log.Error(err, "unable to fetch TaskSpawner for poll interval") + sleepOrDone(ctx, 5*time.Minute) + continue + } + + interval := parsePollInterval(ts.Spec.PollInterval) + log.Info("sleeping until next cycle", "interval", interval) + if done := sleepOrDone(ctx, interval); done { + return + } + } +} + +func runCycle(ctx context.Context, cl client.Client, key types.NamespacedName) error { + log := ctrl.Log.WithName("spawner") + + var ts axonv1alpha1.TaskSpawner + if err := cl.Get(ctx, key, &ts); err != nil { + return fmt.Errorf("fetching TaskSpawner: %w", err) + } + + src, err := buildSource(&ts) + if err != nil { + return fmt.Errorf("building source: %w", err) + } + + items, err := src.Discover(ctx) + if err != nil { + return fmt.Errorf("discovering items: %w", err) + } + + log.Info("discovered items", "count", len(items)) + + // Build set of already-created Tasks by listing them from the API. + // This is resilient to spawner restarts (status may lag behind actual Tasks). + var existingTaskList axonv1alpha1.TaskList + if err := cl.List(ctx, &existingTaskList, + client.InNamespace(ts.Namespace), + client.MatchingLabels{"axon.io/taskspawner": ts.Name}, + ); err != nil { + return fmt.Errorf("listing existing Tasks: %w", err) + } + + existingTasks := make(map[string]bool) + for _, t := range existingTaskList.Items { + existingTasks[t.Name] = true + } + + var newItems []source.WorkItem + for _, item := range items { + taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) + if !existingTasks[taskName] { + newItems = append(newItems, item) + } + } + + newTasksCreated := 0 + for _, item := range newItems { + taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) + + prompt, err := source.RenderPrompt(ts.Spec.TaskTemplate.PromptTemplate, item) + if err != nil { + log.Error(err, "rendering prompt", "item", item.ID) + continue + } + + task := &axonv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: ts.Namespace, + Labels: map[string]string{ + "axon.io/taskspawner": ts.Name, + }, + }, + Spec: axonv1alpha1.TaskSpec{ + Type: ts.Spec.TaskTemplate.Type, + Prompt: prompt, + Credentials: ts.Spec.TaskTemplate.Credentials, + Model: ts.Spec.TaskTemplate.Model, + Workspace: ts.Spec.TaskTemplate.Workspace, + }, + } + + if err := cl.Create(ctx, task); err != nil { + if apierrors.IsAlreadyExists(err) { + log.Info("Task already exists, skipping", "task", taskName) + } else { + log.Error(err, "creating Task", "task", taskName) + } + continue + } + + log.Info("created Task", "task", taskName, "item", item.ID) + newTasksCreated++ + } + + // Update status in a single batch + if err := cl.Get(ctx, key, &ts); err != nil { + return fmt.Errorf("re-fetching TaskSpawner for status update: %w", err) + } + + now := metav1.Now() + ts.Status.Phase = axonv1alpha1.TaskSpawnerPhaseRunning + ts.Status.LastDiscoveryTime = &now + ts.Status.TotalDiscovered = len(items) + ts.Status.TotalTasksCreated += newTasksCreated + ts.Status.Message = fmt.Sprintf("Discovered %d items, created %d tasks total", ts.Status.TotalDiscovered, ts.Status.TotalTasksCreated) + + if err := cl.Status().Update(ctx, &ts); err != nil { + return fmt.Errorf("updating TaskSpawner status: %w", err) + } + + return nil +} + +func buildSource(ts *axonv1alpha1.TaskSpawner) (source.Source, error) { + if ts.Spec.When.GitHubIssues != nil { + gh := ts.Spec.When.GitHubIssues + return &source.GitHubSource{ + Owner: gh.Owner, + Repo: gh.Repo, + Labels: gh.Labels, + State: gh.State, + Token: os.Getenv("GITHUB_TOKEN"), + }, nil + } + + return nil, fmt.Errorf("no source configured in TaskSpawner %s/%s", ts.Namespace, ts.Name) +} + +func parsePollInterval(s string) time.Duration { + if s == "" { + return 5 * time.Minute + } + d, err := time.ParseDuration(s) + if err != nil { + // Try parsing as plain number (seconds) + if n, err := strconv.Atoi(s); err == nil { + return time.Duration(n) * time.Second + } + return 5 * time.Minute + } + return d +} + +func sleepOrDone(ctx context.Context, d time.Duration) bool { + select { + case <-ctx.Done(): + return true + case <-time.After(d): + return false + } +} diff --git a/install-crd.yaml b/install-crd.yaml index c3e52234b..87dd40d5b 100644 --- a/install-crd.yaml +++ b/install-crd.yaml @@ -131,3 +131,199 @@ spec: storage: true subresources: status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: taskspawners.axon.io +spec: + group: axon.io + names: + kind: TaskSpawner + listKind: TaskSpawnerList + plural: taskspawners + singular: taskspawner + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.when.githubIssues.repo + name: Source + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.totalDiscovered + name: Discovered + type: integer + - jsonPath: .status.totalTasksCreated + name: Tasks + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: TaskSpawner is the Schema for the taskspawners 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: TaskSpawnerSpec defines the desired state of TaskSpawner. + properties: + pollInterval: + default: 5m + description: PollInterval is how often to poll the source for new + items (e.g., "5m"). Defaults to "5m". + type: string + taskTemplate: + description: TaskTemplate defines the template for spawned Tasks. + properties: + credentials: + description: Credentials specifies how to authenticate with the + agent. + properties: + secretRef: + description: SecretRef references the Secret containing credentials. + properties: + name: + description: Name is the name of the secret. + type: string + required: + - name + type: object + type: + description: Type specifies the credential type (api-key or + oauth). + enum: + - api-key + - oauth + type: string + required: + - secretRef + - type + type: object + model: + description: Model optionally overrides the default model. + type: string + promptTemplate: + description: |- + PromptTemplate is a Go text/template for rendering the task prompt. + Available variables: {{.Number}}, {{.Title}}, {{.Body}}, {{.URL}}, {{.Comments}}, {{.Labels}}. + type: string + type: + description: Type specifies the agent type (e.g., claude-code). + type: string + workspace: + description: Workspace optionally specifies a git repository for + the agent to work in. + properties: + ref: + description: |- + Ref is the git reference to checkout (branch, tag, or commit SHA). + Defaults to the repository's default branch if not specified. + type: string + repo: + description: Repo is the git repository URL to clone. + pattern: ^(https?://|git://|git@).* + type: string + required: + - repo + type: object + required: + - credentials + - type + type: object + when: + description: When defines the conditions that trigger task spawning. + properties: + githubIssues: + description: GitHubIssues discovers issues from a GitHub repository. + properties: + labels: + description: Labels filters issues by labels. + items: + type: string + type: array + owner: + description: Owner is the GitHub repository owner. + type: string + repo: + description: Repo is the GitHub repository name. + type: string + state: + default: open + description: State filters issues by state (open, closed, + all). Defaults to open. + enum: + - open + - closed + - all + type: string + tokenSecretRef: + description: |- + TokenSecretRef references a Secret containing a GitHub token. + The secret must have a key named GITHUB_TOKEN. + properties: + name: + description: Name is the name of the secret. + type: string + required: + - name + type: object + required: + - owner + - repo + type: object + type: object + required: + - taskTemplate + - when + type: object + status: + description: TaskSpawnerStatus defines the observed state of TaskSpawner. + properties: + deploymentName: + description: DeploymentName is the name of the Deployment running + the spawner. + type: string + lastDiscoveryTime: + description: LastDiscoveryTime is the last time the source was polled. + format: date-time + type: string + message: + description: Message provides additional information about the current + status. + type: string + phase: + description: Phase represents the current phase of the TaskSpawner. + type: string + totalDiscovered: + description: TotalDiscovered is the total number of work items discovered. + type: integer + totalTasksCreated: + description: TotalTasksCreated is the total number of Tasks created. + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/install.yaml b/install.yaml index 982cc2ede..0e7404824 100644 --- a/install.yaml +++ b/install.yaml @@ -45,6 +45,33 @@ rules: - tasks/finalizers verbs: - update + # TaskSpawners + - apiGroups: + - axon.io + resources: + - taskspawners + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - axon.io + resources: + - taskspawners/status + verbs: + - get + - patch + - update + - apiGroups: + - axon.io + resources: + - taskspawners/finalizers + verbs: + - update # Jobs - apiGroups: - batch @@ -58,6 +85,19 @@ rules: - patch - update - watch + # Deployments + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch # Pods (for status) - apiGroups: - "" @@ -67,6 +107,65 @@ rules: - get - list - watch + # Secrets (for token mounting) + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch + # ServiceAccounts (for spawner RBAC setup) + - apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch + - create + # RoleBindings (for spawner RBAC setup) + - apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + verbs: + - get + - list + - watch + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: axon-spawner-role +rules: + - apiGroups: + - axon.io + resources: + - taskspawners + verbs: + - get + - list + - watch + - apiGroups: + - axon.io + resources: + - taskspawners/status + verbs: + - get + - update + - patch + - apiGroups: + - axon.io + resources: + - tasks + verbs: + - create + - get + - list --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/internal/cli/get.go b/internal/cli/get.go index b0ce8aa9e..0f7035e04 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -22,10 +22,44 @@ func newGetCommand(cfg *ClientConfig) *cobra.Command { } cmd.AddCommand(newGetTaskCommand(cfg)) + cmd.AddCommand(newGetTaskSpawnerCommand(cfg)) return cmd } +func newGetTaskSpawnerCommand(cfg *ClientConfig) *cobra.Command { + return &cobra.Command{ + Use: "taskspawner [name]", + Aliases: []string{"taskspawners", "ts"}, + Short: "List task spawners or get details of a specific task spawner", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cl, ns, err := cfg.NewClient() + if err != nil { + return err + } + + ctx := context.Background() + + if len(args) == 1 { + ts := &axonv1alpha1.TaskSpawner{} + if err := cl.Get(ctx, client.ObjectKey{Name: args[0], Namespace: ns}, ts); err != nil { + return fmt.Errorf("getting task spawner: %w", err) + } + printTaskSpawnerDetail(os.Stdout, ts) + return nil + } + + tsList := &axonv1alpha1.TaskSpawnerList{} + if err := cl.List(ctx, tsList, client.InNamespace(ns)); err != nil { + return fmt.Errorf("listing task spawners: %w", err) + } + printTaskSpawnerTable(os.Stdout, tsList.Items) + return nil + }, + } +} + func newGetTaskCommand(cfg *ClientConfig) *cobra.Command { var output string diff --git a/internal/cli/printer.go b/internal/cli/printer.go index 151051001..f67c4362f 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -57,6 +57,61 @@ func printTaskDetail(w io.Writer, t *axonv1alpha1.Task) { } } +func printTaskSpawnerTable(w io.Writer, spawners []axonv1alpha1.TaskSpawner) { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tSOURCE\tPHASE\tDISCOVERED\tTASKS\tAGE") + for _, s := range spawners { + age := duration.HumanDuration(time.Since(s.CreationTimestamp.Time)) + source := "" + if s.Spec.When.GitHubIssues != nil { + source = s.Spec.When.GitHubIssues.Owner + "/" + s.Spec.When.GitHubIssues.Repo + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%s\n", + s.Name, source, s.Status.Phase, + s.Status.TotalDiscovered, s.Status.TotalTasksCreated, age) + } + tw.Flush() +} + +func printTaskSpawnerDetail(w io.Writer, ts *axonv1alpha1.TaskSpawner) { + printField(w, "Name", ts.Name) + printField(w, "Namespace", ts.Namespace) + printField(w, "Phase", string(ts.Status.Phase)) + if ts.Spec.When.GitHubIssues != nil { + gh := ts.Spec.When.GitHubIssues + printField(w, "Source", "GitHub Issues") + printField(w, "Repository", gh.Owner+"/"+gh.Repo) + if gh.State != "" { + printField(w, "State", gh.State) + } + if len(gh.Labels) > 0 { + printField(w, "Labels", fmt.Sprintf("%v", gh.Labels)) + } + } + printField(w, "Task Type", ts.Spec.TaskTemplate.Type) + if ts.Spec.TaskTemplate.Model != "" { + printField(w, "Model", ts.Spec.TaskTemplate.Model) + } + if ts.Spec.TaskTemplate.Workspace != nil { + printField(w, "Workspace Repo", ts.Spec.TaskTemplate.Workspace.Repo) + if ts.Spec.TaskTemplate.Workspace.Ref != "" { + printField(w, "Workspace Ref", ts.Spec.TaskTemplate.Workspace.Ref) + } + } + printField(w, "Poll Interval", ts.Spec.PollInterval) + if ts.Status.DeploymentName != "" { + printField(w, "Deployment", ts.Status.DeploymentName) + } + printField(w, "Discovered", fmt.Sprintf("%d", ts.Status.TotalDiscovered)) + printField(w, "Tasks Created", fmt.Sprintf("%d", ts.Status.TotalTasksCreated)) + if ts.Status.LastDiscoveryTime != nil { + printField(w, "Last Discovery", ts.Status.LastDiscoveryTime.Time.Format(time.RFC3339)) + } + if ts.Status.Message != "" { + printField(w, "Message", ts.Status.Message) + } +} + func printField(w io.Writer, label, value string) { fmt.Fprintf(w, "%-20s%s\n", label+":", value) } diff --git a/internal/controller/taskspawner_controller.go b/internal/controller/taskspawner_controller.go new file mode 100644 index 000000000..d647bb88a --- /dev/null +++ b/internal/controller/taskspawner_controller.go @@ -0,0 +1,304 @@ +package controller + +import ( + "context" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + axonv1alpha1 "github.com/gjkim42/axon/api/v1alpha1" +) + +const ( + taskSpawnerFinalizer = "axon.io/taskspawner-finalizer" +) + +// TaskSpawnerReconciler reconciles a TaskSpawner object. +type TaskSpawnerReconciler struct { + client.Client + Scheme *runtime.Scheme + DeploymentBuilder *DeploymentBuilder +} + +// +kubebuilder:rbac:groups=axon.io,resources=taskspawners,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=axon.io,resources=taskspawners/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=axon.io,resources=taskspawners/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create + +// Reconcile handles TaskSpawner reconciliation. +func (r *TaskSpawnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var ts axonv1alpha1.TaskSpawner + if err := r.Get(ctx, req.NamespacedName, &ts); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + logger.Error(err, "unable to fetch TaskSpawner") + return ctrl.Result{}, err + } + + // Handle deletion + if !ts.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, &ts) + } + + // Add finalizer if not present + if !controllerutil.ContainsFinalizer(&ts, taskSpawnerFinalizer) { + controllerutil.AddFinalizer(&ts, taskSpawnerFinalizer) + if err := r.Update(ctx, &ts); err != nil { + logger.Error(err, "unable to add finalizer") + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + + // Check if Deployment already exists + var deploy appsv1.Deployment + deployExists := true + if err := r.Get(ctx, req.NamespacedName, &deploy); err != nil { + if apierrors.IsNotFound(err) { + deployExists = false + } else { + logger.Error(err, "unable to fetch Deployment") + return ctrl.Result{}, err + } + } + + // Ensure ServiceAccount and RoleBinding exist in the namespace + if err := r.ensureSpawnerRBAC(ctx, ts.Namespace); err != nil { + logger.Error(err, "unable to ensure spawner RBAC") + return ctrl.Result{}, err + } + + // Create Deployment if it doesn't exist + if !deployExists { + return r.createDeployment(ctx, &ts) + } + + // Update Deployment if spec changed + if err := r.updateDeployment(ctx, &ts, &deploy); err != nil { + logger.Error(err, "unable to update Deployment") + return ctrl.Result{}, err + } + + // Update status with deployment name if not set + if ts.Status.DeploymentName != deploy.Name { + ts.Status.DeploymentName = deploy.Name + if ts.Status.Phase == "" { + ts.Status.Phase = axonv1alpha1.TaskSpawnerPhasePending + } + if err := r.Status().Update(ctx, &ts); err != nil { + logger.Error(err, "unable to update TaskSpawner status") + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// handleDeletion handles TaskSpawner deletion. +func (r *TaskSpawnerReconciler) handleDeletion(ctx context.Context, ts *axonv1alpha1.TaskSpawner) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if controllerutil.ContainsFinalizer(ts, taskSpawnerFinalizer) { + // The Deployment will be garbage collected via owner reference, + // but we remove the finalizer to allow the TaskSpawner to be deleted. + controllerutil.RemoveFinalizer(ts, taskSpawnerFinalizer) + if err := r.Update(ctx, ts); err != nil { + logger.Error(err, "unable to remove finalizer") + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// createDeployment creates a Deployment for the TaskSpawner. +func (r *TaskSpawnerReconciler) createDeployment(ctx context.Context, ts *axonv1alpha1.TaskSpawner) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + deploy := r.DeploymentBuilder.Build(ts) + + // Set owner reference + if err := controllerutil.SetControllerReference(ts, deploy, r.Scheme); err != nil { + logger.Error(err, "unable to set owner reference") + return ctrl.Result{}, err + } + + if err := r.Create(ctx, deploy); err != nil { + if apierrors.IsAlreadyExists(err) { + return ctrl.Result{Requeue: true}, nil + } + logger.Error(err, "unable to create Deployment") + return ctrl.Result{}, err + } + + logger.Info("created Deployment", "deployment", deploy.Name) + + // Update status + ts.Status.Phase = axonv1alpha1.TaskSpawnerPhasePending + ts.Status.DeploymentName = deploy.Name + if err := r.Status().Update(ctx, ts); err != nil { + logger.Error(err, "unable to update TaskSpawner status") + return ctrl.Result{}, err + } + + return ctrl.Result{Requeue: true}, nil +} + +// updateDeployment updates the Deployment to match the desired spec if it has drifted. +func (r *TaskSpawnerReconciler) updateDeployment(ctx context.Context, ts *axonv1alpha1.TaskSpawner, deploy *appsv1.Deployment) error { + logger := log.FromContext(ctx) + + desired := r.DeploymentBuilder.Build(ts) + + // Compare container spec (image, args, env) + if len(deploy.Spec.Template.Spec.Containers) == 0 { + return nil + } + current := deploy.Spec.Template.Spec.Containers[0] + target := desired.Spec.Template.Spec.Containers[0] + + needsUpdate := current.Image != target.Image || + !equalStringSlices(current.Args, target.Args) || + !equalEnvVars(current.Env, target.Env) + + if !needsUpdate { + return nil + } + + deploy.Spec.Template.Spec.Containers[0].Image = target.Image + deploy.Spec.Template.Spec.Containers[0].Args = target.Args + deploy.Spec.Template.Spec.Containers[0].Env = target.Env + + if err := r.Update(ctx, deploy); err != nil { + return err + } + + logger.Info("updated Deployment", "deployment", deploy.Name) + return nil +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalEnvVars(a, b []corev1.EnvVar) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Name != b[i].Name { + return false + } + if a[i].Value != b[i].Value { + return false + } + if (a[i].ValueFrom == nil) != (b[i].ValueFrom == nil) { + return false + } + if a[i].ValueFrom != nil && b[i].ValueFrom != nil { + if (a[i].ValueFrom.SecretKeyRef == nil) != (b[i].ValueFrom.SecretKeyRef == nil) { + return false + } + if a[i].ValueFrom.SecretKeyRef != nil && b[i].ValueFrom.SecretKeyRef != nil { + if a[i].ValueFrom.SecretKeyRef.Name != b[i].ValueFrom.SecretKeyRef.Name || + a[i].ValueFrom.SecretKeyRef.Key != b[i].ValueFrom.SecretKeyRef.Key { + return false + } + } + } + } + return true +} + +// ensureSpawnerRBAC ensures a ServiceAccount and RoleBinding exist in the namespace. +func (r *TaskSpawnerReconciler) ensureSpawnerRBAC(ctx context.Context, namespace string) error { + logger := log.FromContext(ctx) + + // Ensure ServiceAccount + var sa corev1.ServiceAccount + if err := r.Get(ctx, types.NamespacedName{Name: SpawnerServiceAccount, Namespace: namespace}, &sa); err != nil { + if !apierrors.IsNotFound(err) { + return err + } + sa = corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: SpawnerServiceAccount, + Namespace: namespace, + }, + } + if err := r.Create(ctx, &sa); err != nil { + if !apierrors.IsAlreadyExists(err) { + return err + } + } else { + logger.Info("created ServiceAccount", "namespace", namespace, "name", SpawnerServiceAccount) + } + } + + // Ensure RoleBinding + rbName := SpawnerServiceAccount + var rb rbacv1.RoleBinding + if err := r.Get(ctx, types.NamespacedName{Name: rbName, Namespace: namespace}, &rb); err != nil { + if !apierrors.IsNotFound(err) { + return err + } + rb = rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: rbName, + Namespace: namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: SpawnerClusterRole, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: SpawnerServiceAccount, + Namespace: namespace, + }, + }, + } + if err := r.Create(ctx, &rb); err != nil { + if !apierrors.IsAlreadyExists(err) { + return err + } + } else { + logger.Info("created RoleBinding", "namespace", namespace, "name", rbName) + } + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TaskSpawnerReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&axonv1alpha1.TaskSpawner{}). + Owns(&appsv1.Deployment{}). + Complete(r) +} diff --git a/internal/controller/taskspawner_deployment_builder.go b/internal/controller/taskspawner_deployment_builder.go new file mode 100644 index 000000000..f3a7e7d67 --- /dev/null +++ b/internal/controller/taskspawner_deployment_builder.go @@ -0,0 +1,95 @@ +package controller + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + axonv1alpha1 "github.com/gjkim42/axon/api/v1alpha1" +) + +const ( + // DefaultSpawnerImage is the default image for the spawner binary. + DefaultSpawnerImage = "gjkim42/axon-spawner:latest" + + // SpawnerServiceAccount is the service account used by spawner Deployments. + SpawnerServiceAccount = "axon-spawner" + + // SpawnerClusterRole is the ClusterRole referenced by spawner RoleBindings. + SpawnerClusterRole = "axon-spawner-role" +) + +// DeploymentBuilder constructs Kubernetes Deployments for TaskSpawners. +type DeploymentBuilder struct { + SpawnerImage string + SpawnerImagePullPolicy corev1.PullPolicy +} + +// NewDeploymentBuilder creates a new DeploymentBuilder. +func NewDeploymentBuilder() *DeploymentBuilder { + return &DeploymentBuilder{SpawnerImage: DefaultSpawnerImage} +} + +// Build creates a Deployment for the given TaskSpawner. +func (b *DeploymentBuilder) Build(ts *axonv1alpha1.TaskSpawner) *appsv1.Deployment { + replicas := int32(1) + + args := []string{ + "--taskspawner-name=" + ts.Name, + "--taskspawner-namespace=" + ts.Namespace, + } + + var envVars []corev1.EnvVar + if ts.Spec.When.GitHubIssues != nil && ts.Spec.When.GitHubIssues.TokenSecretRef != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: "GITHUB_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: ts.Spec.When.GitHubIssues.TokenSecretRef.Name, + }, + Key: "GITHUB_TOKEN", + }, + }, + }) + } + + labels := map[string]string{ + "app.kubernetes.io/name": "axon", + "app.kubernetes.io/component": "spawner", + "app.kubernetes.io/managed-by": "axon-controller", + "axon.io/taskspawner": ts.Name, + } + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: ts.Name, + Namespace: ts.Namespace, + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: SpawnerServiceAccount, + RestartPolicy: corev1.RestartPolicyAlways, + Containers: []corev1.Container{ + { + Name: "spawner", + Image: b.SpawnerImage, + ImagePullPolicy: b.SpawnerImagePullPolicy, + Args: args, + Env: envVars, + }, + }, + }, + }, + }, + } +} diff --git a/internal/source/github.go b/internal/source/github.go new file mode 100644 index 000000000..16c874bee --- /dev/null +++ b/internal/source/github.go @@ -0,0 +1,218 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" +) + +const ( + defaultBaseURL = "https://api.github.com" + + // maxPages limits the number of pages fetched from the GitHub API to prevent + // unbounded API calls for repositories with many issues. + maxPages = 10 + + // maxCommentBytes limits the total size of concatenated comments per issue. + maxCommentBytes = 64 * 1024 +) + +// GitHubSource discovers issues from a GitHub repository. +type GitHubSource struct { + Owner string + Repo string + Labels []string + State string + Token string + BaseURL string + Client *http.Client +} + +type githubIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Labels []githubLabel `json:"labels"` +} + +type githubLabel struct { + Name string `json:"name"` +} + +type githubComment struct { + Body string `json:"body"` +} + +func (s *GitHubSource) baseURL() string { + if s.BaseURL != "" { + return s.BaseURL + } + return defaultBaseURL +} + +func (s *GitHubSource) httpClient() *http.Client { + if s.Client != nil { + return s.Client + } + return http.DefaultClient +} + +// Discover fetches issues from GitHub and returns them as WorkItems. +func (s *GitHubSource) Discover(ctx context.Context) ([]WorkItem, error) { + issues, err := s.fetchAllIssues(ctx) + if err != nil { + return nil, err + } + + var items []WorkItem + for _, issue := range issues { + var labels []string + for _, l := range issue.Labels { + labels = append(labels, l.Name) + } + + comments, err := s.fetchComments(ctx, issue.Number) + if err != nil { + return nil, fmt.Errorf("fetching comments for issue #%d: %w", issue.Number, err) + } + + items = append(items, WorkItem{ + ID: strconv.Itoa(issue.Number), + Number: issue.Number, + Title: issue.Title, + Body: issue.Body, + URL: issue.HTMLURL, + Labels: labels, + Comments: comments, + }) + } + + return items, nil +} + +func (s *GitHubSource) fetchAllIssues(ctx context.Context) ([]githubIssue, error) { + var allIssues []githubIssue + + pageURL := s.buildIssuesURL() + + for page := 0; pageURL != "" && page < maxPages; page++ { + issues, nextURL, err := s.fetchIssuesPage(ctx, pageURL) + if err != nil { + return nil, err + } + allIssues = append(allIssues, issues...) + pageURL = nextURL + } + + return allIssues, nil +} + +func (s *GitHubSource) buildIssuesURL() string { + u := fmt.Sprintf("%s/repos/%s/%s/issues", s.baseURL(), s.Owner, s.Repo) + + params := url.Values{} + params.Set("per_page", "100") + + state := s.State + if state == "" { + state = "open" + } + params.Set("state", state) + + if len(s.Labels) > 0 { + params.Set("labels", strings.Join(s.Labels, ",")) + } + + return u + "?" + params.Encode() +} + +func (s *GitHubSource) fetchIssuesPage(ctx context.Context, pageURL string) ([]githubIssue, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) + if err != nil { + return nil, "", fmt.Errorf("creating request: %w", err) + } + + if s.Token != "" { + req.Header.Set("Authorization", "token "+s.Token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, "", fmt.Errorf("fetching issues: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var issues []githubIssue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, "", fmt.Errorf("decoding issues: %w", err) + } + + nextURL := parseNextLink(resp.Header.Get("Link")) + + return issues, nextURL, nil +} + +func (s *GitHubSource) fetchComments(ctx context.Context, issueNumber int) (string, error) { + u := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100", s.baseURL(), s.Owner, s.Repo, issueNumber) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + if s.Token != "" { + req.Header.Set("Authorization", "token "+s.Token) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("fetching comments: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var comments []githubComment + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + return "", fmt.Errorf("decoding comments: %w", err) + } + + var parts []string + totalBytes := 0 + for _, c := range comments { + totalBytes += len(c.Body) + if totalBytes > maxCommentBytes { + break + } + parts = append(parts, c.Body) + } + + return strings.Join(parts, "\n---\n"), nil +} + +var linkNextRe = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) + +func parseNextLink(header string) string { + matches := linkNextRe.FindStringSubmatch(header) + if len(matches) < 2 { + return "" + } + return matches[1] +} diff --git a/internal/source/github_test.go b/internal/source/github_test.go new file mode 100644 index 000000000..1a1227838 --- /dev/null +++ b/internal/source/github_test.go @@ -0,0 +1,286 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDiscover(t *testing.T) { + issues := []githubIssue{ + {Number: 1, Title: "Bug 1", Body: "Body 1", HTMLURL: "https://github.com/owner/repo/issues/1", Labels: []githubLabel{{Name: "bug"}}}, + {Number: 2, Title: "Bug 2", Body: "Body 2", HTMLURL: "https://github.com/owner/repo/issues/2", Labels: []githubLabel{{Name: "bug"}, {Name: "help wanted"}}}, + {Number: 3, Title: "Feature", Body: "Body 3", HTMLURL: "https://github.com/owner/repo/issues/3", Labels: nil}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/owner/repo/issues": + json.NewEncoder(w).Encode(issues) + case r.URL.Path == "/repos/owner/repo/issues/1/comments" || + r.URL.Path == "/repos/owner/repo/issues/2/comments" || + r.URL.Path == "/repos/owner/repo/issues/3/comments": + json.NewEncoder(w).Encode([]githubComment{}) + } + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(items) != 3 { + t.Fatalf("expected 3 items, got %d", len(items)) + } + + if items[0].ID != "1" || items[0].Title != "Bug 1" || items[0].Body != "Body 1" { + t.Errorf("unexpected item[0]: %+v", items[0]) + } + if items[0].URL != "https://github.com/owner/repo/issues/1" { + t.Errorf("unexpected URL: %s", items[0].URL) + } + if len(items[0].Labels) != 1 || items[0].Labels[0] != "bug" { + t.Errorf("unexpected labels: %v", items[0].Labels) + } + if items[1].Number != 2 { + t.Errorf("expected Number 2, got %d", items[1].Number) + } + if len(items[1].Labels) != 2 { + t.Errorf("expected 2 labels, got %d", len(items[1].Labels)) + } +} + +func TestDiscoverLabelFiltering(t *testing.T) { + var receivedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/owner/repo/issues" { + receivedQuery = r.URL.RawQuery + json.NewEncoder(w).Encode([]githubIssue{}) + } + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + Labels: []string{"bug", "help wanted"}, + BaseURL: server.URL, + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedQuery == "" { + t.Fatal("no query received") + } + // Check that labels param is present + if got := receivedQuery; got == "" { + t.Fatal("empty query") + } + // The URL should contain labels=bug%2Chelp+wanted or similar encoding + if !containsParam(receivedQuery, "labels") { + t.Errorf("expected labels param in query: %s", receivedQuery) + } +} + +func TestDiscoverStateFiltering(t *testing.T) { + var receivedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/owner/repo/issues" { + receivedQuery = r.URL.RawQuery + json.NewEncoder(w).Encode([]githubIssue{}) + } + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + State: "closed", + BaseURL: server.URL, + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !containsParam(receivedQuery, "state=closed") { + t.Errorf("expected state=closed in query: %s", receivedQuery) + } +} + +func TestDiscoverAuthHeader(t *testing.T) { + var authHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/owner/repo/issues" { + authHeader = r.Header.Get("Authorization") + json.NewEncoder(w).Encode([]githubIssue{}) + } + })) + defer server.Close() + + // With token + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + Token: "test-token", + BaseURL: server.URL, + } + + _, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if authHeader != "token test-token" { + t.Errorf("expected 'token test-token', got %q", authHeader) + } + + // Without token + authHeader = "" + s.Token = "" + _, err = s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if authHeader != "" { + t.Errorf("expected no auth header, got %q", authHeader) + } +} + +func TestDiscoverPagination(t *testing.T) { + page1 := []githubIssue{{Number: 1, Title: "Issue 1", Body: "Body 1", HTMLURL: "https://github.com/o/r/issues/1"}} + page2 := []githubIssue{{Number: 2, Title: "Issue 2", Body: "Body 2", HTMLURL: "https://github.com/o/r/issues/2"}} + + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/owner/repo/issues": + if r.URL.Query().Get("page") == "2" { + json.NewEncoder(w).Encode(page2) + return + } + w.Header().Set("Link", fmt.Sprintf(`<%s/repos/owner/repo/issues?page=2>; rel="next"`, serverURL)) + json.NewEncoder(w).Encode(page1) + case r.URL.Path == "/repos/owner/repo/issues/1/comments" || + r.URL.Path == "/repos/owner/repo/issues/2/comments": + json.NewEncoder(w).Encode([]githubComment{}) + } + })) + defer server.Close() + serverURL = server.URL + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].Number != 1 || items[1].Number != 2 { + t.Errorf("unexpected items: %+v", items) + } +} + +func TestDiscoverAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message":"rate limit exceeded"}`)) + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + } + + _, err := s.Discover(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestDiscoverEmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]githubIssue{}) + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 items, got %d", len(items)) + } +} + +func TestDiscoverComments(t *testing.T) { + issues := []githubIssue{ + {Number: 42, Title: "Bug", Body: "Details", HTMLURL: "https://github.com/o/r/issues/42"}, + } + comments := []githubComment{ + {Body: "First comment"}, + {Body: "Second comment"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/owner/repo/issues": + json.NewEncoder(w).Encode(issues) + case "/repos/owner/repo/issues/42/comments": + json.NewEncoder(w).Encode(comments) + } + })) + defer server.Close() + + s := &GitHubSource{ + Owner: "owner", + Repo: "repo", + BaseURL: server.URL, + } + + items, err := s.Discover(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + expected := "First comment\n---\nSecond comment" + if items[0].Comments != expected { + t.Errorf("expected comments %q, got %q", expected, items[0].Comments) + } +} + +func containsParam(query, param string) bool { + return strings.Contains(query, param) +} diff --git a/internal/source/prompt.go b/internal/source/prompt.go new file mode 100644 index 000000000..79487018a --- /dev/null +++ b/internal/source/prompt.go @@ -0,0 +1,56 @@ +package source + +import ( + "bytes" + "fmt" + "strings" + "text/template" +) + +const defaultPromptTemplate = `Issue #{{.Number}}: {{.Title}} + +{{.Body}} +{{- if .Comments}} + +Comments: +{{.Comments}} +{{- end}}` + +// RenderPrompt renders a prompt for the given work item using the provided template. +// If promptTemplate is empty, a default template is used. +func RenderPrompt(promptTemplate string, item WorkItem) (string, error) { + tmplStr := promptTemplate + if tmplStr == "" { + tmplStr = defaultPromptTemplate + } + + tmpl, err := template.New("prompt").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("parsing prompt template: %w", err) + } + + data := struct { + ID string + Number int + Title string + Body string + URL string + Labels string + Comments string + }{ + ID: item.ID, + Number: item.Number, + Title: item.Title, + Body: item.Body, + URL: item.URL, + Labels: strings.Join(item.Labels, ", "), + Comments: item.Comments, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("executing prompt template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/source/prompt_test.go b/internal/source/prompt_test.go new file mode 100644 index 000000000..4e892cd09 --- /dev/null +++ b/internal/source/prompt_test.go @@ -0,0 +1,113 @@ +package source + +import ( + "strings" + "testing" +) + +func TestRenderPromptDefault(t *testing.T) { + item := WorkItem{ + ID: "42", + Number: 42, + Title: "Fix the login bug", + Body: "Users cannot log in after the update.", + URL: "https://github.com/o/r/issues/42", + Labels: []string{"bug"}, + } + + result, err := RenderPrompt("", item) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(result, "#42") { + t.Errorf("expected issue number in output: %s", result) + } + if !strings.Contains(result, "Fix the login bug") { + t.Errorf("expected title in output: %s", result) + } + if !strings.Contains(result, "Users cannot log in") { + t.Errorf("expected body in output: %s", result) + } +} + +func TestRenderPromptCustom(t *testing.T) { + item := WorkItem{ + Number: 7, + Title: "Add dark mode", + } + + result, err := RenderPrompt("Fix {{.Title}} (issue #{{.Number}})", item) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "Fix Add dark mode (issue #7)" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestRenderPromptWithComments(t *testing.T) { + item := WorkItem{ + Number: 1, + Title: "Test", + Body: "Body", + Comments: "A comment", + } + + result, err := RenderPrompt("", item) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(result, "Comments:") { + t.Errorf("expected Comments section in output: %s", result) + } + if !strings.Contains(result, "A comment") { + t.Errorf("expected comment text in output: %s", result) + } + + // Without comments + item.Comments = "" + result, err = RenderPrompt("", item) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(result, "Comments:") { + t.Errorf("expected no Comments section in output: %s", result) + } +} + +func TestRenderPromptAllVariables(t *testing.T) { + item := WorkItem{ + ID: "99", + Number: 99, + Title: "T", + Body: "B", + URL: "U", + Labels: []string{"a", "b"}, + Comments: "C", + } + + tmpl := "{{.ID}} {{.Number}} {{.Title}} {{.Body}} {{.URL}} {{.Labels}} {{.Comments}}" + result, err := RenderPrompt(tmpl, item) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "99 99 T B U a, b C" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestRenderPromptInvalidTemplate(t *testing.T) { + item := WorkItem{} + + _, err := RenderPrompt("{{.Invalid", item) + if err == nil { + t.Fatal("expected error for invalid template") + } +} diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 000000000..632e9e9e8 --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,19 @@ +package source + +import "context" + +// WorkItem represents a discovered work item from an external source. +type WorkItem struct { + ID string + Number int + Title string + Body string + URL string + Labels []string + Comments string +} + +// Source discovers work items from an external system. +type Source interface { + Discover(ctx context.Context) ([]WorkItem, error) +} diff --git a/test/e2e/suite_test.go b/test/e2e/suite_test.go index f5c8a4775..90456ee6d 100644 --- a/test/e2e/suite_test.go +++ b/test/e2e/suite_test.go @@ -10,7 +10,10 @@ import ( const testModel = "haiku" -var oauthToken string +var ( + oauthToken string + githubToken string +) func TestE2E(t *testing.T) { RegisterFailHandler(Fail) @@ -22,4 +25,5 @@ var _ = BeforeSuite(func() { if oauthToken == "" { Skip("CLAUDE_CODE_OAUTH_TOKEN not set, skipping e2e tests") } + githubToken = os.Getenv("GITHUB_TOKEN") }) diff --git a/test/e2e/taskspawner_test.go b/test/e2e/taskspawner_test.go new file mode 100644 index 000000000..66ee951cc --- /dev/null +++ b/test/e2e/taskspawner_test.go @@ -0,0 +1,147 @@ +package e2e + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const taskSpawnerName = "e2e-test-spawner" + +var _ = Describe("TaskSpawner", func() { + BeforeEach(func() { + if githubToken == "" { + Skip("GITHUB_TOKEN not set, skipping TaskSpawner e2e tests") + } + + By("cleaning up existing resources") + kubectl("delete", "taskspawner", taskSpawnerName, "--ignore-not-found") + kubectl("delete", "secret", "github-token", "--ignore-not-found") + kubectl("delete", "secret", "claude-credentials", "--ignore-not-found") + }) + + AfterEach(func() { + if CurrentSpecReport().Failed() { + By("collecting debug info on failure") + GinkgoWriter.Println("=== Debug: TaskSpawner status ===") + kubectl("get", "taskspawner", taskSpawnerName, "-o", "yaml") + GinkgoWriter.Println("=== Debug: Deployment status ===") + kubectl("get", "deployment", taskSpawnerName, "-o", "yaml") + GinkgoWriter.Println("=== Debug: Controller logs ===") + kubectl("logs", "-n", "axon-system", "deployment/axon-controller-manager", "--tail=50") + } + + By("cleaning up test resources") + kubectl("delete", "taskspawner", taskSpawnerName, "--ignore-not-found") + kubectl("delete", "secret", "github-token", "--ignore-not-found") + kubectl("delete", "secret", "claude-credentials", "--ignore-not-found") + }) + + It("should create a spawner Deployment and discover issues", func() { + By("creating GitHub token secret") + Expect(kubectlWithInput("", "create", "secret", "generic", "github-token", + "--from-literal=GITHUB_TOKEN="+githubToken)).To(Succeed()) + + By("creating OAuth credentials secret") + Expect(kubectlWithInput("", "create", "secret", "generic", "claude-credentials", + "--from-literal=CLAUDE_CODE_OAUTH_TOKEN="+oauthToken)).To(Succeed()) + + By("creating a TaskSpawner") + tsYAML := `apiVersion: axon.io/v1alpha1 +kind: TaskSpawner +metadata: + name: ` + taskSpawnerName + ` +spec: + when: + githubIssues: + owner: gjkim42 + repo: axon + labels: [bug] + state: open + tokenSecretRef: + name: github-token + taskTemplate: + type: claude-code + credentials: + type: oauth + secretRef: + name: claude-credentials + promptTemplate: "Fix: {{.Title}}\n{{.Body}}" + pollInterval: 1m +` + Expect(kubectlWithInput(tsYAML, "apply", "-f", "-")).To(Succeed()) + + By("waiting for Deployment to become available") + Eventually(func() error { + return kubectlWithInput("", "wait", "--for=condition=available", "deployment/"+taskSpawnerName, "--timeout=10s") + }, 2*time.Minute, 10*time.Second).Should(Succeed()) + + By("waiting for TaskSpawner phase to become Running") + Eventually(func() string { + return kubectlOutput("get", "taskspawner", taskSpawnerName, "-o", "jsonpath={.status.phase}") + }, 3*time.Minute, 10*time.Second).Should(Equal("Running")) + + By("verifying at least one Task was created") + Eventually(func() string { + return kubectlOutput("get", "tasks", "-l", "axon.io/taskspawner="+taskSpawnerName, "-o", "name") + }, 3*time.Minute, 10*time.Second).ShouldNot(BeEmpty()) + }) + + It("should be accessible via CLI", func() { + By("creating a TaskSpawner") + tsYAML := `apiVersion: axon.io/v1alpha1 +kind: TaskSpawner +metadata: + name: ` + taskSpawnerName + ` +spec: + when: + githubIssues: + owner: gjkim42 + repo: axon + taskTemplate: + type: claude-code + credentials: + type: oauth + secretRef: + name: claude-credentials + pollInterval: 5m +` + Expect(kubectlWithInput(tsYAML, "apply", "-f", "-")).To(Succeed()) + + By("verifying axon get taskspawners lists it") + output := axonOutput("get", "taskspawners") + Expect(output).To(ContainSubstring(taskSpawnerName)) + + By("verifying axon get taskspawner shows detail") + output = axonOutput("get", "taskspawner", taskSpawnerName) + Expect(output).To(ContainSubstring(taskSpawnerName)) + Expect(output).To(ContainSubstring("GitHub Issues")) + + By("deleting via kubectl") + kubectl("delete", "taskspawner", taskSpawnerName) + + By("verifying it disappears from list") + Eventually(func() string { + return axonOutput("get", "taskspawners") + }, 30*time.Second, time.Second).ShouldNot(ContainSubstring(taskSpawnerName)) + }) +}) + +var _ = Describe("get taskspawner", func() { + It("should succeed with 'taskspawners' alias", func() { + axonOutput("get", "taskspawners") + }) + + It("should succeed with 'ts' alias", func() { + axonOutput("get", "ts") + }) + + It("should succeed with 'taskspawner' subcommand", func() { + axonOutput("get", "taskspawner") + }) + + It("should fail for a nonexistent taskspawner", func() { + axonFail("get", "taskspawner", "nonexistent-spawner") + }) +}) diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index e17583d69..cda36c517 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -69,6 +69,13 @@ var _ = BeforeSuite(func() { }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = (&controller.TaskSpawnerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + DeploymentBuilder: controller.NewDeploymentBuilder(), + }).SetupWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(ctx) diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go new file mode 100644 index 000000000..f3ebff3b1 --- /dev/null +++ b/test/integration/taskspawner_test.go @@ -0,0 +1,331 @@ +package integration + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + axonv1alpha1 "github.com/gjkim42/axon/api/v1alpha1" + "github.com/gjkim42/axon/internal/controller" +) + +var _ = Describe("TaskSpawner Controller", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + Context("When creating a TaskSpawner with GitHub source", func() { + It("Should create a Deployment and update status", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-github", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a TaskSpawner") + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + GitHubIssues: &axonv1alpha1.GitHubIssues{ + Owner: "gjkim42", + Repo: "axon", + State: "open", + }, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{ + Name: "claude-credentials", + }, + }, + }, + PollInterval: "5m", + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + tsLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdTS := &axonv1alpha1.TaskSpawner{} + + By("Verifying the TaskSpawner has a finalizer") + Eventually(func() bool { + err := k8sClient.Get(ctx, tsLookupKey, createdTS) + if err != nil { + return false + } + for _, f := range createdTS.Finalizers { + if f == "axon.io/taskspawner-finalizer" { + return true + } + } + return false + }, timeout, interval).Should(BeTrue()) + + By("Verifying a Deployment is created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Verifying the Deployment labels") + Expect(createdDeploy.Labels["axon.io/taskspawner"]).To(Equal(ts.Name)) + + By("Verifying the Deployment spec") + Expect(createdDeploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := createdDeploy.Spec.Template.Spec.Containers[0] + Expect(container.Name).To(Equal("spawner")) + Expect(container.Image).To(Equal(controller.DefaultSpawnerImage)) + Expect(container.Args).To(ConsistOf( + "--taskspawner-name="+ts.Name, + "--taskspawner-namespace="+ns.Name, + )) + + By("Verifying the ServiceAccount") + sa := &corev1.ServiceAccount{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: controller.SpawnerServiceAccount, Namespace: ns.Name}, sa) + return err == nil + }, timeout, interval).Should(BeTrue()) + Expect(createdDeploy.Spec.Template.Spec.ServiceAccountName).To(Equal(controller.SpawnerServiceAccount)) + + By("Verifying the Deployment has owner reference") + Expect(createdDeploy.OwnerReferences).To(HaveLen(1)) + Expect(createdDeploy.OwnerReferences[0].Name).To(Equal(ts.Name)) + Expect(createdDeploy.OwnerReferences[0].Kind).To(Equal("TaskSpawner")) + + By("Verifying TaskSpawner status has deploymentName") + Eventually(func() string { + err := k8sClient.Get(ctx, tsLookupKey, createdTS) + if err != nil { + return "" + } + return createdTS.Status.DeploymentName + }, timeout, interval).Should(Equal(ts.Name)) + + By("Verifying TaskSpawner phase is Pending") + Expect(createdTS.Status.Phase).To(Equal(axonv1alpha1.TaskSpawnerPhasePending)) + }) + }) + + Context("When creating a TaskSpawner with GitHub token secret", func() { + It("Should create a Deployment with GITHUB_TOKEN env var", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-token", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a Secret with GitHub token") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "github-token", + Namespace: ns.Name, + }, + StringData: map[string]string{ + "GITHUB_TOKEN": "test-github-token", + }, + } + Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + + By("Creating a TaskSpawner with tokenSecretRef") + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-token", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + GitHubIssues: &axonv1alpha1.GitHubIssues{ + Owner: "gjkim42", + Repo: "axon", + TokenSecretRef: &axonv1alpha1.SecretReference{ + Name: "github-token", + }, + }, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{ + Name: "claude-credentials", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + By("Verifying a Deployment is created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Verifying the Deployment has GITHUB_TOKEN env var") + container := createdDeploy.Spec.Template.Spec.Containers[0] + Expect(container.Env).To(HaveLen(1)) + Expect(container.Env[0].Name).To(Equal("GITHUB_TOKEN")) + Expect(container.Env[0].ValueFrom.SecretKeyRef.Name).To(Equal("github-token")) + Expect(container.Env[0].ValueFrom.SecretKeyRef.Key).To(Equal("GITHUB_TOKEN")) + }) + }) + + Context("When deleting a TaskSpawner", func() { + It("Should clean up and remove the finalizer", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-delete", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a TaskSpawner") + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-delete", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + GitHubIssues: &axonv1alpha1.GitHubIssues{ + Owner: "gjkim42", + Repo: "axon", + }, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{ + Name: "claude-credentials", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + tsLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdTS := &axonv1alpha1.TaskSpawner{} + + By("Waiting for the Deployment to be created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Deleting the TaskSpawner") + Expect(k8sClient.Delete(ctx, ts)).Should(Succeed()) + + By("Verifying the TaskSpawner is deleted") + Eventually(func() bool { + err := k8sClient.Get(ctx, tsLookupKey, createdTS) + return err != nil + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("Idempotency", func() { + It("Should not create duplicate Deployments on re-reconcile", func() { + By("Creating a namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-taskspawner-idempotent", + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + By("Creating a TaskSpawner") + ts := &axonv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spawner-idempotent", + Namespace: ns.Name, + }, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + GitHubIssues: &axonv1alpha1.GitHubIssues{ + Owner: "gjkim42", + Repo: "axon", + }, + }, + TaskTemplate: axonv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{ + Name: "claude-credentials", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ts)).Should(Succeed()) + + By("Waiting for the Deployment to be created") + deployLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + createdDeploy := &appsv1.Deployment{} + Eventually(func() bool { + err := k8sClient.Get(ctx, deployLookupKey, createdDeploy) + return err == nil + }, timeout, interval).Should(BeTrue()) + + By("Verifying only 1 Deployment exists") + deployList := &appsv1.DeploymentList{} + Expect(k8sClient.List(ctx, deployList, + client.InNamespace(ns.Name), + client.MatchingLabels{"axon.io/taskspawner": ts.Name}, + )).Should(Succeed()) + Expect(deployList.Items).To(HaveLen(1)) + + By("Triggering re-reconcile by updating TaskSpawner") + tsLookupKey := types.NamespacedName{Name: ts.Name, Namespace: ns.Name} + updatedTS := &axonv1alpha1.TaskSpawner{} + Expect(k8sClient.Get(ctx, tsLookupKey, updatedTS)).Should(Succeed()) + if updatedTS.Annotations == nil { + updatedTS.Annotations = map[string]string{} + } + updatedTS.Annotations["test"] = "trigger-reconcile" + Expect(k8sClient.Update(ctx, updatedTS)).Should(Succeed()) + + By("Verifying still only 1 Deployment exists after re-reconcile") + Consistently(func() int { + dl := &appsv1.DeploymentList{} + err := k8sClient.List(ctx, dl, + client.InNamespace(ns.Name), + client.MatchingLabels{"axon.io/taskspawner": ts.Name}, + ) + if err != nil { + return -1 + } + return len(dl.Items) + }, time.Second*2, interval).Should(Equal(1)) + }) + }) +})