Ark Controller Development
Guidance for developing the Ark Kubernetes operator in ark/.
When to use this skill
- Modifying Go type definitions (
api/v1alpha1/*_types.go) - Fixing CRD/Helm chart sync errors
- Adding new CRD fields or resources
CRD Generation Flow
api/v1alpha1/*_types.go # Go types with markers
↓
make manifests # Generates CRDs and syncs to Helm chart
↓
config/crd/bases/*.yaml # Source CRDs (auto-generated)
dist/chart/templates/crd/ # Helm chart CRDs (auto-synced)
make manifests automatically syncs source CRDs to the Helm chart while preserving templated headers.
Fixing "CRDs out of sync" Errors
When make build fails with CRD validation errors:
cd ark
make manifests
make build
Key Directories
| Directory | Purpose |
|---|---|
api/v1alpha1/ |
Go type definitions |
config/crd/bases/ |
Auto-generated source CRDs |
dist/chart/templates/crd/ |
Helm chart CRDs (auto-synced) |
internal/controller/ |
Reconciliation logic |
internal/webhook/ |
Admission webhooks |
internal/genai/ |
AI/ML execution logic |
Common Tasks
After Modifying Types or Comments
Go type comments become CRD field descriptions:
cd ark
make manifests
make build
After Any Go Code Change
make lint-fix # Format and auto-fix what the linter can fix
make lint # Fail fast on issues CI will reject — MANDATORY
make build # Build and validate
make test # Run unit + envtest suites
Before Opening a PR
Run the chainsaw e2e suite against a live cluster — unit + envtest don't exercise the full apply → reconcile → status loop. Prefer the deterministic mock-llm suite; only run the LLM-backed tests when the change specifically touches that path (saves cost + avoids flakes from real LLM providers).
# Deterministic suite (default)
(cd tests && chainsaw test --selector '!llm')
# LLM suite — only when your change needs it
(cd tests && chainsaw test --selector 'llm')
See the chainsaw skill for patterns and antipatterns (e.g. prefer
chainsaw assert over shell grep; prefer wait over polling).
make lint runs the same golangci-lint rules CI enforces. Running it
locally before pushing is non-negotiable — red PR checks cost more than
the minute the lint takes.
Leverage Existing Code — Don't Reinvent
Before writing a parser, struct, helper, retry loop, URL builder, or any utility, check whether it already exists in:
- Go standard library.
- Direct or transitive dependencies in
go.mod(controller-runtime,apimachinery, the SDK for whatever spec you're implementing,golang.org/x/...).
Hand-rolled code drifts, duplicates tests, and becomes maintenance burden. A short adapter delegating to a library is almost always better than a reimplementation.
Heuristic: if the thing you're writing mirrors an existing spec, file
format, header syntax, or common infra pattern, someone has already
written it. Search first: go doc <import-path>, ls $(go env GOMODCACHE)/<dep>.
Re-export library types via alias (type X = lib.X) rather than
defining a parallel struct with the same fields.
Worked example
Started this project by hand-rolling a WWW-Authenticate parser, RFC 9728
struct, RFC 8414 struct, and HTTP fetchers — ~150 LOC. The go-sdk
already shipped them in oauthex:
// Bad — 40-line parser, partial struct, duplicated HTTP wrapper.
func parseHeader(h string) (string, bool) { /* ... */ }
type Metadata struct { /* partial mirror of RFC */ }
func fetch(ctx, url) (*Metadata, error) { /* 30 lines */ }
// Good — thin adapter, full spec support, ~10 LOC.
import "github.com/modelcontextprotocol/go-sdk/oauthex"
type Metadata = oauthex.ProtectedResourceMetadata
func Fetch(ctx context.Context, metaURL, resourceURL string, timeout time.Duration) (*Metadata, error) {
return oauthex.GetProtectedResourceMetadata(ctx, metaURL, resourceURL, &http.Client{Timeout: timeout})
}
Same principle applies to retry/backoff (k8s.io/apimachinery/pkg/util/wait),
JSON patch (strategicpatch), condition updates
(meta.SetStatusCondition), HTTP clients, workqueues — whatever you're
touching, check the existing deps first.
Avoid Magic Numbers — Reuse Configurable Values
Never inline numeric durations, retry counts, or buffer sizes. Priority:
- User-configurable spec field — if the resource already has
spec.timeout,spec.pollInterval,spec.retries, use it. One operator knob should govern all related code paths in the same reconcile. - Package-level const with a name explaining why that number.
- Test-local const (e.g.
const testTimeout = 5 * time.Second) — never inline literals in tests.
Worked example
// Bad — parallel constant invented out of thin air.
const discoveryTimeout = 10 * time.Second
client := &http.Client{Timeout: discoveryTimeout}
// Good — plumb the MCPServer's spec.timeout; single source of truth.
func Fetch(ctx, url string, timeout time.Duration) ...
timeout := parseTimeout(mcpServer.Spec.Timeout)
Testing — not optional
Every code change ships with tests. A PR touching ark/internal/ with
no _test.go is incomplete. Applies to controller branches, webhooks,
helpers — not just public APIs.
| Change | Where | Framework |
|---|---|---|
Helper / parser in internal/<pkg>/ |
<file>_test.go |
stdlib testing, table-driven |
| Controller branch | internal/controller/<scenario>_test.go |
Ginkgo + envtest |
| Webhook rule | internal/webhook/v1/<resource>_webhook_test.go |
Ginkgo |
| HTTP logic | httptest.NewServer in same package |
stdlib testing |
Required coverage for a controller branch:
- Happy path → expected status + conditions.
- Each failure mode → distinct stable condition reason (no string-matching).
- Idempotency → re-reconcile doesn't emit duplicate events or bump
lastTransitionTime. - Regression guard → pre-existing branches unaffected.
Completion Checklist
Before saying "done":
-
make manifestsclean (if types changed) -
make lintclean — same rules CI enforces -
make buildgreen -
make testgreen — paste or reference output - Chainsaw deterministic suite (
chainsaw test --selector '!llm') green; LLM suite only if change requires it - New
_test.goexists for every new function / branch - Helm chart CRDs regenerated and committed
- OpenAPI / Python SDK / TypeScript SDK regenerated if CRD changed
- Running pod re-verified — devspace syncs source, but only a container restart picks up new Go binaries; tail controller logs after any reconciliation-path change