Task Breakdown: Kubernetes Deployment Plugin
Ordered, granular tasks derived from
./plan.md. Each task is small enough to land in a single PR and ships with tests per Constitution Principle VI.
Feature ID: k8s-deployment
Plan: ./plan.md
Spec: ./spec.md
Status: Done
Last updated: 2026-05-05
How to use
- Tasks are sequential by default. Tasks marked
(parallel)can run alongside their predecessor. - Each task has explicit file paths so an implementer can pick it up cold.
- Test paths follow the existing
__tests__convention used bypackages/plugins/vercel/. - The phase numbering matches
plan.md§10 phased rollout: each phase is one PR.
Phase 1 — Plugin scaffold + works-config field (PR #1)
Goal: a discoverable but inert plugin and a provider-agnostic
deployProviderfield in.works/works.yml. The latter is independent of k8s and benefits Vercel immediately. Visibility ='hidden'until Phase 2 lands.
-
T1. Create plugin package skeleton at
packages/plugins/k8s/:package.jsonmirroringpackages/plugins/vercel/package.json:name: '@ever-works/k8s-plugin'dependencies: { '@kubernetes/client-node': '^1.0.0', 'js-yaml': '^4.1.0' }devDependencies: { '@types/js-yaml': '^4.0.9', /* same as vercel */ }everworks.plugin: { id: 'k8s', name: 'Kubernetes', category: 'deployment', capabilities: ['deployment'], builtIn: true, systemPlugin: true, autoEnable: true, visibility: 'hidden', description: 'Deploy works to a Kubernetes cluster' }
tsconfig.json,tsup.config.ts,vitest.config.ts— copy from vercel.README.md— short developer note linking to this spec.
-
T2 (parallel with T1). Add stub source files:
src/index.ts—export { KubernetesPlugin, KubernetesPlugin as default } from './k8s.plugin.js'; export * from './types.js';src/types.ts—KubernetesSettings,RegistryConfig,IngressClassDescriptor,KubernetesClusterInfo(perplan.md§3 DTOs).src/k8s.plugin.ts—KubernetesPluginclass withid/name/category/capabilities/configurationMode/settingsSchema/getManifest()populated; allIDeploymentPluginmethods thrownew Error('Kubernetes plugin: not yet implemented (Phase 2+)').src/errors.ts—K8sPluginErrorclass only (no scrubber yet).
-
T3. Run
pnpm installfrom repo root to register the new workspace package. -
T4. Confirm plugin discovery:
- Boot the API locally:
pnpm dev:api. curl http://localhost:3100/api/plugins?category=deploymentreturns bothvercelandk8sentries.- Test:
packages/plugins/k8s/src/__tests__/discovery.spec.ts— instantiates the plugin, asserts manifest matches the snapshot.
- Boot the API locally:
-
T5 (parallel with T4). Plugin metadata test at
packages/plugins/k8s/src/__tests__/k8s.plugin.metadata.spec.ts:id === 'k8s',name === 'Kubernetes',category === 'deployment',capabilitiescontains'deployment'.configurationMode === 'user-required'.- Manifest does NOT include
defaultForCapabilities(Vercel keeps that role). - Settings schema has
kubeconfigasx-secret: true,x-scope: 'user',x-widget: 'textarea'. - Settings schema has
registryas a discriminatedoneOfwith three branches; default{ kind: 'github' }. - Required fields are
['kubeconfig'].
-
T6. Update
docs/plugin-system/built-in-plugins.mdto add the Kubernetes row under Deployment. Increment any plugin count in the same doc per Constitution Principle VIII.
Works-config field (provider-agnostic)
- T6a. Add
deployProvider?: stringto theWorksConfiginterface/parser atpackages/agent/src/works-config/services/works-config.service.ts.- Test:
packages/agent/src/works-config/__tests__/works-config.service.spec.ts—parses a config with deployProvider: vercelandparses a config with deployProvider: k8sandrejects an empty deployProvider string.
- Test:
- T6b. Wire the field through
WorksConfigImportApplierService(find by grepping for the existing applier; resolve the deploy facade and validate the value againstgetAvailableProviders()).- On valid id → call
WorkLifecycleService.update({ deployProvider })(lifecycle service already validates against the same list — seeapps/api/.../work-lifecycle.service.ts:184). - On unknown id → emit
import_erroractivity-log event with the id and the list of available providers; do NOT touch the work. - Test:
__tests__/works-config-import-applier.service.spec.ts— three cases: validvercel, validk8s(with the deploy facade stub returning both), unknown id.
- On valid id → call
- T6c. Mirror the field in
packages/agent/src/works-config/works-config-data.tsso projection back to YAML round-trips.- Test: round-trip
parse → project → parsepreservesdeployProvider.
- Test: round-trip
- T6d (parallel with T6a). Document the new field in
docs/features/works-config.md(or its successor) with examples forvercelandk8s. - T6e. Activity-log event
deploy_provider_conflictwhenwork.deployProvider !== worksConfig.deployProviderat sync time; data repo wins per FR-18.- Test: assert the event is emitted with both values.
Phase 1 DoD: API boots; plugin appears in /api/plugins list; calling any deploy method throws Not yet implemented; a .works/works.yml with deployProvider: vercel already round-trips and applies (no k8s code path involved). Tests T4–T5, T6a–T6c, T6e green. UI shows nothing yet (visibility hidden).
Phase 2 — Settings, connection validation, registry & ingress strategies (PR #2)
Goal: user can paste a kubeconfig, hit save, see "Connected to cluster X (v1.30.4); detected ingress controllers: nginx, traefik". Registry strategy registry shipped (github default, dockerhub, generic). Plugin flips to
visibility: 'user-only'at the end of this phase.
-
T7.
kubeconfig.parser.ts:parseKubeconfig(yaml: string): { config, currentContext, server, clusterCa, fingerprint }usingjs-yaml.loadwith safe schema.- Throws
K8sPluginError('INVALID_YAML' | 'MISSING_CONTEXT' | 'MISSING_CLUSTER' | 'MISSING_USER', message). - Detects
users[].user.execand returns arequiresExecPlugin: trueflag (warn-only). - Test:
__tests__/kubeconfig.parser.spec.ts:- Valid kubeconfig with
current-context: foo→ returns the expected server URL. - Invalid YAML → throws
INVALID_YAML. - Empty contexts → throws
MISSING_CONTEXT. - kubeconfig with
execprovider → returnsrequiresExecPlugin: true. - Fixtures live under
__tests__/fixtures/kubeconfigs/.
- Valid kubeconfig with
-
T8.
errors.tsscrubber:scrubError(unknown): { code, message }with regex passes for kubeconfig YAML, bearer tokens, PEM blocks, registry passwords (passed in via factory).- Test:
__tests__/errors.spec.ts— assert each kind of secret is replaced with[REDACTED].
-
T9.
k8s-api.service.ts:createKubeConfig(yaml): KubeConfig(loads viaclient-node).getServerVersion(yaml): Promise<{ version, platform }>(usesVersionApi).listIngressClasses(yaml): Promise<IngressClassDescriptor[]>(usesNetworkingV1Api.listIngressClass()).getClusterIdentity(yaml): { name, server, fingerprint }.- All methods catch and run errors through
scrubError. - Test:
__tests__/k8s-api.service.spec.ts— mock@kubernetes/client-node(Vitestvi.mock), assert success path returns the mapped shape and failure path throws scrubbed errors.
Registry strategies (provider-pluggable)
- T9a.
registries/provider.ts—RegistryProviderinterface (perplan.md§5). - T9b.
registries/provider.registry.ts— keyed byRegistryConfig['kind']; exposesregister(kind, provider)for extension.- Test:
__tests__/registries/provider.registry.spec.ts— registers a fake kind, resolves it back; throws on unknown kind.
- Test:
- T9c.
registries/github.provider.ts:imageBase({ kind: 'github', owner })→ghcr.io/<owner>(owner falls back toctx.githubOwner).workflowLogin→docker login ghcr.io -u $GITHUB_ACTOR --password-stdinusingGITHUB_TOKEN.resolveVisibility(config, ctx)→ ifconfig.visibility === 'auto'(default) callctx.githubService.getRepository(owner, websiteRepo, token)(signature already exists atpackages/plugins/github/src/github.plugin.ts:116) and return'public'or'private'based onisPrivate. If'public'or'private'is set explicitly, return it as-is.pullSecret(config, ctx)→ returnsnullwhen resolved visibility is'public'. Otherwise uses a stored GitHub token (read viaPluginContext.getService('github')) scoped forread:packages; if unavailable, returns an error and the deploy fails fast with a clear message.- Test:
__tests__/registries/github.provider.spec.ts— assertions on the returned image base, login step, and pull-secret payload, plus visibility-resolution matrix:visibility: 'auto'× public website repo → no pull secret.visibility: 'auto'× private website repo → pull secret withread:packagestoken.visibility: 'public'× private website repo → no pull secret (explicit override wins).visibility: 'private'× public website repo → pull secret (explicit override wins).
- T9d.
registries/dockerhub.provider.ts.- Test:
__tests__/registries/dockerhub.provider.spec.ts.
- Test:
- T9e.
registries/generic.provider.ts.- Test:
__tests__/registries/generic.provider.spec.ts.
- Test:
Ingress strategies (controller-pluggable)
- T9f.
ingress/strategy.ts—IngressStrategyinterface;ingress/strategy.registry.ts— keyed bycontrollerstring;selectStrategy(controller)falls back to generic if no match.- Test:
__tests__/ingress/strategy.registry.spec.ts— registry lookup, fallback path, custom registration.
- Test:
- T9g.
ingress/nginx.strategy.ts— annotationsnginx.ingress.kubernetes.io/proxy-body-size,…/ssl-redirect, etc. as appropriate.- Test:
__tests__/ingress/nginx.strategy.spec.ts— snapshot of generated annotations + TLS section.
- Test:
- T9h.
ingress/traefik.strategy.ts— annotationstraefik.ingress.kubernetes.io/router.tls,…/router.entrypoints, etc.- Test:
__tests__/ingress/traefik.strategy.spec.ts.
- Test:
- T9i.
ingress/generic.strategy.ts— no annotations, plain Ingress.- Test:
__tests__/ingress/generic.strategy.spec.ts.
- Test:
Plugin wiring
-
T10. Implement
KubernetesPlugin.validateConnection():- Returns
{ success: true, message, details: { clusterName, serverUrl, serverVersion, ingressClasses, registryGithubReady } }on success. - Returns
{ success: false, message: '<scrubbed>' }on failure. - When
settings.registry?.kind === 'github'and the GitHub plugin is missing/unauthenticated → success can befalsewith a "connect GitHub first" message and asetupLinkto the GitHub plugin's settings. - Test:
__tests__/k8s.plugin.validate.spec.ts— five cases: empty kubeconfig, invalid YAML, valid kubeconfig but GHCR + no GitHub, valid kubeconfig with GHCR + connected GitHub, valid kubeconfig with non-GitHub registry kind.
- Returns
-
T11. Implement
KubernetesPlugin.validateToken(token):- For this plugin,
tokenIS the kubeconfig. ReturnstrueifvalidateConnectionwould succeed.
- For this plugin,
-
T12. Flip plugin
visibility: 'user-only'inpackage.jsonandgetManifest(). -
T13. Manual QA against:
- kind cluster (
kind create cluster) — happy path. - Cluster with expired client cert — error path.
- Cluster with both nginx and Traefik installed — both controllers appear in
validateConnection.details.ingressClasses.
- kind cluster (
-
T14. Update spec acceptance criteria "Configure & verify", "Configure & reject", "Ingress detection" to
[x]after manual QA passes.
Phase 2 DoD: visiting /en/settings/plugins/deployment shows Kubernetes; pasting a valid kubeconfig saves and shows cluster info + detected ingress controllers; invalid input is rejected gracefully; the registry-form discriminator switches sub-form when the user changes "GitHub" to "Docker Hub". Tests T7–T11 + T9a–T9i green.
Phase 3 — Deploy & status (PR #3)
Goal: a work with
deployProvider = 'k8s'deploys end-to-end and reachesready.
-
T15.
manifest.renderer.ts— pure functions:buildDeployment({ workId, namespace, image, replicas, labels })buildService({ workId, namespace, labels })(port 3000 / containerPort 3000)buildIngress({ workId, namespace, host, ingressClass?, tlsIssuer? })— only ifhostprovidedbuildImagePullSecret({ namespace, registry, username, password })- All return typed
V1Deployment/V1Service/V1Ingress/V1Secretobjects. - Labels:
ever-works.io/managed=true,ever-works.io/work-id=<id>,app.kubernetes.io/name=<work-slug>. - Test:
__tests__/manifest.renderer.spec.ts— snapshot every output for representative inputs; assert label correctness.
-
T16.
status.mapper.ts:mapDeploymentToStatus(deployment: V1Deployment): DeploymentResult['status']- Mapping table:
Available=True→'ready';Progressing=True(noAvailable) →'deploying';Available=False, ReplicaFailure=True→'error'; pending pods only →'building'. - Test:
__tests__/status.mapper.spec.ts— fixture-driven; one fixture per state.
-
T17. Add server-side apply helpers to
k8s-api.service.ts:apply(kubeconfig, manifest, fieldManager)usingKubeConfig.makeApiClient(CustomObjectsApi)PATCH withapplication/apply-patch+yaml.getDeployment(kubeconfig, namespace, name): Promise<V1Deployment | null>.waitForRollout(kubeconfig, namespace, name, timeoutMs): Promise<void>(polls every 2s).- Test: integration-style with mocked
@kubernetes/client-node— assert the right HTTP method/headers/body for SSA.
-
T18. Implement
KubernetesPlugin.deploy(config, kubeconfig):- Resolve registry strategy via
RegistryProviderRegistry.resolve(settings.registry?.kind ?? 'github')and compute image tag =${strategy.imageBase(...)}/${slug}:${shortSha}(sha provided inconfig.options.gitSha). - Resolve ingress strategy via
IngressStrategyRegistry.selectStrategy(controllerForClass(settings.ingressClass)). - Order: ensure namespace label → ensure imagePullSecret (from registry strategy, may be null) → SSA Deployment → SSA Service → (if host) SSA Ingress (annotations from ingress strategy).
- Return
{ id: '<namespace>/<work-id>', status: 'deploying', createdAt }. - On any step failure after Deployment is created,
kubectl rollout undois NOT performed (let user keep the rollout history); just returnerror. - Activity-log emit:
deployment_started,deployment_applied,deployment_succeeded/deployment_failed. Each event carriesregistryKindandingressController. - Test:
__tests__/k8s.plugin.deploy.spec.ts— happy path with each registry kind × each ingress strategy (matrix), image push failure (no Deployment created), apply failure mid-way (Deployment created, Service fails) — assert correct activity-log calls.
- Resolve registry strategy via
-
T19. Implement
KubernetesPlugin.getDeploymentStatus(deploymentId, kubeconfig):- Parse
<namespace>/<work-id>; fetch Deployment; map viastatus.mapper. - Return
{ id, status, url, createdAt, completedAt? }. URL =https://<ingressHost>if Ingress exists, else<service-cluster-ip>:3000placeholder. - Test: per fixture, asserts the expected status.
- Parse
-
T20. Implement
KubernetesPlugin.listProjects(kubeconfig):- List Deployments cluster-wide labelled
ever-works.io/managed=true; group byever-works.io/work-id. - Return
DeploymentProject[]. - Test: mocked listing returns 3 deployments → 3 projects.
- List Deployments cluster-wide labelled
-
T21. Implement
KubernetesPlugin.lookupExistingDeployment(projectName, kubeconfig):- Find Deployment by label
app.kubernetes.io/name=<projectName>. - Return
{ found, website?, deploymentState?, projectId? }. - Test: covers found and not-found.
- Find Deployment by label
-
T22. Implement
KubernetesPlugin.getTeams(kubeconfig)returning[](k8s has no teams concept). Test. -
T23. Add
deploy_k8s.yamlworkflow +Dockerfile+k8s/manifests/placeholder todirectory-web-templaterepo:- This is a separate repo (
ever-works/directory-web-template); coordinate the PR. - Workflow:
docker/build-push-action@v6→azure/setup-kubectl@v4→kubectl apply -k k8s/. - Inputs:
environment,image_tag,namespace,ingress_host?,registry_kind. - Secrets read:
KUBECONFIG; registry-kind-conditional secrets —GITHUB_TOKEN(auto, for GHCR), orREGISTRY_USERNAME+REGISTRY_PASSWORD(for dockerhub/generic), orREGISTRY_SERVER(generic only). - The workflow's
docker loginstep is generated from the registry strategy'sworkflowLogin()output (T9c–T9e); same for the cluster pull-secret apply step.
- This is a separate repo (
-
T24 (parallel with T23). Same for
directory-web-minimal-template.
Capability contract extension (provider-agnostic)
-
T24a. Extend
IDeploymentPluginatpackages/plugin/src/contracts/capabilities/deployment.interface.tswith two OPTIONAL methods:getWorkflowFilenames?(): string[]— workflow files to dispatch, in priority order.getDeploymentSecrets?(settings: Record<string, unknown>): Promise<Record<string, string>>— extra secrets to push to the website repo before dispatch.- Both default to existing behaviour when unimplemented.
- Test:
packages/plugin/src/contracts/__tests__/deployment.interface.spec.ts— type-level test that an existing plugin without these methods still satisfies the interface.
-
T24b. Implement both methods on the Vercel plugin to preserve today's behaviour exactly:
getWorkflowFilenames()→['deploy_vercel.yaml', 'deploy_prod.yaml'].getDeploymentSecrets()→{}(Vercel needs no extras beyond whatsetRequiredSecretsalready pushes).- Test:
packages/plugins/vercel/src/__tests__/vercel.plugin.deployment-secrets.spec.ts.
-
T24c. Implement both methods on the Kubernetes plugin:
getWorkflowFilenames()→['deploy_k8s.yaml'].getDeploymentSecrets(settings)→ registry-kind-conditional + ingress vars:github→{ K8S_REGISTRY_KIND: 'github', K8S_REGISTRY_OWNER: <owner> }(the workflow's auto-injectedGITHUB_TOKENhandles auth).dockerhub→{ K8S_REGISTRY_KIND: 'dockerhub', REGISTRY_USERNAME, REGISTRY_PASSWORD }.generic→{ K8S_REGISTRY_KIND: 'generic', REGISTRY_SERVER, REGISTRY_USERNAME, REGISTRY_PASSWORD }.- Always:
K8S_NAMESPACE,K8S_INGRESS_CLASS?,K8S_INGRESS_HOST?,K8S_TLS_ISSUER?,K8S_REPLICAS?.
- Test:
__tests__/k8s.plugin.deployment-secrets.spec.tscovering each registry kind and asserting no kubeconfig substring appears in any returned secret value.
-
T25. Update
apps/api/src/plugins-capabilities/deploy/deploy.service.ts:- Replace the hardcoded
workflowFilesToTry(line 249) withplugin.getWorkflowFilenames?.() ?? ['deploy_prod.yaml']. The plugin reference comes from the existingDeployFacadeService.resolvePluginAndTokenWithWorkcall. - In
setRequiredSecrets(line 204), after the existing block, callplugin.getDeploymentSecrets?.(settings) ?? {}and push each[key, value]viasetSecret. - Confirm
setSecret(ctx, '<PROVIDER>_TOKEN', deployToken)(line 217) already covers k8s — it pushesK8S_TOKEN = kubeconfig. No change needed there. - Test:
apps/api/test/deploy.service.spec.ts— Vercel and Kubernetes flows both go through the same code path; noif (provider === 'k8s')branches; assert the dispatch payload for each registry kind, and confirm no kubeconfig contents appear in any GHA secret value (string-search forapiVersion: v1\nkind: Config).
- Replace the hardcoded
-
T26. e2e test against
kindin CI:- GitHub Actions matrix job creates a kind cluster, deploys a fixture work via the platform API, polls
getDeploymentStatusuntilready, asserts the pod responds 200 on the service. - Matrix dimensions:
registry: [github (via local registry stub), generic]×ingress: [nginx, traefik]. - Located under
apps/api/test/deploy-k8s.e2e-spec.tsplus a.github/workflows/e2e-k8s.yamlrunner.
- GitHub Actions matrix job creates a kind cluster, deploys a fixture work via the platform API, polls
Phase 3 DoD: end-to-end deploy of a fixture work to kind succeeds in CI for the github/nginx matrix cell at minimum; redeploy is a rolling update; failure paths surface scrubbed errors. Tests T15–T22, T25, T26 green.
Phase 4 — Custom domains (PR #4)
Goal: domain CRUD via the existing Settings → Domains UI works on k8s-deployed works.
-
T27.
domain.handler.ts:addDomainToIngress(api, namespace, workId, domain, tlsIssuer?)— read Ingress, append a rule, append atls.hostsentry, SSA back.removeDomainFromIngress(api, namespace, workId, domain).verifyDomainResolution(domain, expectedTarget)— DNS lookup; checks CNAME or A points to the cluster's ingress LB.- Test:
__tests__/domain.handler.spec.ts— covers add to fresh ingress, add as additional host, remove leaves last host intact, verify success/failure.
-
T28. Implement
KubernetesPlugin.addDomain/removeDomain/verifyDomainwiring T27 outputs intoAddDomainResult/DeploymentDomainshapes.verification:{ type: 'CNAME', name: '<domain>', value: '<ingressLbHost>' }for subdomains;Arecord for apex.
-
T29. Implement
KubernetesPlugin.getDomains(projectId, kubeconfig)reading the Ingress hosts. -
T30. e2e test in CI extending T26 cluster: add a domain, assert Ingress rule appears, remove it, assert removal.
-
T31. Update spec acceptance criterion "Custom domains" + "Provider switch" to
[x].
Phase 4 DoD: Settings → Domains UI works for k8s works; switching a work from Vercel to k8s preserves domain rows.
Phase 5 — Workflow YAML in templates (PR #5 in template repos)
Goal: the template repos ship the deploy workflow + Dockerfile + manifests.
- T32. PR to
ever-works/directory-web-template:.github/workflows/deploy_k8s.yamlDockerfile(multi-stage Next.js build → distroless runtime)k8s/kustomization.yaml,k8s/deployment.yaml,k8s/service.yaml,k8s/ingress.yaml.tpl
- T33. Same PR to
ever-works/directory-web-minimal-template. - T34. Smoke test the resulting workflow against a kind cluster reachable from the runner (use an
actions/setup-kindstep).
Phase 6 — Docs & default-on (PR #6)
- T35. User-facing doc at
docs/features/k8s-deployment.md(shipped together with the spec/plan in this same PR); cross-linked fromapps/docs/sidebarsPlatform.ts. (No separatedocs/features/index.mdexists; the sidebar is the index.) - T36. K8s row added to
docs/plugin-system/built-in-plugins.md(covered by T6 in Phase 1). - T37. Update spec status to
Implemented; mark plan and tasksDone. (Pending Phase 5 template-repo PRs landing.) - T38. Run
pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm buildfrom repo root and confirm green. - T39. Cut a release note to
apps/docs/changelog/(or wherever existing release notes live).
Phase 7 — Tests coverage matrix
Each functional requirement in
spec.md§3 must map to at least one passing test. Tick when the test exists and is in CI.
| FR | Test file(s) | Description |
|---|---|---|
| FR-1 (plugin discovery) | discovery.spec.ts, k8s.plugin.metadata.spec.ts | Plugin appears in /api/plugins, has correct metadata. |
| FR-2 (settings schema) | k8s.plugin.metadata.spec.ts | Required fields, secret + scope + widget hints. |
| FR-3 (validate on save) | k8s.plugin.validate.spec.ts | Save flow rejects unreachable cluster. |
| FR-4 (auto UI discovery) | Playwright e2e under apps/web/e2e/plugins-deployment.spec.ts | Both Vercel and Kubernetes cards render on /en/settings/plugins/deployment. |
| FR-5 (end-to-end deploy) | deploy-k8s.e2e-spec.ts | Kind cluster, fixture work, deploys to ready. |
| FR-6 (image build & apply order) | k8s.plugin.deploy.spec.ts | Manifest application order asserted via mock. |
| FR-7 (rolling update) | deploy-k8s.e2e-spec.ts (rollback test) | Second deploy preserves rollout history. |
| FR-8 (domain CRUD) | domain.handler.spec.ts, deploy-k8s.e2e-spec.ts | Ingress patches; DNS guidance returned. |
| FR-9 (status mapping) | status.mapper.spec.ts | Each rollout state mapped correctly. |
| FR-10 (secret hygiene) | errors.spec.ts, k8s.plugin.deploy.spec.ts | scrubError covers each leak; deploy errors never include kubeconfig substring. |
| FR-11 (not default) | k8s.plugin.metadata.spec.ts | Manifest excludes defaultForCapabilities. |
| FR-12 (cluster info on success) | k8s.plugin.validate.spec.ts | Details object includes name, server URL, version. |
| FR-13 (registry abstraction) | registries/*.spec.ts, provider.registry.spec.ts | Each kind builds the right image base, login, pull secret. |
| FR-14 (GHCR via GitHub plugin) | registries/github.provider.spec.ts, k8s.plugin.validate.spec.ts | Uses GitHub plugin context; "connect GitHub first" path. |
| FR-15 (ingress detection) | k8s-api.service.spec.ts, k8s.plugin.validate.spec.ts | listIngressClasses populates details.ingressClasses with hasStrategy. |
| FR-16 (ingress strategies) | ingress/*.spec.ts, strategy.registry.spec.ts | Snapshot annotations per controller; generic fallback. |
| FR-17 (works-config field) | works-config.service.spec.ts, works-config-import-applier.service.spec.ts | Parses deployProvider; applies it via lifecycle service; rejects unknown ids. |
| FR-18 (data-repo wins) | works-config-sync.listener.spec.ts (or applier spec) | Conflict event emitted; data repo value applied. |
| FR-19 (provider-agnostic plumbing) | works-config tests, deploy.service.spec.ts | Vercel and k8s both pass through the same code path with no plugin-specific branches. |
| FR-20 (visibility mirrors website repo) | registries/github.provider.spec.ts, deploy-k8s.e2e-spec.ts | auto resolves to public/private based on isPrivate; both branches covered. |
| FR-21 (pull secret iff private) | registries/github.provider.spec.ts, manifest.renderer.spec.ts | Public → Deployment has no imagePullSecrets; private → Deployment references the secret. |
| FR-22 (settings persistence) | k8s.plugin.metadata.spec.ts, manual: GET /api/plugins/k8s/settings returns no secrets | Settings live in plugin_settings (no new table). |
| FR-23 (one cluster per user) | k8s.plugin.deploy.spec.ts | Two works, same user, both use the same kubeconfig from plugin settings. |
| FR-24 (no caching in v1) | k8s-api.service.spec.ts | listIngressClasses is called on every validateConnection invocation. |
| Capability contract additive | deployment.interface.spec.ts, vercel.plugin.deployment-secrets.spec.ts, k8s.plugin.deployment-secrets.spec.ts | Existing plugins without the new optional methods still type-check and run. |
Test infrastructure
- Unit: Vitest in
packages/plugins/k8s/;@kubernetes/client-nodemocked withvi.mock. - Integration: a
kindcluster spun up in a GitHub Actions job (engineerd/setup-kind); image registry = localregistry:2container reachable from the kind node. - e2e API: NestJS Supertest; uses the same kind cluster; covers
POST /api/deploy/works/:idfor adeployProvider = 'k8s'work. - Web e2e: Playwright spec under
apps/web/e2e/; loads/en/settings/plugins/deploymentagainst a seeded user with both Vercel and k8s plugins discoverable. - Coverage target: ≥ 90% line coverage in
packages/plugins/k8s/; reported viapnpm --filter @ever-works/k8s-plugin test:coverage.
Definition of Done
- All checkboxes above ticked.
- All Phase 7 tests green in CI on Linux + Windows runners.
-
pnpm format:check,pnpm lint,pnpm type-checkgreen. -
pnpm --filter ever-works-docs buildproduces no broken-link warnings. - Constitution gates in
./spec.md§9 all confirmed satisfied. - Spec status flipped to
Implemented; this file's status flipped toDone. - User-facing doc linked from sidebar.
- At least one real-cluster smoke test logged in the PR (e.g. screenshot of a deployed fixture work on kind, k3d, or EKS).