About ark-controller-development
A development skill for contributors working on the Ark Kubernetes operator in `ark/`. It covers CRD and Helm chart synchronization, implementation practices, and validation steps from tests to end-to-end checks.
Key features
- 01CRD generation and Helm chart sync
- 02Go implementation and dependency guidance
- 03Lint, build, and test procedures
- 04Controller and webhook test guidance
- 05Pre-PR validation checklist
Use cases
- 01Adding or changing CRD fields
- 02Resolving CRD synchronization failures
- 03Validating controller or webhook changes
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ mckinsey/ agents-at-scale-ark --skill ark-controller-developmentAfter installing, type/ark-controller-developmentin Claude Code to confirm it loaded.
Details
From the project READMEArk 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