Greentic AI · greentic-deployer · env-pack K8s path
How a Greentic digital worker is deployed to, and served from, a Kubernetes cluster using the
environment-pack model of greentic-deployer (the op env … CLI surface) —
the mental model, the rendered objects, the two-command flow, the demo from
my_demos/k8s-deploy-demo, its config files, and how to reproduce it end-to-end on your own PC.
The single most important thing to internalize.
"The cluster and the environment id are two independent axes." They are frequently conflated; they are not the same thing. One decides which cluster the objects land in; the other decides the logical environment name that drives the namespace and the store partition.
kubeconfig_context answer, or the current context).
The deployer is cluster-agnostic — nothing is kind-specific.local, prod, staging),
which drives the namespace (gtc-<id>) and the store partition.
Set by environment.id in the manifest, or op env create <id>.
A local store is single-operator: its authorization boundary is OS filesystem ownership, so
named environments are first-class — you may keep the id local while targeting a remote
production cluster, or give it a real name like prod. A shared, multi-operator control plane
(an operator-store server with RBAC) is a separate, still-future effort.
The deployer separates authoring (writes the store) from applying (writes the cluster):
op env apply --answers
op env create / op deploy
op env-packs add …
op env reconcile <id>
op env apply-revision
--reconcile flag on apply — by design.
op env apply / op deploy / op env-packs add mutate the store
(the desired state) and never touch the cluster. op env reconcile <id> renders the desired
state to manifests and pushes them onto the live cluster (and prunes workers for revisions no longer
present). The two steps are deliberately separate so authoring is offline / dry-runnable and applying is an
explicit, auditable act.
op env reconcile <id> renders a fixed set of objects into namespace gtc-<env-id>. From a webchat+Telegram env this is 15 objects.
| Kind | Count | Name(s) / purpose |
|---|---|---|
Namespace | 1 | gtc-<env-id> — see namespace derivation below. |
Deployment | 2 | gtc-router (ingress routing, HA — ≥2 replicas) + one gtc-worker-<revision> per active revision (runs the bundle). |
Service | 2 | ClusterIP on port 8080, one for the router and one per worker. |
ConfigMap | 2 | gtc-runtime-config (projected runtime config) + gtc-env-store (environment.json). |
Secret | 1 | gtc-dev-secrets — base64 of the operator dev-store, rendered only when the env binds a secrets pack. optional: true. |
NetworkPolicy | 5 | Default-deny + scoped allow rules (e.g. gtc-allow-worker-egress, rendered when a routed revision has a bundle_source_uri). |
PodDisruptionBudget | 1 | Keeps the router available during voluntary disruptions. |
ClusterIP on port 8080. The deployer renders no Ingress and no LoadBalancer — external exposure is bring-your-own (§8).imagePullPolicy is set. A non-digest tag (e.g. :develop) defaults to IfNotPresent, and a warm node can serve a stale cached layer. Pin runtime_image to a digest for deterministic pulls in production.bundle_source_uri; the worker (greentic-start) fetches the .gtbundle, re-verifies it against the recorded bundle_digest (two-point integrity), materializes it, and serves the revision.65532 with readOnlyRootFilesystem: true; HOME (/var/greentic) is a writable emptyDir.namespace_for_env(env_id) (src/env_packs/k8s/manifests.rs):
[a-z0-9-], no leading/trailing -, and gtc-<id> ≤ 63 chars) → gtc-<id> verbatim. local → gtc-local, prod → gtc-prod.., _) or exceeding the RFC 1123 63-char limit → a collision-proof hash suffix: gtc-<sanitized-prefix>-<hash8>. Distinct ids that sanitize identically still get unique namespaces.What you need before the two-command flow.
kubectl with a working context for the target cluster (kubectl config get-contexts).gtc CLI with an up-to-date deployer. Recommended path: install / refresh the prebuilt nextgen-deployer toolchain release so the gtc op … router and its embedded deployer/operator are current — this ships the OCI-bundle (URI-only) support, the cloudflared-in-image runtime, and the loopback-admin-listener split this guide relies on.ghcr.io at boot.ghcr.io/greenticai/greentic-start-distroless:develop. For Telegram-via-tunnel you need an image that ships cloudflared (the :develop distroless image carries it).install / refresh the deployer toolchain
gtc-dev install --release nextgen-deployer # add --force to overwrite an already-installed toolchain
Build from source instead? Use default features — the k8s-client feature is default-on and required by reconcile:
cargo build -p greentic-deployer --bin greentic-deployer # binary at target/debug/greentic-deployer; invoke `… op …` # a stale --no-default-features build fails reconcile with: # conflict: this build was compiled without the k8s-client feature
<deployer> op [GLOBAL FLAGS] <noun> <verb> [ARGS]. The global flags that matter come
before the noun: --store-root <DIR> (the local FS store / desired state),
--answers <PATH> (the env-manifest for env apply, or a verb's answer payload),
and --store-url <URL> (target a remote operator-store server instead of the local FS store).
my_demos/k8s-deploy-demoThe fastest path: 2 JSON files, 2 commands (plus one-time cluster bring-up). Brings up Webchat and Telegram — the K8s analog of the local setup --answers … && start --cloudflared on two-liner.
| File | Replaces these op calls |
|---|---|
k8s.env.json( op env apply) |
env create + 2× env-packs add + trust-root bootstrap + bundles add + revisions stage + revisions warm + traffic set + messaging endpoint add + endpoint link-bundle + secrets put |
deployer-answers.json |
the deployer pack's runtime_image + tunnel answers |
Three things the runtime does for you on K8s — the same way local does:
kubectl create secret + a bridge patch.)tunnel: cloudflared boots the worker with --cloudflared on; greentic-start spawns the cloudflared baked into the runtime image and discovers a *.trycloudflare.com URL. (Was a manual host-side cloudflared tunnel.)setWebhook with the webhook secret op env apply already minted. (Was a manual setWebhook + endpoint-id-keyed secret juggling.)op env reconcile is always separate — apply writes the store, reconcile writes the
cluster. And in the demo's all-in-one manifest, op deploy is folded in because apply
stages the OCI bundle pullable; the only hard split that remains is store → cluster.
A complete, copy-paste fish walk-through against a local kind cluster. One token-bearing command; everything else is store + cluster plumbing.
The setWebhook / deleteWebhook calls and the apply step are token-bearing.
The live bot token has been replaced with <YOUR_BOT_TOKEN> below — never paste a real
Telegram bot token into a published document. Pass it inline (env VAR=val cmd) at apply time so
it reaches the process on any shell and is never written to a file.
walk-through · fish
# ── config (paths are fixed) ─────────────────────────────────────────────── set -gx STORE /tmp/gtc-k8s-demo/.greentic/environments # ── one-time only: create the cluster (skip if `kind get clusters` shows gtc-demo) kind create cluster --name gtc-demo kind export kubeconfig --name gtc-demo # ── refresh the runtime image (kind won't re-pull a mutable :develop tag itself) docker pull ghcr.io/greenticai/greentic-start-distroless:develop kind load docker-image ghcr.io/greenticai/greentic-start-distroless:develop --name gtc-demo # ── 1. author the env (THE one token-bearing command — inline your bot token) ─ env TELEGRAM_DEMO_BOT_TOKEN=<YOUR_BOT_TOKEN> \ gtc-dev op --store-root $STORE --answers /home/vampik/greenticai/my_demos/k8s-deploy-demo/declarative/k8s.env.json env apply --yes # ── 2. reconcile onto the live cluster ────────────────────────────────────── gtc-dev op --store-root $STORE env reconcile local # ── wait for the worker, then port-forward the webchat admin listener ──────── set WORKER (kubectl -n gtc-local get deploy -o name | string match '*worker*') kubectl -n gtc-local rollout status $WORKER --timeout=240s kubectl -n gtc-local port-forward $WORKER 8081:8081 # → open http://localhost:8081/chat (webchat — works alongside the tunnel) # → Telegram: just message the bot (worker auto-registered the webhook at boot)
With the cloudflared tunnel up (the demo default), the main port 8080 serves provider
webhooks only; the /chat console moves to the loopback-only admin listener at
8081 (main port + 1) — hence port-forward 8081:8081. The boot banner prints the exact
admin port. With no tunnel, the console stays on 8080 and you'd
port-forward 8080:8080.
teardown + Telegram hygiene · fish
# stop any running port-forward (Ctrl+C in its terminal, or:) pkill -f 'port-forward.*gtc-worker' # nuke everything (cluster + node image cache) kind delete cluster --name gtc-demo rm -rf /tmp/gtc-k8s-demo # Telegram hygiene — unpoint the bot from the now-dead tunnel (token-bearing, yours) curl -s "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/deleteWebhook" # ── clean slate: drop any prior env from cluster + store ──────────────────── kubectl delete ns gtc-local --ignore-not-found --wait rm -rf $STORE; mkdir -p $STORE
Same two-command flow — only the cluster changes. Set kubeconfig_context in
deployer-answers.json (or omit for the current context) and either set
environment.public_base_url to your HTTPS Ingress host (production-shaped, leave tunnel
off) or keep tunnel: cloudflared for a zero-infra demo URL. Pin runtime_image to a
digest. The repo's real-cluster/ directory carries a ready-to-edit manifest pair.
Two files under declarative/. Secret values never appear here — only a from_env pointer that's read at apply time.
the env-manifest · declarative/k8s.env.json
{
"schema": "greentic.env-manifest.v1",
"environment": {
"id": "local",
"name": "k8s",
"gui_enabled": true
},
"trust_root": "bootstrap",
"packs": [
{
"slot": "deployer",
"kind": "greentic.deployer.k8s@1.0.0",
"pack_ref": "builtin",
"answers_ref": "deployer-answers.json"
},
{
"slot": "secrets",
"kind": "greentic.secrets.dev-store@1.0.0",
"pack_ref": "builtin"
}
],
"bundles": [
{
"bundle_id": "webchat-bot",
"bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:v1",
"bundle_digest": "sha256:4f560749ec709e75b6063cdeccab15ed5074c2e60bc5f772c2d3b7d4bd992363",
"route_binding": {
"hosts": [],
"path_prefixes": ["/"],
"tenant_selector": { "tenant": "tenant-default", "team": "default" }
}
}
],
"secrets": [
{
"path": "tenant-default/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_DEMO_BOT_TOKEN"
}
],
"messaging_endpoints": [
{
"name": "webchat-bot",
"provider_type": "messaging.telegram.bot",
"links": ["webchat-bot"]
}
]
}
the deployer pack's answers · declarative/deployer-answers.json
{
"runtime_image": "ghcr.io/greenticai/greentic-start-distroless:develop",
"tunnel": "cloudflared"
}
| Field | Meaning |
|---|---|
environment.id | local → namespace gtc-local. Only local is auto-bootstrapped by apply; other ids need op env create <id> first. |
environment.gui_enabled | Serve the loopback-trusted /chat webchat console. |
trust_root | "bootstrap" mints the env trust-root inline (no separate trust-root bootstrap call). |
packs[].slot | A deployer slot bound to greentic.deployer.k8s@1.0.0 (+ answers_ref), and a secrets slot bound to greentic.secrets.dev-store@1.0.0 for pod secrets. |
bundles[] | Declared by bundle_source_uri (the oci:// ref the worker pulls) + a bundle_digest integrity pin — no local bundle_path on the apply host. route_binding selects host/path-prefix + tenant_selector. |
secrets[] | { path, from_env } — the bot-token value is read from $TELEGRAM_DEMO_BOT_TOKEN at apply; the value never goes in the manifest. The URI segment messaging-telegram is fixed (not the endpoint name). |
messaging_endpoints[] | provider_type: "messaging.telegram.bot"; links references a bundle_id. |
runtime_image | Image for router + worker pods. Pin to a digest in production. |
tunnel | cloudflared → the worker spawns a quick tunnel and self-registers the webhook (single-revision only). |
The K8s model does not yet integrate a real secrets backend (AWS SM / Vault / native secretKeyRef). When an env binds greentic.secrets.dev-store@1.0.0:
op env apply (or op secrets put) writes secret values into the operator's local dev-store at <store>/<env>/.greentic/dev/.dev.secrets.env (AES-256-GCM per secret).gtc-dev-secrets Secret.stage-dev-secrets busybox init container copies the file into the worker's writable HOME at $HOME/.greentic/environments/<id>/.greentic/dev/.dev.secrets.env — the path greentic-start's DevStore resolves. It must be writable: the DevStore opens the file write+create with flock on every read, so a read-only Secret mount would fail.greentic.ai/dev-store-hash annotation derived from the secret bytes, so re-reconciling after a secret change rolls the worker pods (otherwise the init container only copies once at pod start).The dev-store master key is SHA256($GREENTIC_DEV_MASTER_KEY), defaulting to SHA256("")
when unset on both host and pod. With the default (unset), the .dev.secrets.env file is fully
portable — decryptable in-pod with no extra key material. This is the Phase-E gap: it works, but it is
not a production secrets backend.
Two surfaces, two trust postures.
| Surface | How | Why |
|---|---|---|
| Telegram public |
Ingress/LB → worker Service :8080 over HTTPS (option A), or the cloudflared tunnel (option B). The worker auto-registers the webhook at boot. |
Provider webhooks self-authenticate (secret-token header). |
| Webchat /chat private |
kubectl port-forward to the worker. |
/chat and /workers/invoke are loopback-trusted and intentionally not served through the public edge. |
The revision server trusts a caller only when (peer is loopback) AND (no public tunnel fronts this listener):
8080); a port-forward 8080:8080 is a genuine loopback peer and gets /chat + 200./chat → 405, /workers/invoke → 403). A separate loopback-only admin listener (main port + 1 = 8081) keeps serving the console. port-forward 8081:8081 for /chat; the boot banner prints the exact admin port. This lets webchat and Telegram run simultaneously.Deliberate security posture: cloudflared forwards from loopback, so public tunnel traffic would otherwise read as loopback and bypass the /workers/invoke gate. Routing the console to an untunneled, loopback-scoped admin listener closes that without exposing anything new to the network.
Every key, sourced from src/env_packs/k8s/.
greentic.deployer.k8s@1.0.0 (answers_ref)A flat JSON object keyed by wizard question id. All keys optional; unknown keys are rejected (fail closed on version skew).
| Key | Type | Default | Effect |
|---|---|---|---|
kubeconfig_context | string | current context | Which kubeconfig context reconcile targets. Client-targeting only — not a manifest knob. |
namespace | string (RFC 1123) | gtc-<env-id> | Override the namespace every object lands in. |
runtime_image | string | greentic-start-distroless:develop | Container image for router + worker pods. Pin to a digest in production. |
router_replicas | int | 2 | Router replica count. Must be ≥ 2 (HA). |
tunnel | "off" | "cloudflared" | off | Worker public-exposure mode. cloudflared → worker spawns a quick tunnel (single-revision only). |
oci_insecure_registries | string[] | [] | Registry authorities the worker/router may pull bundles from over plain HTTP. Rendered as GREENTIC_OCI_INSECURE_REGISTRIES. Empty → HTTPS only. |
greentic.env-manifest.v1 (K8s-relevant fields)| Field | Notes |
|---|---|
environment.id | Drives the namespace (gtc-<id>) and the store partition. Only local is auto-bootstrapped by apply. |
environment.gui_enabled | Serve the /chat console (loopback-trusted). |
environment.public_base_url | HTTPS URL for webhook auto-registration (option A). Omit when using a tunnel (the tunnel URL wins). |
trust_root | "bootstrap" to mint the env trust-root. |
packs[] | slot ∈ deployer / secrets / … (lowercase). For K8s: a deployer slot (+ answers_ref) and a secrets slot if you need pod secrets. |
bundles[] | Declare the bundle by bundle_source_uri + bundle_digest — no local bundle_path needed. route_binding selects host/path-prefix + tenant_selector. Non-local envs require customer_id. |
secrets[] | { path, from_env } — values come from from_env (read at apply) or paste; secret values never go in the manifest. |
messaging_endpoints[] | { name, provider_type, links }. The URI segment for the bot-token secret is fixed messaging-telegram (not the endpoint name). |
All verified in source. None silently broken — each is a deliberate current limitation with a workaround.
No managed Ingress/LoadBalancer. The deployer renders only ClusterIP Services on :8080. External exposure is BYO-Ingress (§8 option A) or the ephemeral cloudflared tunnel (option B).
Secrets use the dev-store bridge. The bot token is base64'd from the operator's local dev-store into a K8s Secret. It works but is not AWS SM / Vault / native secretKeyRef.
No imagePullSecrets. A private runtime image or private bundle registry is not yet supported by the rendered manifests. The demo image and bundle are public ghcr.
No imagePullPolicy. Non-digest tags default to IfNotPresent; pin a digest for deterministic pulls.
Tunnel is single-revision. Each worker pod spawns its own cloudflared tunnel, so a traffic split registers N competing webhooks. For multi-revision / production use BYO-Ingress with a stable public_base_url.
Named envs are first-class but single-operator. The local store authorizes any env id under filesystem ownership (local-owner policy). A shared, multi-operator control plane (--store-url server with RBAC + CAS) exists in scaffold form but remote stage/warm verbs are not yet wired end-to-end.
Reconcile authenticates as your ambient kubeconfig identity.
Symptom → cause → fix.
| Symptom | Cause | Fix |
|---|---|---|
conflict: this build was compiled without the k8s-client feature | A deployer binary built --no-default-features. | Rebuild with default features: cargo build -p greentic-deployer --bin greentic-deployer. |
Worker CrashLoopBackOff / no revision served | Bundle digest mismatch, or registry not reachable from the pod. | Check the pod has internet egress; verify bundle_digest matches the oci:// artifact. The worker fails closed on mismatch. |
Telegram setWebhook fails to resolve host | Fresh *.trycloudflare.com not yet globally resolvable, or local DNS can't resolve it. | Wait ~15–30s; verify via a public resolver (dig +short @1.1.1.1 <host>), then getWebhookInfo. |
port-forward returns 502 after a rollout | port-forward binds one pod; a rollout replaces it. | Restart the port-forward. |
/chat returns 405/403 over the tunnel | Loopback-trust posture: the tunneled main port serves webhooks only. | Port-forward the admin listener (8081, main+1). The boot banner prints the port. |
Webhook registration not visible in kubectl logs | greentic-start logs registration via OTLP / system.log, not pod stdout. | Confirm with Telegram getWebhookInfo, not kubectl logs. |
| Secret change didn't take effect | Init container copies the dev-store once at pod start. | Re-reconcile — the greentic.ai/dev-store-hash annotation rolls the pods on a data change. |
| Term | Meaning |
|---|---|
| env-pack | A pluggable capability bound to an environment slot (deployer, secrets, …). The K8s deployer is greentic.deployer.k8s@1.0.0. |
| store | The local FS (or remote) record of desired state, partitioned per env id. Written by apply/deploy, read by reconcile. |
| reconcile | Render the store's desired state to manifests and push them onto the live cluster (and prune stale workers). |
| revision | A staged, integrity-pinned snapshot of a bundle (pack_list, config_digest, bundle_digest, …). The worker pulls and serves it. |
| route_binding | How a bundle's traffic is selected — host(s), path prefix(es), and tenant_selector. |
| dev-store bridge | The mechanism that gets operator secrets into the pod via a rendered Secret + init container (the Phase-E placeholder for a real backend). |
| loopback-trust | The rule gating /chat + /workers/invoke: trusted only when the peer is loopback AND no tunnel fronts the listener. |
| admin listener | A loopback-only listener (main port + 1) that serves the console while the main port is tunneled. |