gtc op env applyOne file describes what your Greentic environment should look like. One command makes it real β and keeps making it real, safely, every time you run it.
Today, setting up a Greentic environment with two chatbots takes about 13 commands, typed in the right order, copying IDs from one command's output into the next. Get one wrong, and you debug.
This plan replaces all of that with one JSON file that says "here is what I want my environment to contain" β and one command (gtc op env apply) that reads the file, compares it with what currently exists, and does only what's missing. Run it twice? Nothing breaks β the second run says "everything is already as you asked" and stops. Like a shopping list the system fulfils itself, instead of a recipe you must cook by hand.
Bringing up a multi-bundle environment on the new deployment model today takes ~13 imperative commands across 4 different tools. The my_demos/two-dept-telegram demo (two Telegram bots, two isolated bundles, one runtime) is the canonical example: env init, trust-root bootstrap, two secret seedings via a separate tool, two deploys, two route-binding updates, two endpoint additions, two endpointβbundle links, a sanity check, a tunnel, and the runtime start.
The command count is a symptom. The root cause: there is no single source of truth for environment desired state. Each command is an imperative RPC, and the user is the reconciliation engine β capturing endpoint_id from one verb's output with jq to feed the next, ordering steps, re-running on partial failure, and plumbing secrets through a separate tool.
Imagine assembling furniture where every screw needs a separate phone call to the manufacturer, and step 7 needs a serial number that step 6 prints out. The instructions work β but you are the project manager, the note-taker, and the error handler. We want to hand over the blueprint and say: "make it so."
gtc op env initgtc op trust-root bootstrap --answers β¦greentic-secrets apply β¦ (legal token)greentic-secrets apply β¦ (accounting token)gtc op deploy --answers β¦ (legal)gtc op deploy --answers β¦ (accounting)gtc op messaging endpoint add β¦ + capture endpoint_id via jqgtc op messaging endpoint link-bundle β¦gtc op env show β¦ (sanity check)cloudflared tunnel β¦PUBLIC_BASE_URL=β¦ gtc start# everything wired from one file
gtc op env apply --answers two-dept.env.json
# tunnel + webhook auto-registration:
# already built into greentic-start
gtc start --cloudflared
Re-run the first command any time: unchanged things are skipped, changed things are updated, missing things are created. The plan is printed before anything happens.
An investigation across the workspace found ~70% of the machinery already in place β just disconnected:
| Asset | Location |
|---|---|
Composite single-bundle deploy (bundles add β stage β warm β traffic 100%) | greentic-deployer/src/cli/deploy.rs |
EnvironmentMutations trait β 30 methods / 9 verb groups (PR-3a) | greentic-deployer/src/environment/mutations.rs |
--answers JSON + --schema on every op verb | greentic-deployer/src/cli/*.rs |
| Idempotency keys (ULID) on all mutating verbs | greentic-deployer/src/cli/mod.rs |
Dev-store secrets put with canonical-segment validation | greentic-deployer/src/cli/secrets.rs |
Tunnel supervision + PUBLIC_BASE_URL auto-webhook registration at boot | greentic-start/src/cloudflared.rs, revision_webhook_register.rs |
Plan-then-execute pattern (SetupPlan/SetupStep) β reused as a pattern | greentic-setup/src/plan.rs |
We are not building a new engine β the engine parts exist. We are building the conductor: one component that reads the blueprint and presses the existing buttons in the right order, with the right values, skipping what's already done.
greentic.env-manifest.v1A JSON document with five sections. It is passed via --answers, exactly like every other op verb's payload β "manifest" names the schema, not a new transport. Unlike other answers files (one-shot arguments), this one is a durable desired-state document: keep it in version control, edit it, re-apply it.
// two-dept.env.json β the complete file for the two-bot demo
{
"schema": "greentic.env-manifest.v1",
"environment": { "id": "local", "public_base_url": null },
"trust_root": "bootstrap",
"secrets": [
{ "path": "legal/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_LEGAL_BOT_TOKEN" },
{ "path": "accounting/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_ACCOUNTING_BOT_TOKEN" }
],
"bundles": [
{ "bundle_id": "realbot-legal",
"bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
"route_binding": {
"hosts": [], "path_prefixes": ["/legal"],
"tenant_selector": { "tenant": "legal", "team": "default" } } },
{ "bundle_id": "realbot-accounting",
"bundle_path": "bundle-workspace-accounting/realbot-accounting.gtbundle",
"route_binding": {
"hosts": [], "path_prefixes": ["/accounting"],
"tenant_selector": { "tenant": "accounting", "team": "default" } } }
],
"messaging_endpoints": [
{ "name": "realbot-legal",
"provider_type": "messaging.telegram.bot",
"links": ["realbot-legal"] },
{ "name": "realbot-accounting",
"provider_type": "messaging.telegram.bot",
"links": ["realbot-accounting"] }
]
}
environmentid (required) β which environment to wire. If it doesn't exist, apply runs the env init bootstrap (default capability-slot bindings, including the dev-store secrets binding). public_base_url (optional) β persisted via the env set-public-url path; null/absent means "leave whatever is there" (apply never clears it).
trust_rootv1 accepts only "bootstrap" or omission. Bootstrap loads (or generates) the operator key and adds it to the env trust root β documented idempotent: re-running with the same key is a no-op. A future { "additional_keys": [β¦] } shape extends this without a schema bump.
The "trust root" is the list of keys the environment trusts for signed artifacts. "bootstrap" means: set up my own operator key, once.
secrets[]path β dev-store path <tenant>/<team>/<pack>/<name>, validated before any mutation with the same canonical-segment checks as op secrets put. from_env β the name of an environment variable holding the value. Secret values never appear in the manifest. Missing env var = validation failure before anything is written.
The file says where the secret should live and which environment variable to read it from β never the secret itself. You can safely commit this file to git.
bundles[]bundle_id β the natural key (unique). bundle_path β local .gtbundle, resolved relative to the manifest's directory (manifests must be relocatable); its sha256 digest is computed for diffing. route_binding β hosts / path prefixes / tenant selector, with the same structural rule as bundles.rs (a tenant-selector without a matcher is rejected). Optional: customer_id (required for non-local envs, B10 rule), config_overrides (three-valued: absent = untouched, {} = clear, non-empty = replace).
Each entry says: this app package, served under this URL path, running as this tenant. The two bots stay isolated because each gets its own path (/legal, /accounting) and tenant β so each resolves its own bot token.
messaging_endpoints[]name β manifest-local handle and the endpoint's display_name; together with provider_type it forms the upsert natural key. Ambiguous matches (two existing endpoints with the same pair) are an error, never a guess. links β which bundles this endpoint admits; each target must be in this manifest or already in the env (the latter produces a warning, supporting layered manifests). Optional: welcome_flow, secret_refs.
Deliberately absent: provider_id (the webhook secret token). It's a credential, not configuration β minted on create (reported once in the output), never touched on re-apply, rotated via the imperative rotate-webhook-secret verb.
An endpoint is one channel identity β e.g. one Telegram bot. Linking it to a bundle says "messages from this bot go to this app". The bot's secret handshake token is generated by the system, not written in the file.
gtc op env apply --answers <manifest.json> [--dry-run] [--json] [--updated-by <who>] [--yes]
--dry-run prints the plan and exits without touching anything. --json emits a machine-readable plan + execution report. --updated-by sets the audit principal (default env-apply). Implementation: new module greentic-deployer/src/cli/env_apply.rs; the operator gets the verb for free via the existing OpCommand re-export.
--dry-run exits after step 3. Step 5 re-reads the store and asserts every non-no-op step's effect is visible.AddEndpoint returns the store-assigned endpoint_id, consumed directly by the following LinkEndpoint step. No more jq.sha256(schema β env_id β step_kind β natural_key β desired_state_hash), rendered in ULID's 26-char Crockford-base32 format. Same manifest β same keys β a retry is a replay (HTTP-store dedupe-compatible); changed desired state β new keys β a real mutation.If the power goes out halfway through, just run the same command again. Finished steps are recognized and skipped; the rest completes. There is no "half-configured" trap state, and no extra bookkeeping files β the environment itself remembers what's done.
"Upsert-only" means apply creates and updates, never deletes. Resources in the store but absent from the manifest are left untouched. Per resource type:
| Resource | Natural key | Exists & matches | Exists & differs | Absent |
|---|---|---|---|---|
| Environment | id | no-op | update (only fields the manifest sets) | init / bootstrap |
| Trust root | operator key | no-op (idempotent) | n/a | bootstrap |
| Secret | path | always put* | always put* | put |
| Bundle deployment | bundle_id | no-op (digest + binding match) | see decomposition β | full deploy |
| Messaging endpoint | (provider_type, name) | no-op | update (welcome_flow only) | add + mint credential |
| Endpoint link | (endpoint, bundle_id) | no-op | n/a (set member) | link-bundle |
* op secrets get is NotYetImplemented for all backends, so values can't be compared. v1 unconditionally writes every entry (idempotent overwrite in the dev-store) and the plan honestly says put (cannot diff) β never a false "no-op". When A9 lands a real get, this tightens to write-if-changed with no schema change.
The live revision's bundle_digest (a real sha256: of the artifact, stamped at stage time) is compared with the local file's digest:
op deploy (blue-green re-stage + 100% cut-over), carrying route_binding so no follow-up update is needed.bundles update only β no pointless re-stage.sha256:00) β treated as differs. Failing toward a redeploy beats silently keeping a possibly-stale artifact.Apply fingerprints your app package. Same fingerprint as what's running? Nothing to do. New fingerprint? Roll out the new version safely (the old one keeps serving until the new one is ready). Only the URL path changed? Update just the routing β don't reinstall the app.
Pruning (deleting what's not in the manifest) needs ownership tracking and is the highest-blast-radius failure mode of declarative tooling. Locked out of v1; a possible --prune flag is v2 material.
Plain words: the file can add and change things, but can never accidentally delete something just because you forgot to mention it.
The manifest starts at "artifacts exist". Authoring stays with greentic-dev / packc / gtc dev bundle. Conflating build with deploy bloats both tools.
Plain words: this tool installs and connects apps; it doesn't compile them. Baking the cake and serving the cake stay separate jobs.
--answers, schema named "manifest"The flag convention matches every other op verb β the manifest is apply's payload. But the content is deliberately NOT the wizard-answers shape (LoadedAnswers/AnswerSet: question-keyed, consumed once) nor a batch of per-verb payloads. Schema-name precedent: greentic.pack-config-input.v1.
Plain words: it rides through the same door as every other command file β only what's written inside is different: a description of the destination, not a list of driving instructions.
(provider_type, display_name)Stateless matching β no sidecar mapping file that breaks when applying from another machine. Ambiguity (two existing endpoints matching the pair) is an error naming the colliding endpoint_ids, never a guess.
Plain words: the system recognizes "the Telegram bot called realbot-legal" by name and type. If two things answer to that name, it stops and asks rather than picking one.
Credentials aren't configuration. Minted (high-entropy) on create, reported once, never touched on re-apply. Rotation stays an explicit imperative verb β desired-state credential rotation is a foot-gun.
Plain words: passwords don't belong in blueprints. The system invents them when needed and changing them is always a deliberate, separate action.
Every underlying verb leaves the store valid; re-running apply resumes naturally thanks to upsert + deterministic idempotency keys. Checkpoint files would duplicate what the store already knows.
Plain words: if something fails, nothing is corrupted β fix the cause and run the same command again. It picks up where it left off, automatically.
Apply's verify step is a store-level assertion (re-read, compare). Runtime readiness β trust root usable, secrets actually resolvable, routes served β belongs to the extended doctor (PR-3), which is independent.
Plain words: "apply" checks the configuration was written correctly; "doctor" checks the running system is actually healthy. Two different questions, two different tools.
[{verb, payload}, β¦])The obvious "no new schema" option β and it fails on the exact problem being solved. link-bundle requires the endpoint_id that endpoint add only returns at execution time, so the batch cannot even be authored. And a batch stays imperative: a deploy entry always deploys β no diff, no no-op re-run. It would be demo.sh serialized to JSON.
Considered during the idea phase and pushed back: (a) bootstrap chicken-and-egg β you need a working environment to run the pack that sets up the environment; (b) it places environment-mutation authority inside the capability-sandboxed data plane, contradicting Greentic's deny-by-default / no-ambient-authority principle. The admin surface stays on the operator/start trust boundary (the mTLS admin server already exists in greentic-start/src/admin_server.rs); future web UIs are clients of that API, not packs with mutation powers.
Secrets never appear in the manifest, in plan output, in reports, or in logs β only paths and env-var names. This matches the existing B12a redaction discipline and the ProvisionPlan precedent (redacted values in dry-run plans).
greentic-deployer: manifest + apply coreManifest types, validation, diff, plan, execute, verify for environment / trust root / bundles / messaging endpoints (everything except secrets). --dry-run, --json, --schema, deterministic idempotency keys.
Tests: diff rules with one mutation-discriminating test per comparison term; integration against temp LocalFsStore envs β fresh apply, no-op re-apply, digest-change redeploy, binding-only change, partial-failure resume, endpoint ambiguity error.
greentic-deployer: the secrets[] sectionFactor the canonical-path checks out of secrets.rs into shared helpers; from_env resolution; always-put semantics with honest plan annotation; redaction tests.
greentic-start: doctor readiness checks (independent)Three gaps found in the investigation: trust-root presence/validity (today only checked inside the revision health gate), secrets resolvability (doctor lists declared keys but never attempts a read), messaging-endpoint linkage (not checked at all). The runtime-health complement to apply's store-level verify.
Interactive wizard emitting a manifest (FormSpec/IncludeSpec from greentic-qa; per-verb --schema output seeds question specs) Β· POST /env/apply on the operator store-server when that track's PR-4 lands Β· Admin Web / Adaptive-Card-over-webchat clients (the card backend β render_qa_card, the admin server's QA endpoints β already exists).
Build the engine room first (the file format and the apply command). Friendly faces β a question-and-answer wizard, a web admin page, even a chat-based setup with interactive cards β come after, and all of them produce or edit the same one file. That's why the file comes first: the orchestration logic is written once, not once per UI.
# fresh environment, first apply
$ gtc op env apply --answers two-dept.env.json
ensure-environment local create
bootstrap-trust-root local create
put-secret legal/_/β¦/telegram_bot_token put (from $TELEGRAM_LEGAL_BOT_TOKEN)
put-secret accounting/_/β¦/telegram_bot_token put (from $TELEGRAM_ACCOUNTING_BOT_TOKEN)
deploy-bundle realbot-legal create (sha256:ab12β¦ β /legal, tenant=legal)
deploy-bundle realbot-accounting create (sha256:cd34β¦ β /accounting, tenant=accounting)
add-endpoint realbot-legal create (messaging.telegram.bot, credential minted)
add-endpoint realbot-accounting create (messaging.telegram.bot, credential minted)
link-endpoint realbot-legal β realbot-legal create
link-endpoint realbot-accounting β realbot-accounting create
Run the same command again immediately: every row reads no-op except the two put (cannot diff) secret rows. Rebuild one .gtbundle and re-apply: exactly one deploy-bundle β¦ update row.
Old demo verbs replaced: prereqs + put-secrets + deploy + add-endpoints + show collapse into apply; tunnel + start-bg + auto-wire collapse into gtc start --cloudflared (all pre-existing greentic-start behavior). build-packs / build-bundles remain authoring; teardown remains imperative (upsert-only v1 has no delete).
--prune is possible v2)--check mode (exit 1 on pending diff) for CI β trivial follow-upsecret_refs drift on existing endpoints β warning-only until an endpoint update verb existslocal..gtbundle)/legal goes to the legal bundle, running as tenant legal".