Skip to content

feat: add kubernetes deployment support - #127

Draft
kaiitunnz wants to merge 11 commits into
mainfrom
kaiitunnz/feat/k8s-stack-backend
Draft

kaiitunnz wants to merge 11 commits into
mainfrom
kaiitunnz/feat/k8s-stack-backend

Conversation

@kaiitunnz

@kaiitunnz kaiitunnz commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Adds Kubernetes as a deployment target, in two parts. A kubernetes worker provider lets the supervisor create and delete worker Pods through the cluster API instead of placing containers on its own host. A --backend k8s mode for flowmesh stack up/down/restart/logs/ps/clean renders the server, both Redis instances, Services, and RBAC from the same env schema that drives compose, then applies them with kubectl.

One namespace is one FlowMesh node: a single server Deployment runs NODE_ROLE=root with its supervisor, and the cluster scheduler does the machine placement that bare-metal worker nodes do today. A cluster can still join an external root node by setting NODE_ROLE=worker and pointing at that node's Redis, in which case the Redis workloads are not deployed.

This sits alongside the external provider from #128 rather than replacing it: external admits workers whose lifecycle you own, while kubernetes gives FlowMesh the ability to create and destroy them on demand. docs/KUBERNETES.md contrasts the two.

Changes

  • src/server/supervisor/adapters/kubernetes.py — the new provider: pod and secret construction, start/stop lifecycle, orphan reaping, and a node-derived hardware probe.
  • src/server/supervisor/manager.py, adapters/base.py, adapters/docker.py — register the provider in the node's guarded provider list, and move the shared WorkerType enum onto the adapter base so the provider does not import it through the Docker adapter.
  • src/server/env.pyK8S_* settings the adapter reads for Service names, namespaces, and the GPU resource name.
  • sdk/stack/src/flowmesh_stack/manifests.py, kubernetes.py — manifest rendering and the kubectl wrapper, mirroring the existing compose wrapper.
  • cli/stack/src/flowmesh_cli_stack/assets/k8s/ — the shipped manifests (namespace, RBAC, Redis, server) and an example worker config.
  • cli/stack/src/flowmesh_cli_stack/k8s.py, stack.py, utils.py, env_schema.py — the backend implementation, --backend dispatch on the lifecycle commands, the shared worker-drain helper, and the new STACK_BACKEND / K8S_* schema section.
  • docs/KUBERNETES.md plus ARCHITECTURE.md, CLI.md, ENV.md, AGENTS.md — deployment guide and routing.
  • Tests — the adapter against a mocked Kubernetes API, manifest rendering including every directive and each shipped asset, and CLI backend dispatch.

Design

The server Deployment is pinned to replicas: 1 with the Recreate strategy. A worker's token lives in the in-memory registry of the supervisor that minted it, so a second replica behind the Service would reject registrations from workers the other replica started. Restarting the pod is still safe, since scheduling state is persisted to Redis and rebuilt on startup.

The provider registers in the node's guarded provider list alongside docker and vastai, and resolves cluster access in its factory's initializer. That placement is load-bearing: a provider that constructs successfully is advertised by GET /stack/workers/providers, so deferring cluster resolution to first use would make a node claim kubernetes with no kubeconfig at all and turn #129's 409 into a late failure.

Manifests are ordinary YAML assets carrying compose-compatible ${VAR} substitution plus a small set of directives for what substitution alone cannot express: conditional inclusion, integers for fields the API rejects as strings, file contents for configuration compose bind-mounts, and the env file's own values for what compose passes through env_file. One .env therefore drives both backends, and no templating dependency is added to a published wheel. Values handed to the cluster come from the env file rather than the process environment, so nothing else in the operator's shell reaches the namespace.

Worker credentials go into a per-worker Secret consumed through envFrom rather than inline in the pod spec, and the secret set is derived from the SecretStr fields on WorkerConfig so a credential added later cannot silently land in a manifest. GPUs are requested as extended resources and left to the device plugin to assign; the server never pins device indices. Worker pods carry no owner reference to the server pod, so a server rollout does not take running workers with it, while pods orphaned by an unclean exit are reaped at supervisor startup because their tokens died with the previous registry.

Two limitations are deliberate and documented. A kubernetes worker is a bare Pod: it restarts in place but is not rescheduled if its node is lost, so pools on preemptible or frequently drained nodes are better served by external. And SSH tasks are unavailable on Kubernetes workers — without a Docker socket the executor never loads, so the task type is never advertised and the dispatcher will not route to it.

Test Plan

uv run pre-commit run --all-files
uv run pytest tests/ --ignore=tests/worker/test_mp_executor_cleanup_gpu.py
uvx bandit -c pyproject.toml -r src/
uv run scripts/dev/check_env_examples.py
uv build --package flowmesh-cli-stack --wheel

Test Result

1789 passed; pre-commit (gitleaks, isort, black, ruff, mypy, codespell, requirements sync) clean; bandit reports no issues on src/; env examples in sync. The flowmesh-cli-stack wheel builds with assets/k8s/*.yaml included, so the manifests ship with the CLI.

No live cluster was involved. The provider is covered against a mocked Kubernetes API and the manifests are covered by rendering assertions over every shipped asset, so the deployment path itself is unexercised end to end and wants a real cluster before this leaves draft.


Pre-submission Checklist
  • I have read the contribution guidelines.
  • I have run pre-commit run --all-files and fixed any issues.
  • I have added or updated tests covering my changes (if applicable).
  • I have verified that uv run pytest tests/ passes locally.
  • If I changed shared schemas or proto definitions, I have checked downstream compatibility across Server and Worker. — n/a, no schema or proto changes.
  • If I changed the SDK or CLI, I have verified the affected packages work (uv sync --all-packages --group ci --frozen).
  • If this is a breaking change, I have prefixed the PR title with [BREAKING] and described migration steps above. — n/a, no breaking changes.
  • I have updated documentation or config examples if user-facing behavior changed.

🤖 Generated with Claude Code

kaiitunnz and others added 11 commits September 11, 2026 22:36
The CPU/GPU distinction is not Docker-specific; the Kubernetes provider added
later in this branch needs it too, and importing it from the Docker adapter
would pull the Docker SDK in with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The relock also moves pyyaml from 6.0.2 to 6.0.3.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The supervisor creates and deletes worker Pods through the Kubernetes API,
so a cluster schedules workers onto GPU nodes instead of the supervisor
placing containers on its own host. Credentials travel in a per-worker Secret
consumed via envFrom rather than inline in the pod spec, and the secret set is
derived from the SecretStr fields on WorkerConfig so a newly added credential
cannot silently land in the manifest.

GPUs are requested as extended resources and left to the device plugin to
assign. Object names are sanitized to RFC 1123, which Docker container names
are not; the default node alias alone would otherwise be rejected by the API.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The manifests are ordinary YAML assets carrying compose-compatible ${VAR}
substitution plus a few directives for what substitution alone cannot express:
conditional inclusion, integers for fields the API rejects as strings, file
contents for configuration compose bind-mounts, and the environment file's own
values for what compose passes through env_file. One .env therefore drives both
backends.

Values handed to the cluster come from the environment file rather than the
process environment, so nothing else in the operator's shell is copied into the
namespace.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
`flowmesh stack up/down/restart/logs/ps/clean --backend k8s` renders the
manifests and drives them with kubectl, while the compose path is unchanged
when the backend is compose. The backend also reads from STACK_BACKEND, so an
environment file selects it once.

Restarting with an image tag applies rather than rolls, because a rollout
restart leaves the pod template's image as it was and the tag would silently
have no effect. The worker drain moves into utils so both backends run it
before the supervisor goes away.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Teardown applied the whole rendered stream to `kubectl delete`, which includes
the Namespace and the results PersistentVolumeClaim. Deleting a namespace
cascades to every claim inside it, so `stack down` destroyed task results and
Redis state, while the compose backend's `down` leaves volumes alone. `down`
now keeps the namespace and the claims and `clean` removes them, matching the
split compose has between `down` and `down -v`.

Separately, the RBAC the server needs is bound in the worker namespace, so
setting K8S_WORKER_NAMESPACE to anything other than the stack namespace made
the apply target a namespace that was never created.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Pod deletion is accepted asynchronously and the object outlives the call while
it terminates, so recreating a worker under the same name raced the pod being
replaced and would fail with a conflict. Replacement now deletes with no grace
period and waits for the name to come free.

The `flowmesh.io/node-id` label also carried a worker id, which is assigned
only once the worker registers — after the pod exists — so it was both
misnamed and never actually set. It is dropped; the node alias remains the
stable key the orphan reaper selects on.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
`clean` deletes persistent volume claims by label, but the Redis claims are
created by the StatefulSet controller from volumeClaimTemplates, which carried
no labels — so cleanup silently left both Redis volumes behind.

Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The factory resolved cluster access on first use, so it always constructed and
the node advertised `kubernetes` even with no kubeconfig and no cluster — the
create then failed late instead of being refused by the provider registry.
Resolving access in the initializer lets an unreachable cluster drop the
provider the same way an absent Docker daemon drops that one.

The dockerless-host test now stubs every spawning provider rather than two of
them, so its assertion about the surviving provider set no longer depends on
whether the machine running it happens to have a kubeconfig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The `external` provider covers workers deployed with kubectl that enroll
themselves, which this guide had listed as unsupported. Contrast the two
Kubernetes paths by who owns the pod lifecycle, and record that a `kubernetes`
worker is a bare Pod and so is not rescheduled when its node is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
@kaiitunnz
kaiitunnz force-pushed the kaiitunnz/feat/k8s-stack-backend branch from 2d8da21 to 5ba8d64 Compare September 11, 2026 14:58
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.

1 participant