Skip to content

Implement TaskSpawner CRD - #22

Merged
gjkim42 merged 2 commits into
mainfrom
axon-spawner
Feb 5, 2026
Merged

gjkim42 merged 2 commits into
mainfrom
axon-spawner

Conversation

@gjkim42

@gjkim42 gjkim42 commented Feb 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Introduces the TaskSpawner CRD that watches external sources (GitHub Issues) and automatically creates Task resources for each discovered item
  • Each TaskSpawner gets a dedicated spawner Deployment for isolation, with the main controller managing its lifecycle via finalizer pattern
  • Adds a source abstraction layer (internal/source/) with a GitHub implementation supporting pagination, auth, label/state filtering, and comment fetching
  • Includes CLI support (axon get taskspawner), RBAC updates, and a Dockerfile for the spawner binary

Architecture

TaskSpawner (CRD)
  └─ Deployment (spawner pod, created by main controller)
       └─ continuously polls source (e.g. GitHub issues)
            └─ creates Task resources
                 └─ Job → Pod (existing Task flow)

New Files

File Description
api/v1alpha1/taskspawner_types.go CRD types
internal/source/{source,github,prompt}.go Source abstraction + GitHub impl + prompt templates
cmd/spawner/main.go + Dockerfile Spawner binary
internal/controller/taskspawner_controller.go Main reconciler
internal/controller/taskspawner_deployment_builder.go Deployment builder
test/integration/taskspawner_test.go 4 integration tests
test/e2e/taskspawner_test.go E2E tests
internal/source/*_test.go 13 unit tests

Test plan

  • make build — all 3 binaries compile
  • make test — unit tests pass (13 source tests)
  • make test-integration — all 8 integration tests pass (4 existing + 4 new)
  • go vet ./... — clean
  • make test-e2e — requires cluster + GITHUB_TOKEN + CLAUDE_CODE_OAUTH_TOKEN
  • make image WHAT=cmd/spawner — spawner image builds

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings February 5, 2026 14:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces the TaskSpawner CRD, which enables automated Task creation by polling external sources like GitHub Issues. The feature adds a new controller that manages dedicated spawner Deployments for each TaskSpawner resource, implementing a clean separation between the main controller and the spawner logic.

Changes:

  • Implements TaskSpawner CRD with support for GitHub Issues as an external source
  • Adds a main controller (TaskSpawnerReconciler) that creates and manages spawner Deployments with finalizer-based lifecycle management
  • Creates a new spawner binary that continuously polls sources and creates Task resources
  • Extends CLI with "axon get taskspawner/taskspawners/ts" commands and adds printer functions
  • Updates RBAC permissions to allow Deployment and Secret management

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
api/v1alpha1/taskspawner_types.go Defines TaskSpawner CRD types including When, GitHubIssues, TaskTemplate, and status fields
internal/controller/taskspawner_controller.go Main reconciler managing TaskSpawner lifecycle, Deployment creation, and finalizer handling
internal/controller/taskspawner_deployment_builder.go Builds spawner Deployments with GitHub token mounting and proper labels
internal/source/source.go Defines Source interface and WorkItem abstraction for external sources
internal/source/github.go Implements GitHub source with pagination, auth, filtering, and comment fetching
internal/source/prompt.go Template rendering for converting WorkItems to Task prompts
cmd/spawner/main.go Spawner binary that polls sources and creates Tasks in a continuous loop
cmd/spawner/Dockerfile Multi-stage Dockerfile for spawner binary
test/integration/taskspawner_test.go Integration tests covering creation, deletion, token mounting, and idempotency
test/e2e/taskspawner_test.go E2E tests for end-to-end TaskSpawner workflow and CLI commands
internal/cli/get.go Adds taskspawner subcommand with aliases
internal/cli/printer.go Table and detail view printers for TaskSpawners
install.yaml RBAC updates for Deployments and Secrets
install-crd.yaml TaskSpawner CRD definition
Makefile Spawner image build target

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cmd/axon-spawner/main.go Outdated
Comment on lines +154 to +168
// Re-fetch before updating status to avoid conflicts
if err := cl.Get(ctx, key, &ts); err != nil {
return fmt.Errorf("re-fetching TaskSpawner: %w", err)
}

ts.Status.DiscoveredItems = append(ts.Status.DiscoveredItems, axonv1alpha1.WorkItemReference{
ID: item.ID,
TaskName: taskName,
})
ts.Status.TotalDiscovered = len(ts.Status.DiscoveredItems)
ts.Status.TotalTasksCreated++

if err := cl.Status().Update(ctx, &ts); err != nil {
log.Error(err, "updating TaskSpawner status", "item", item.ID)
}

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating the status within the loop for each new item creates multiple API calls and potential race conditions. If multiple items are discovered, each status update refetches the TaskSpawner and updates incrementally, which is inefficient. Consider batching the status updates: collect all the WorkItemReferences first, then perform a single status update after all tasks have been created.

Copilot uses AI. Check for mistakes.
Comment thread internal/source/github.go
Comment on lines +72 to +75
comments, err := s.fetchComments(ctx, issue.Number)
if err != nil {
return nil, fmt.Errorf("fetching comments for issue #%d: %w", issue.Number, err)
}

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetching comments for every issue synchronously can significantly slow down discovery, especially with many issues. Each issue triggers a separate API call, and if there are 100 issues, this results in 101+ API calls (1 for issues + 100 for comments). Consider making comment fetching optional, concurrent, or implementing a batch fetching strategy to improve performance.

Copilot uses AI. Check for mistakes.
Comment thread test/integration/taskspawner_test.go Outdated
Comment on lines +312 to +320
// Small wait for reconcile to process
time.Sleep(500 * time.Millisecond)

By("Verifying still only 1 Deployment exists")
Expect(k8sClient.List(ctx, deployList,
client.InNamespace(ns.Name),
client.MatchingLabels{"axon.io/taskspawner": ts.Name},
)).Should(Succeed())
Expect(deployList.Items).To(HaveLen(1))

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a fixed sleep time for reconciliation (500ms) creates a flaky test. If the reconciliation takes longer than 500ms due to system load or other factors, the test may pass even if idempotency is broken. Consider replacing this with an Eventually assertion that polls for a condition, or increase the timeout significantly if a sleep is necessary.

Suggested change
// Small wait for reconcile to process
time.Sleep(500 * time.Millisecond)
By("Verifying still only 1 Deployment exists")
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("Verifying still only 1 Deployment exists")
Eventually(func(g Gomega) {
err := k8sClient.List(ctx, deployList,
client.InNamespace(ns.Name),
client.MatchingLabels{"axon.io/taskspawner": ts.Name},
)
g.Expect(err).Should(Succeed())
g.Expect(deployList.Items).To(HaveLen(1))
}, timeout, interval).Should(Succeed())

Copilot uses AI. Check for mistakes.
Comment thread cmd/axon-spawner/main.go
}

for _, item := range newItems {
taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID)

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task name format uses the pattern "taskspawner-name-item-id", but Kubernetes resource names have a 253 character limit and must match DNS subdomain format. If the TaskSpawner name is long or the item ID is large, this could exceed the limit or fail validation. Consider adding validation or truncation logic to ensure generated task names are always valid Kubernetes resource names.

Copilot uses AI. Check for mistakes.
Comment thread internal/source/github.go
Comment on lines +160 to +190
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(resp.Body)
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
for _, c := range comments {
parts = append(parts, c.Body)

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment pagination is not implemented. The fetchComments function only fetches the first 100 comments with "per_page=100". If an issue has more than 100 comments, they will be silently truncated. Consider implementing pagination for comments similar to how it's done for issues, or document this limitation.

Suggested change
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(resp.Body)
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
for _, c := range comments {
parts = append(parts, c.Body)
pageURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=100", s.baseURL(), s.Owner, s.Repo, issueNumber)
var parts []string
for pageURL != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, 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(resp.Body)
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)
}
for _, c := range comments {
parts = append(parts, c.Body)
}
pageURL = parseNextLink(resp.Header.Get("Link"))

Copilot uses AI. Check for mistakes.
Comment on lines +76 to +82
Containers: []corev1.Container{
{
Name: "spawner",
Image: SpawnerImage,
Args: args,
Env: envVars,
},

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spawner container lacks resource limits and requests. Without these, the spawner pod can potentially consume unlimited CPU and memory, which could affect cluster stability, especially if many TaskSpawners are created or if the GitHub API returns a large number of issues. Consider adding reasonable resource limits and requests for production use.

Copilot uses AI. Check for mistakes.
Comment thread cmd/axon-spawner/main.go Outdated
Comment on lines +147 to +148
if err := cl.Create(ctx, task); err != nil {
log.Error(err, "creating Task", "task", taskName)

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When task creation fails, the error is logged but the loop continues without updating the status. This means failed items are not tracked, and on the next cycle, the spawner will attempt to create the same task again, potentially causing repeated failures. Consider tracking failed items in the status or implementing retry logic with exponential backoff.

Suggested change
if err := cl.Create(ctx, task); err != nil {
log.Error(err, "creating Task", "task", taskName)
// Retry task creation with exponential backoff to avoid tight failure loops on transient errors.
const maxCreateAttempts = 3
createBackoff := time.Second
var createErr error
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
createErr = cl.Create(ctx, task)
if createErr == nil {
break
}
log.Error(createErr, "creating Task", "task", taskName, "attempt", attempt, "maxAttempts", maxCreateAttempts)
if attempt < maxCreateAttempts {
time.Sleep(createBackoff)
createBackoff *= 2
}
}
if createErr != nil {
// Give up on this item after maxCreateAttempts; it may be retried in a future cycle.

Copilot uses AI. Check for mistakes.
Comment thread internal/source/github.go Outdated
Comment on lines +145 to +146
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the entire error response body with io.ReadAll without a size limit could lead to memory exhaustion if the GitHub API returns a very large error response. Consider limiting the number of bytes read or using io.LimitReader to prevent potential DoS scenarios.

Copilot uses AI. Check for mistakes.
Comment thread internal/source/github.go Outdated
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the entire error response body with io.ReadAll without a size limit could lead to memory exhaustion if the GitHub API returns a very large error response. Consider limiting the number of bytes read or using io.LimitReader to prevent potential DoS scenarios.

Suggested change
body, _ := io.ReadAll(resp.Body)
const maxErrorBodyBytes = 4096
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +25
// 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"`
}

Copilot AI Feb 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The When struct comment states "Exactly one field must be set" but there's no validation enforcing this constraint. Users could create a TaskSpawner with no source configured (all fields nil) or potentially multiple sources in the future. Consider adding a webhook validator or CRD validation rule to enforce that exactly one source type is specified.

Copilot uses AI. Check for mistakes.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 23 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="test/integration/taskspawner_test.go">

<violation number="1" location="test/integration/taskspawner_test.go:313">
P2: Use `Consistently` instead of `time.Sleep` for idempotency verification. Fixed-duration sleeps in tests are flaky - they may pass incorrectly if the sleep is too short, or waste time if too long. `Consistently` is the idiomatic Ginkgo approach for verifying a condition holds over time.</violation>
</file>

<file name="cmd/spawner/main.go">

<violation number="1" location="cmd/spawner/main.go:147">
P2: Missing `AlreadyExists` error check when creating Tasks. If a Task was created but status update failed, subsequent cycles will repeatedly try to create the same Task, logging errors each time without ever updating the status to track it. Should check for `apierrors.IsAlreadyExists(err)` and proceed to update status if the Task already exists.</violation>
</file>

<file name="test/e2e/taskspawner_test.go">

<violation number="1" location="test/e2e/taskspawner_test.go:73">
P2: This test references `claude-credentials` secret in the TaskSpawner YAML but never creates it. The `BeforeEach` deletes existing secrets, and Ginkgo runs each `It` block independently with its own `BeforeEach`/`AfterEach`. This will cause the TaskSpawner to be created with an invalid secret reference.</violation>
</file>

<file name="internal/source/github.go">

<violation number="1" location="internal/source/github.go:160">
P2: Missing pagination for comments. Unlike `fetchAllIssues`, this function only fetches the first 100 comments and ignores pagination links. Issues with more than 100 comments will have their comments silently truncated, potentially losing important context for the AI agent.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread test/integration/taskspawner_test.go Outdated
Comment thread cmd/axon-spawner/main.go
},
}

if err := cl.Create(ctx, task); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing AlreadyExists error check when creating Tasks. If a Task was created but status update failed, subsequent cycles will repeatedly try to create the same Task, logging errors each time without ever updating the status to track it. Should check for apierrors.IsAlreadyExists(err) and proceed to update status if the Task already exists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/spawner/main.go, line 147:

<comment>Missing `AlreadyExists` error check when creating Tasks. If a Task was created but status update failed, subsequent cycles will repeatedly try to create the same Task, logging errors each time without ever updating the status to track it. Should check for `apierrors.IsAlreadyExists(err)` and proceed to update status if the Task already exists.</comment>

<file context>
@@ -0,0 +1,225 @@
+			},
+		}
+
+		if err := cl.Create(ctx, task); err != nil {
+			log.Error(err, "creating Task", "task", taskName)
+			continue
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we did resolve it?

promptTemplate: "Fix: {{.Title}}\n{{.Body}}"
pollInterval: 1m
`
Expect(kubectlWithInput(tsYAML, "apply", "-f", "-")).To(Succeed())

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test references claude-credentials secret in the TaskSpawner YAML but never creates it. The BeforeEach deletes existing secrets, and Ginkgo runs each It block independently with its own BeforeEach/AfterEach. This will cause the TaskSpawner to be created with an invalid secret reference.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/e2e/taskspawner_test.go, line 73:

<comment>This test references `claude-credentials` secret in the TaskSpawner YAML but never creates it. The `BeforeEach` deletes existing secrets, and Ginkgo runs each `It` block independently with its own `BeforeEach`/`AfterEach`. This will cause the TaskSpawner to be created with an invalid secret reference.</comment>

<file context>
@@ -0,0 +1,147 @@
+    promptTemplate: "Fix: {{.Title}}\n{{.Body}}"
+  pollInterval: 1m
+`
+		Expect(kubectlWithInput(tsYAML, "apply", "-f", "-")).To(Succeed())
+
+		By("waiting for Deployment to become available")
</file context>
Fix with Cubic

Comment thread internal/source/github.go
}

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)

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing pagination for comments. Unlike fetchAllIssues, this function only fetches the first 100 comments and ignores pagination links. Issues with more than 100 comments will have their comments silently truncated, potentially losing important context for the AI agent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/source/github.go, line 160:

<comment>Missing pagination for comments. Unlike `fetchAllIssues`, this function only fetches the first 100 comments and ignores pagination links. Issues with more than 100 comments will have their comments silently truncated, potentially losing important context for the AI agent.</comment>

<file context>
@@ -0,0 +1,204 @@
+}
+
+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)
</file context>
Fix with Cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="internal/controller/taskspawner_controller.go">

<violation number="1" location="internal/controller/taskspawner_controller.go:175">
P2: Misleading log: "created ServiceAccount" is logged even when creation returns `IsAlreadyExists`. Move the log inside an else branch so it only logs when creation actually succeeds.</violation>

<violation number="2" location="internal/controller/taskspawner_controller.go:206">
P2: Misleading log: "created RoleBinding" is logged even when creation returns `IsAlreadyExists`. Move the log inside an else branch so it only logs when creation actually succeeds.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread internal/controller/taskspawner_controller.go Outdated
Comment thread internal/controller/taskspawner_controller.go Outdated
@gjkim42
gjkim42 force-pushed the axon-spawner branch 4 times, most recently from 9d3e344 to 672cbe8 Compare February 5, 2026 23:18
…ources

Add a new TaskSpawner CRD that watches external sources (e.g., GitHub
Issues) and automatically creates Task resources for each discovered
work item.

Architecture:
- Main controller creates a Deployment per TaskSpawner
- Spawner binary (cmd/axon-spawner) runs in the Deployment, polls
  the configured source, and creates Tasks with deduplication
- Source abstraction in internal/source/ (interface-based, GitHub
  implementation with pagination limits and comment size caps)
- Separate ServiceAccount and RBAC for spawner Deployments
  (least-privilege: read TaskSpawners, create Tasks)
- Deployment reconciliation when TaskSpawner spec changes

Includes unit tests, integration tests (envtest), and e2e tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants