Greentic · Deployment Guide

Deploying a Greentic worker to OpenShift

Author the environment, reconcile it onto the cluster, and let a worker pod pull its bundle and serve HTTP — on OpenShift (OCP/OKD), where one default policy stands between you and a running pod. Two end-to-end walkthroughs: a plain Kubernetes deploy, and one backed by HashiCorp Vault.

gtc-dev install --release nextgen-deployer restricted-v2 → nonroot-v2 SCC Routes · NetworkPolicy · Vault

Before you startThe OpenShift-specific steps below (the SCC grant, Routes, the Vault setup) follow standard OpenShift behavior but should be validated on your own cluster — when a pod won't schedule, oc get events and oc describe tell you exactly which admission rule fired. Everything uses installed binaries (gtc-dev install --release nextgen-deployer); nothing is built from source.

Why OpenShift is different (and the one thing that will bite you)

The deploy model is the same on any cluster. You author an environment with the deployer op verbs, then op env reconcile server-side-applies the rendered objects, and a worker pod boots greentic-start start --env, pulls its .gtbundle, digest-verifies it, and serves HTTP on :8080.

CONTROL PLANE · your host gtc-dev op env apply env-packs add · bundles add revisions stage · warm · traffic writes the env store → becomes the gtc-env-store ConfigMap reconcile server-side apply DATA PLANE · OpenShift project gtc-<env> gtc-router Deployment · Svc · PDB gtc-worker-<ulid> start --env · serves :8080 deny-by-default NetworkPolicies · ConfigMaps · (secrets) pulls .gtbundle over OCI/HTTP, digest-gates, activates
Control plane (host) authors the store; reconcile pushes objects; the worker pod self-pulls and serves.

The deployer renders every worker/router pod with a fixed security context. UID 65532 is the distroless nonroot user:

rendered pod + container securityContext
// pod
{ "runAsNonRoot": true, "runAsUser": 65532, "runAsGroup": 65532,
  "fsGroup": 65532, "seccompProfile": { "type": "RuntimeDefault" } }
// container
{ "allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true,
  "capabilities": { "drop": ["ALL"] } }

This is a hardened, restricted-friendly profile — almost exactly what OpenShift wants. But OpenShift's default restricted-v2 SCC uses runAsUser: MustRunAsRange and fsGroup: MustRunAs, both pinned to the namespace-allocated UID range. A pod that explicitly demands UID/group 65532 falls outside that range, so it is rejected at admission and no pod is ever created.

Default · restricted-v2 SCC pod wants runAsUser: 65532 allowed range: 1000700000–1000709999 FailedCreate · admission denied no pod is created — Deployment stuck After: grant nonroot-v2 to the SA pod wants runAsUser: 65532 nonroot-v2: any non-root UID allowed pod scheduled, unmodified all hardening kept · no manifest patch
The one mandatory fix. nonroot-v2 keeps non-root, drop-ALL-caps, no-priv-esc, RuntimeDefault seccomp — it only relaxes the UID-range constraint, so the rendered 65532 is accepted as-is and survives every re-reconcile.

The fix, in one lineoc adm policy add-scc-to-user nonroot-v2 -z <sa> -n gtc-<env> — granted to the ServiceAccount the pods run as (the project default SA for a plain deploy, or gtc-worker on the Vault path).

The five deltas vs vanilla Kubernetes

#DeltaMandatory?What you do
1SCC — pods demand UID 65532; restricted-v2 forbids itYesoc adm policy add-scc-to-user nonroot-v2 -z <sa>
2External exposure — only a ClusterIP:8080 Service is rendered, no IngressTo reach itOpenShift Route (oc create route edge) — gives the HTTPS Telegram needs, no cert wrangling
3Image pull — no imagePullSecrets / no imagePullPolicy renderedPrivate reg / multi-nodeLink pull secret to SA: oc secrets link <sa> <secret> --for=pull; pin runtime_image to a @sha256: digest
4Project + RBAC — reconcile creates a Namespace, uses the default SARecommendedPre-create with oc new-project; reconcile with a context that can create/patch the Namespace
5Vault IPC_LOCK (Vault path only) — a dev-mode Vault asks for a forbidden capabilityVault path onlyDrop the cap + VAULT_DISABLE_MLOCK=true, or the official Vault Helm chart with global.openshift=true

NetworkPolicies are enforcedOpenShift's CNI (OVN-Kubernetes) enforces NetworkPolicies. The deployer's deny-by-default posture (gtc-default-deny + gtc-allow-router/workers + *-egress) therefore applies as written — and any co-located out-of-band component (such as an in-namespace Vault) needs an explicit ingress allow. Demo 2 ships exactly that policy.

Prerequisites & install

Tool / artifactWhyNotes
oc, logged in (oc login)talks to OpenShift (superset of kubectl)your token must create/patch a Namespace + namespaced objects + NetworkPolicies, and have the scc admin verb (cluster-admin) for the grant
gtc-dev launcher + nextgen-deployer releaseprovides op env apply / env reconcile and the runtime-start binariesinstalled below — no source build
greentic-start-distroless:develop (2026-06-18+)the container image the pods boot (pulled by the cluster, separate from the host CLI)the stable tag predates the serve boot and never reaches Ready. Pin to a digest on OpenShift
cluster egress to ghcr.io (or a mirror)the pod pulls the runtime image and the .gtbundle at bootdisconnected clusters: mirror the image + host the bundle on a reachable registry

Install the toolchain once (this is the only tooling install — everything afterward runs through it):

bash — one-time toolchain install
# the dev-channel launcher (skip if you already run gtc-dev)
cargo binstall -y gtc-dev

# installs the pinned deployer + operator + runtime-start companion binaries
gtc-dev install --release nextgen-deployer

# the op verbs are now available through the launcher:
gtc-dev op --help

nextgen-deployer is a named release on the dev channel; the launcher resolves gtc-dev op … to the installed operator/deployer companions. Pin the container image separately (it is what the pods boot, not a host binary).

Demo 1 — Deploy to OpenShift Kubernetes · dev-store secrets

A worker pod that boots, pulls a bundle over OCI, and serves a Webchat console plus an optional Telegram endpoint. Secrets ride in via the built-in dev-store (rendered as a K8s Secret). This is the fastest path to a running worker.

  1. Set names
    bash
    export ENVID=local                 # env id 'local' -> project 'gtc-local'
    export PROJECT=gtc-local           # = gtc-<ENVID>
    export STORE=$HOME/.greentic/environments
    export IMG='ghcr.io/greenticai/greentic-start-distroless:develop'  # pin @sha256:… in prod

    The project name is always gtc-<environment.id>.

  2. Create the project + grant the SCC (before reconcile)
    bash
    oc new-project "$PROJECT"          # creates the project; you're its admin
    
    # worker/router pods run as the project 'default' SA; grant nonroot-v2
    # so the rendered UID 65532 is accepted, unmodified.
    oc adm policy add-scc-to-user nonroot-v2 -z default -n "$PROJECT"

    Skipped it? No pods appear, and:oc -n "$PROJECT" get events --field-selector reason=FailedCreate“…security context constraint: runAsUser … must be in the ranges …”

  3. Author the environment from one manifest

    Write openshift.env.json:

    openshift.env.json
    {
      "schema": "greentic.env-manifest.v1",
      "environment": { "id": "local", "name": "openshift", "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_BOT_TOKEN" }
      ],
      "messaging_endpoints": [
        { "name": "webchat-bot", "provider_type": "messaging.telegram.bot", "links": ["webchat-bot"] }
      ]
    }

    and deployer-answers.json — note tunnel: off (you expose via a Route) and the pinned digest:

    deployer-answers.json
    {
      "runtime_image": "ghcr.io/greenticai/greentic-start-distroless@sha256:<digest>",
      "tunnel": "off",
      "router_replicas": 2
    }

    Apply it (the bot token is passed inline, never written to a file; omit it for a webchat-only deploy):

    bash
    env TELEGRAM_BOT_TOKEN=<your-bot-token> \
      gtc-dev op --store-root "$STORE" --answers ./openshift.env.json env apply --yes

    One env apply stands in for env create + two env-packs add + trust-root bootstrap + bundles add + revisions stage/warm + traffic set + the endpoint + the secret.

  4. Reconcile onto the cluster
    bash
    gtc-dev op --store-root "$STORE" env reconcile "$ENVID"
    
    # the worker Deployment is named gtc-worker-<ulid>; find it by label:
    export WORKER=$(oc -n "$PROJECT" get deploy -l app.kubernetes.io/component=worker \
      -o jsonpath='{.items[0].metadata.name}')
    
    oc -n "$PROJECT" rollout status deploy/gtc-router  --timeout=180s
    oc -n "$PROJECT" rollout status "deploy/$WORKER"   --timeout=240s
    
    # confirm a REAL revision activated (not probes-only):
    oc -n "$PROJECT" logs "deploy/$WORKER" | grep -E "revision\(s\) for env|probes only"
    # SUCCESS: "... serving 1 revision(s) for env `local` ..."

    Pod Pending / no pod → re-check step 2 (SCC). CrashLoopBackOff on pull → check egress to ghcr and the digest pin.

  5. Expose it with a Route (HTTPS)
    bash
    # edge-terminated TLS Route -> the worker Service on 8080
    oc -n "$PROJECT" create route edge webchat-bot \
      --service="$WORKER" --port=8080 --insecure-policy=Redirect
    
    oc -n "$PROJECT" get route webchat-bot -o jsonpath='{.spec.host}{"\n"}'
    # -> webchat-bot-gtc-local.apps.<your-cluster-domain>

    Webhook ordering (chicken-and-egg)The worker auto-registers the Telegram webhook against environment.public_base_url at boot, but the Route host isn't known until the Route exists. Two-step: (1) reconcile + create the Route, read the host; (2) set "public_base_url": "https://<route-host>" on environment in the manifest, re-env apply, re-env reconcile. The worker restarts and registers against the stable URL.

  6. Verify (and reach the private console)
    bash
    # /chat and /workers/invoke are loopback-trusted — reach them via port-forward
    oc -n "$PROJECT" port-forward "deploy/$WORKER" 8080:8080 &
    sleep 2
    curl -s http://127.0.0.1:8080/healthz; echo          # 200
    curl -s http://127.0.0.1:8080/status | jq .          # {"revisions_active":1,"bundles_active":1,...}
    # open http://localhost:8080/chat in a browser for the Webchat console
    kill %1
  7. Teardown
    bash
    oc delete project "$PROJECT"        # tears down everything reconciled
    rm -rf "$STORE/$ENVID"               # drop the host-side env store (optional)

Demo 2 — Deploy with Vault workload identity · no secrets in cluster

The same worker, but it resolves its secret:// references from HashiCorp Vault under its Kubernetes ServiceAccount identity — so no secret values are written into the cluster (no K8s Secret object). The env is tenant-owned, which the runtime requires when serving a tenant deployment from a Vault-backed env.

  1. Set names + project + SCCs
    bash
    export ENVID=vault-demo
    export PROJECT=gtc-vault-demo       # = gtc-<ENVID>; Vault lives here too
    export TENANT=tenant-default
    export STORE=$HOME/.greentic/environments
    export IMG='ghcr.io/greenticai/greentic-start-distroless:develop'
    
    oc new-project "$PROJECT"
    oc -n "$PROJECT" create serviceaccount gtc-worker
    oc -n "$PROJECT" create serviceaccount vault
    
    # the worker runs as gtc-worker on the Vault path; Vault dev-mode runs as 'vault'.
    # grant nonroot-v2 to BOTH so neither is rejected by restricted-v2.
    oc adm policy add-scc-to-user nonroot-v2 -z gtc-worker -n "$PROJECT"
    oc adm policy add-scc-to-user nonroot-v2 -z vault      -n "$PROJECT"
  2. Deploy a dev-mode Vault (OpenShift-safe)

    Dev mode is in-memory, auto-unsealed, root token root — for the round-trip only, not production. The OpenShift edits vs a vanilla manifest: no IPC_LOCK capability, and VAULT_DISABLE_MLOCK=true.

    vault.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRoleBinding
    metadata: { name: gtc-vault-demo-auth-delegator }
    roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: system:auth-delegator }
    subjects: [{ kind: ServiceAccount, name: vault, namespace: gtc-vault-demo }]
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata: { name: vault, namespace: gtc-vault-demo, labels: { app: vault } }
    spec:
      replicas: 1
      selector: { matchLabels: { app: vault } }
      template:
        metadata: { labels: { app: vault } }
        spec:
          serviceAccountName: vault
          containers:
            - name: vault
              image: hashicorp/vault:1.17
              args: ["server","-dev","-dev-listen-address=0.0.0.0:8200","-dev-root-token-id=root"]
              env:
                - { name: VAULT_DISABLE_MLOCK, value: "true" }   # no IPC_LOCK under restricted SCC
              ports: [{ containerPort: 8200 }]
              readinessProbe:
                httpGet: { path: /v1/sys/health, port: 8200 }
                initialDelaySeconds: 3
                periodSeconds: 5
    ---
    apiVersion: v1
    kind: Service
    metadata: { name: vault, namespace: gtc-vault-demo }
    spec:
      selector: { app: vault }
      ports: [{ port: 8200, targetPort: 8200 }]
    ---
    # the deployer's gtc-default-deny blocks ingress to ALL pods (incl. Vault).
    # open Vault:8200 to worker pods so login + KV read aren't dropped.
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata: { name: allow-vault-ingress-from-workers, namespace: gtc-vault-demo }
    spec:
      podSelector: { matchLabels: { app: vault } }
      policyTypes: [Ingress]
      ingress:
        - from: [{ podSelector: { matchLabels: { app.kubernetes.io/component: worker } } }]
          ports: [{ protocol: TCP, port: 8200 }]
    bash
    oc apply -f vault.yaml
    oc -n "$PROJECT" rollout status deploy/vault --timeout=120s
  3. Bootstrap Vault (auth + policy + role)

    Run inside the Vault pod so it uses its own SA token + CA as the TokenReview reviewer. Teaches Vault to trust the gtc-worker SA and hand it a read-only token scoped to this env + tenant.

    bash
    oc -n "$PROJECT" exec -i deploy/vault -- \
      env VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root sh -s <<'EOF'
    set -e
    # dev-mode already mounts secret/ as KV v2; enable transit + k8s auth
    vault secrets enable -path=transit transit 2>/dev/null || true
    vault write -f transit/keys/greentic
    vault auth enable kubernetes 2>/dev/null || true
    vault write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc
    
    # read-only policy: KV reads for this env+tenant + transit-decrypt only
    vault policy write gtc-worker-ro - <<'POL'
    path "secret/data/greentic/vault-demo/tenant-default/*"     { capabilities = ["read"] }
    path "secret/metadata/greentic/vault-demo/tenant-default/*" { capabilities = ["read","list"] }
    path "transit/decrypt/greentic"                             { capabilities = ["update"] }
    POL
    
    # bind the gtc-worker SA (in this namespace) -> role -> policy
    vault write auth/kubernetes/role/gtc-worker \
      bound_service_account_names=gtc-worker \
      bound_service_account_namespaces=gtc-vault-demo \
      policies=gtc-worker-ro ttl=1h
    EOF
  4. Author the tenant-owned env (Vault secrets slot)

    vault.env.json — the secrets slot is Vault, and tenant_org_id makes the env tenant-owned (a bare env fails the scope guard for a tenant deployment):

    vault.env.json
    {
      "schema": "greentic.env-manifest.v1",
      "environment": { "id": "vault-demo", "name": "vault-demo", "tenant_org_id": "tenant-default" },
      "trust_root": "bootstrap",
      "packs": [
        { "slot": "secrets", "kind": "greentic.secrets.vault@0.1.0",
          "pack_ref": "builtin", "answers_ref": "secrets-vault-answers.json" },
        { "slot": "deployer", "kind": "greentic.deployer.k8s@1.0.0",
          "pack_ref": "builtin", "answers_ref": "deployer-answers.json" }
      ],
      "bundles": [
        { "bundle_id": "webchat-bot", "customer_id": "demo-customer",
          "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-vault-answers.json (addr is Vault as seen from inside the cluster; role matches the bootstrap):

    secrets-vault-answers.json
    { "addr": "http://vault.gtc-vault-demo.svc:8200", "role": "gtc-worker" }

    Reuse deployer-answers.json from Demo 1 (the tunnel: off + digest one). Then:

    bash
    gtc-dev op --store-root "$STORE" --answers ./vault.env.json env apply --yes
  5. Seed the secret INTO Vault (transit-wrapped, no plaintext in cluster)

    Seeding goes through Greentic's secrets-put path — which transit-encrypts the value — not a raw vault kv put. Port-forward Vault for the admin write:

    bash
    oc -n "$PROJECT" port-forward svc/vault 8200:8200 &
    sleep 2
    
    cat > seed.json <<'JSON'
    { "environment_id":"vault-demo",
      "path":"tenant-default/_/messaging-telegram/telegram_bot_token",
      "value":"<your-bot-token>" }
    JSON
    
    VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root \
      gtc-dev op --store-root "$STORE" --answers ./seed.json secrets put
    
    kill %1   # stop the admin port-forward; the worker reads Vault under its own SA

    A kubectl get/vault kv get on this path shows ciphertext, never the plaintext. Any per-endpoint webhook secret is seeded the same way under the env-owner tenant.

  6. Reconcile + wait for the worker
    bash
    gtc-dev op --store-root "$STORE" env reconcile "$ENVID"
    export WORKER=$(oc -n "$PROJECT" get deploy -l app.kubernetes.io/component=worker \
      -o jsonpath='{.items[0].metadata.name}')
    oc -n "$PROJECT" rollout status "deploy/$WORKER" --timeout=240s
  7. Verify the workload identity (no secrets in the cluster)
    bash
    # A. there must be NO gtc-dev-secrets Secret
    oc -n "$PROJECT" get secret | grep gtc-dev-secrets && echo "UNEXPECTED" || echo "absent ✓"
    
    # B. the worker carries the Vault identity, not a Secret mount
    oc -n "$PROJECT" get deploy "$WORKER" -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}'
    # -> gtc-worker
    oc -n "$PROJECT" set env deploy/"$WORKER" --list | grep -E 'GREENTIC_SECRETS_BACKEND|VAULT_ADDR|VAULT_K8S_ROLE'
    # -> GREENTIC_SECRETS_BACKEND=vault, VAULT_ADDR=…, VAULT_K8S_ROLE=gtc-worker
    
    # C. the bundle pulled + activated
    oc -n "$PROJECT" port-forward "deploy/$WORKER" 8080:8080 &
    sleep 2; curl -s http://127.0.0.1:8080/status | jq '.revisions_active'; kill %1
    
    # D. (optional) Vault audit shows the worker's authenticated reads
    oc -n "$PROJECT" exec deploy/vault -- env VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root \
      vault audit enable file file_path=stdout 2>/dev/null || true
    oc -n "$PROJECT" logs deploy/vault --since=10m | grep -E 'kubernetes/login|transit/decrypt|"read"'
  8. Teardown
    bash
    oc delete clusterrolebinding gtc-vault-demo-auth-delegator
    oc delete project "$PROJECT"
    rm -rf "$STORE/$ENVID"

    For production, don't hand-roll Vault — use the official HashiCorp Vault Helm chart with global.openshift=true, or point addr at an external Vault outside the cluster.

Known gaps / production caveats