Skip to main content

EAM tasks (ign eam)

The Enterprise Administration Module's task surface — the read-heavy family with guarded writes (eam task new, force, and the 10-04 lifecycle/mutation verbs: suspend/resume/cancel/ modify/delete).

The controller state gate. On a stock gateway the EAM module is installed but its module-settings singleton carries installMode: "NotInstalled" — every RUNTIME endpoint (/data/eam/api/v1/*) answers 403 with "This operation can only be performed when EAM is configured as a controller." That is a STATE refusal, not an auth failure: the CLI classifies it to eam_not_controller (exit 6) with the manual-flip hint, never a misleading auth_rejected. Definitions are different: they are plain config resources (resources/com.inductiveautomation.eam/eam-tasks) and answer on stock gateways — ign eam tasks works everywhere; ign eam history needs the controller.

The manual flip (deliberately NOT a CLI verb — a gateway-ROLE decision): config-resource PUT on com.inductiveautomation.eam/module-settings with installMode: "Controller", array body carrying the current record's signature (find it first: GET …/resources/find/ com.inductiveautomation.eam/module-settings). Live-proven during 07-RESEARCH; the CLI surfaces the state and refuses honestly instead of automating the role change.

Execution honesty (research Pitfall 3). Even on a controller, task EXECUTION needs (1) a Gateway-Network-connected agent target (even _controller self-targets fail with "Gateway network for agent … not connected" until GNET is configured) and (2) a live trial/license ("Trial timer is expired" blocks runs). These outcomes surface as DATA in history rows (level: Failed + the gateway's own detail text) — exit 0 reads, never hidden.

Deferred reads: the scheduled/retry list views remain v1 backlog. The suspend/resume/cancel task verbs shipped (the guarded lifecycle set below); agent-level writes did NOT — see the reconciliation note. The EAM archive store (storage/archived-backups — a controller's fleet-backup inventory, needs serverids) is a different thing from this gateway's gwbk files and stays out of MVP scope.

The guarded task lifecycle verbs (suspend / resume / cancel / modify / delete)​

All five run the two-tier blast-radius guard, and force composes onto the same gate:

  1. Tier 0 — pure precheck (no network): an empty or whitespace task name (or a modify that targets no keys) refuses exit 2 invalid_input before anything is resolved.
  2. Tier 1 — preview fetch (read-only): the CLI finds the definition and its scheduled state and composes the blast radius — task, type, agent targets, pending executions, and the factual controller impact. A bad task name refuses exit 6 not_found HERE, before any prompt: the preview IS the pre-flight.
  3. Tier 2 — confirm-with-preview: without --yes the CLI refuses exit 2 confirmation_required and the refusal message IS the preview line (suspend nightly-backup: suspends task nightly-backup … targets: [gw-a, _controller] pending: 1). The refusal names the task, its agent targets, and the impact — an agent can read the blast radius from stderr alone. Zero mutations have fired.
  4. With --yes the action runs — and core re-checks the state it captured (the authoritative re-read), so a race between preview and write still lands honestly (e.g. suspend on an already-suspended task refuses, fired: false on cancel with nothing pending).

The seam honesty: suspend/resume/cancel ride the RUNTIME seam — on a stock gateway they refuse exit 6 eam_not_controller with the manual-flip hint (the preview fetch's definition read still works everywhere; the refusal comes from the write). modify and delete ride the config-resource seam and work on stock gateways.

The agent-vs-task reconciliation (read this before scripting): the EAM wire has no agent-level suspend/resume. Suspend, resume, and cancel are TASK-scoped runtime verbs — an "agent" is suspended by suspending its tasks, and the blast-radius preview names the affected agents through targetGateways so the task-scoped verb still answers the fleet question. Genuinely agent-level writes DO exist on the wire — quarantined-agent delete, agent approve, agent upgrade — but they are deliberately NOT exposed in v1.1 (deferred scope; nothing plans them).

The eam task new guard ladder​

type classexamplesguard
benigneam_backupnone with the default OnDemand schedule (it never auto-fires; force owns the dispatch)
mutatingeam_restart, eam_sendProject, eam_sendResource, eam_sendTags, eam_activateLicense, eam_updateLicense, eam_unactivateLicense--yes — they act on their agent targets when dispatched
fleet-destructiveeam_restoreBackup, eam_installModules, eam_remoteUpgradeREFUSED outright (exit 6 eam_task_type_refused) — they push backups/modules/upgrades to every agent; run them from the Ignition EAM console (EXT-03 v2 scope)

ANY --schedule-mode other than OnDemand additionally requires --yes (it arms autonomous gateway actions — even eam_backup). An unknown type classifies fail-safe (--yes); the server's own validation is the backstop.

Settings forms (--setting vs --definition)​

  • --setting K=V auto-types scalars: a value that parses cleanly as true/false or an integer serializes as a JSON bool/number; anything else stays a string. Arrays and objects are OUT of scope for K=V.

  • --definition PATH is the typed/array path: a full-JSON file whose top-level object deep-merges over the composed config.settings (objects merge recursively; arrays and scalars REPLACE). Example (the live-captured eam_backup settings shape):

    {
    "targetGateways": ["gw-a", "gw-b"],
    "targetGroups": [],
    "concurrentBackups": 2,
    "forceBackups": true
    }

    (--setting + --definition together is a usage error.)

eam task force semantics​

force = find (owner from scheduledTaskState.details.owner, fallback eam) → POST /eam-tasks/force/{owner}/{name} (204 = dispatched) → a history re-read. The history entry rides the result so the OUTCOME is visible immediately: level/detail are data — a Failed run with "Gateway network for agent … not connected" or "Trial timer is expired" is the honest report of the gateway's execution prerequisites (GNET agent + live trial), never hidden.

A leftover run occupying the task's slot refuses with exit 6 eam_task_in_flight — the gateway's own 409 page text ("Task 'X (forced)' already exists! It must be completed or deleted …") rides the message verbatim; resolve the run from the EAM console (no ign verb deletes runs).

eam_restoreBackup vs ign backup restore — different axes: ign backup restore restores THIS gateway from gwbk bytes; the EAM eam_restoreBackup type dispatches an ARCHIVED backup to fleet AGENTS (and is therefore refused in task new — run it from the EAM console).

Project export/import specifics​

Timeouts. Long transfers are the classic default-timeout death, so both operations carry per-request budgets instead of the 30 s client default: export 120 s, import 300 s. The import is synchronous (the gateway answers when it finishes — no job IDs). If an import times out or the connection drops mid-flight, the gateway state is unknown: verify with ign project list (and re-run) rather than assuming.

Scope metadata. A project export contains views, scripts, named-queries, and the other module project-resources — it does NOT contain tag providers, tags, or UDTs: those are gateway configuration, not project resources (this is why git-module conventions keep a separate tags/ tree). Both project export and project import carry the same static scope: {includes, excludes} arrays in their JSON data so agents and humans always know what a ZIP does and does not contain.

Collision policy. REST exposes exactly two choices: abort (the default — the CLI pre-checks with find and refuses with project_exists before uploading anything) and overwrite (replaces the ENTIRE project — resources absent from the ZIP are deleted). merge is the Designer import popup's mode and is not available via REST; the CLI rejects it at the flag level by simply not offering it.

Script decode/encode (--decode-scripts / --encode-scripts)​

ign project export NAME --decode-scripts [-o DIR] writes the export's members to a DIRECTORY (default <name>-export/) plus, for every JSON member with embedded scripts, editable sidecars:

MyProj-export/
├── project.json
├── com.inductiveautomation.perspective/resources/views/Dashboard/
│ ├── view.json # the member, marker-free
│ ├── view.json.1.py # decoded + dedented script
│ ├── view.json.2.py
│ └── resource.json
└── scripts-manifest.json # JSON-pointer addresses → sidecar

Edit the .py sidecars in nvim/VS Code, lint them with ignition-lint, then re-import the whole tree: ign project import NAME --file MyProj-export --encode-scripts (the sidecars are spliced back and the manifest stripped before the standard import path — --file - is invalid in this mode). An edited member changes ONLY at the spliced spans; a sidecar deleted from the tree keeps the JSON's current value.

The unedited round-trip is byte-exact. Decode → encode with no edits reproduces every member byte-identically (contract-pinned). This rides the exact "Ignition Flint" escape codec ignition-nvim uses (backslash-first multi-pass encode, single-pass decode, common tab-prefix dedent/reindent) with splicing done at raw byte spans — key order and formatting of the gateway's JSON are never touched.

Scope honesty. Only scripts EMBEDDED in JSON resources decode (Perspective view.json component/event scripts, tag event scripts, …). ignition/script-python project resources are ALREADY plain .py members in the export — they land in the tree verbatim and never grow sidecars. Single-line Ignition expressions (under expression and friends) pass through untouched: a value decodes only when it carries script-ish escape markers AND decodes to multiple lines.

Cross-gateway diff & sync​

ign project diff <PROFILE_A> <PROFILE_B> --project <NAME> compares one project's resources across two gateway profiles — the dev→test→ prod promotion workflow's first half. Direction semantics: the output is always B-relative-to-A — added means the resource exists only in B, removed only in A, changed in both with differing content (after normalization), same in both. Human mode prints grouped ADDED/REMOVED/CHANGED sections plus the N same, N added, N removed, N changed summary; JSON carries {scope, profile_a, profile_b, project, project_meta, summary, entries} with every key always present.

Normalization. Every gateway-written resource.json carries attributes.lastModification and attributes.lastModificationSignature, which differ between gateways even for identical content — a byte compare would report everything CHANGED. The diff strips exactly those two fields and compares canonicalized (key-sorted) JSON; every other byte — script bodies, view JSON, descriptors' semantic fields — compares as-is.

Scope honesty. Diff and sync carry scope: "project" in their output metadata: they operate on project resources ONLY. Tag providers are gateway configuration on a different seam — promote them with the shipped pipe across profiles instead:

ign --profile dev tags export [default]P5 -o - | ign --profile prod tags import --file - --provider P5

Two-sided secrets. Each side resolves its own credential through the same chain (env tokens → keyring → basic env pair), and that chain is auth-shape-conditional (see Credential resolution order): the generic IGNITION_TOKEN and basic env pair reach a side only when THAT side's profile declares no auth block. A side declaring keyring or named-env auth resolves its own credential and never picks up the other side's — or the shell's — generic token. For real two-gateway use, store per-profile tokens in the keyring (ign profile add --keyring) so each side authenticates as itself.

Envelope. The output envelope keeps its single profile field — the ACTIVE profile, exactly as every other command resolves it — while data.profile_a/data.profile_b name both sides explicitly.

Sync. ign project sync <A> <B> --project <NAME> --resource PATH... [--all-changed] [--delete] --yes is the second half: the selected resources from A land in B. The mechanism is the resource family's surgery generalized to two clients — export both sides, splice A's member bytes into B's zip (replace_member's put-new descriptor landing rules ride free), then ONE overwrite-import into B. Semantics: direction is always explicit A→B; the default is upsert-only (nothing on B is ever deleted unless --delete is passed); --all-changed promotes everything A has that B lacks or differs on. The --yes guard is mandatory — sync implicitly overwrite-imports the WHOLE project on B (replacing concurrent Designer edits, the resource-put consequence pattern) — and fires before either client resolves: a refusal is exit 2 with profile null and performs zero requests of any kind. An empty effective selection (--all-changed with nothing changed) performs NO import — zero writes, empty synced/removed lists.

e2e witness. The promotion loop is live-runnable against two real gateways: crates/ignition-cli/tests/e2e_projects.rs (project_sync_two_gateways_witness, -- --ignored, needs IGNITION_LIVE_URL + IGNITION_LIVE_URL_B + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1) puts a differing resource on A, diffs it, syncs it, and re-reads it on B (the adoption oracle — never trusting the import's success body). IGNITION_LIVE_TOKEN applies to both sides in the harness (the two-sided-secret caveat above).

Resource editing — the surgical loop (export-zip surgery)​

ign resource changes ONE view/script/query member — the get → edit → put loop:

ign resource list PlantFloor                       # member paths from the export tree
ign resource get PlantFloor ignition/script-python/e2e/scratch > scratch.json
# ...edit scratch.json...
ign resource put PlantFloor ignition/script-python/e2e/scratch --file scratch.json --yes
ign resource delete PlantFloor com.example/views/OldView/view.json --yes

Mechanism (05-02 re-point). There are NO per-resource REST endpoints on 8.3 gateways (the Phase-3 family originally targeted /projects/{p}/resources/** routes that do not exist — openapi-evidenced against real 8.3.x, 575 paths, zero matches). Every resource op therefore rides the native export/import round-trip: export the project to a temp ZIP → perform member surgery (list / read / replace-inject / remove) → import back with overwrite=true. Paths keep their slashes and address ZIP MEMBERS — the file at <collection>/resources/<rest>, listed as <collection>/<rest> (the core module's folder is ignition/, not the com.inductiveautomation.ignition the old docs suggest).

put sniffs its input: valid JSON parses, other UTF-8 rides as text — and BINARY content (a NUL byte near the head, data.bin-class resources like Perspective session-permissions) refuses with exit 6 resource_binary on BOTH get and put: binary resources belong to the export/import family, never the JSON loop.

Perf honesty (accepted trade). Every resource op round-trips the WHOLE project ZIP — heavier than a hypothetical per-resource route, but rigs and dev projects are small and the alternative was the family not working at all. Concurrent-edit race: because put and delete re-import the project, edits made in a Designer between the CLI's export and import are REPLACED (replace-not-merge — resources absent from the ZIP are deleted). This is the documented, accepted trade for the CLI's dev-tool scope; both verbs are --yes-guarded and their refusal messages name the consequence.

e2e witness. The loop is live-runnable for the first time since Phase 3: crates/ignition-cli/tests/e2e_projects.rs (-- --ignored, needs IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1) drives list/get/put/delete through the surgery implementation against a real gateway, including two-sided put honesty (the export before a put carries the member's old content; the export after carries the new).

Workspace — the local edit loop (checkout / status / push)​

ign workspace is a git-like loop over a project's resources: check the resources out to a local tree, edit them with any tool, then push guarded changes back to the gateway.

ign workspace checkout PlantFloor ./plantfloor --decode-scripts
# ...edit ./plantfloor/... with your editor; commit the tree...
ign workspace status ./plantfloor
ign workspace push ./plantfloor --yes --delete

Scope honesty. The workspace is PROJECT RESOURCES only — the source is strictly the project export zip. Tag values NEVER appear in the tree (tags are ign tags verbs, and this is structural: checkout ingests the export zip and nothing else, so tag data cannot ride in).

The manifest is the workspace identity. Checkout records .ign-workspace.json at the tree root: the project, the profile it resolved against, the checkout time, and every member's gateway↔local path pair plus a content hash (descriptor-normalized — gateway lastModification volatility never masquerades as drift). Status and push READ that manifest and never re-derive anything, so the project is never re-typed. Commit .ign-workspace.json; checkout also writes an idempotent .gitignore covering scripts-manifest.json and *.py — the decode artifacts are generated, not source.

Status states are push-relative — one direction, every row names what push would do to the GATEWAY:

StateMeaningWhat push does
cleanboth sides match checkoutnothing
local_editchanged locally, gateway at baselinewrites the member
gateway_driftgateway moved, local at baselineuntouched (re-checkout to refresh)
conflictBOTH sides diverged since checkoutrefuses — see below
deleted (local)member removed locallyremoves gateway-side under --delete, else reported as skipped
deleted (gateway)member removed gateway-sideuntouched (re-checkout to refresh)
addedexported gateway-side since checkoutuntouched (re-checkout brings it in)
untrackeda local file that is not a memberignored — never imported

Human mode includes clean rows (agents diff the full state) and ends with the x local edits, y gateway drift, z conflicts, n untracked summary; the JSON's clean flag is the same verdict in one key.

Push is guarded, spliced, and honest. The refusal without --yes carries the blast-radius preview AS its message — exactly which members would be written and deleted. Conflicts refuse EVEN WITH --yes: writing over a concurrent Designer edit is beyond any flag, so reconcile manually (keep your local change or the gateway's) and re-checkout. The splice rides the manifest-recorded local bytes into a FRESH export — never a stale full zip, so members the gateway deleted since checkout are not resurrected — and imports exactly once. Deletions are --delete opt-in (without it, locally-deleted members are reported as skipped). Untracked files are never imported. An empty selection performs zero mutations and never prompts. Over MCP, confirm is the field: omitting it returns the blast-radius preview as the tool result, setting confirm: true executes the push, and neither form can push over a conflict.

--decode-scripts round-trip guarantee. Embedded JSON scripts decode to counter-named <member>.<n>.py sidecars keyed by scripts-manifest.json (also gitignored); the UNEDITED tree re-encodes through the codec BYTE-EXACTLY per member — proven at tree scale by the checkout contract suite. See Script decode/encode for the codec contract.

Editing ONE resource without checking out the whole tree is ign edit's job — the workspace is for whole-tree authoring.

Edit — the kubectl-edit loop over one resource (ign edit)​

ign edit <PROJECT> <RESOURCE_PATH> is the single-resource counterpart of the workspace loop: fetch the project export → decode the whole tree into a private (0700) temp directory → open the ONE resource in $EDITOR → on save, re-encode, gate, and push the spliced result back with exactly one project import.

EDITOR="code --wait" ign edit PlantFloor ignition/script-python/e2e/scratch

The $EDITOR contract. VISUAL wins over EDITOR (trimmed; an empty value counts as unset); both unset refuses exit 2 BEFORE anything resolves — the envelope's profile is null and zero requests hit the gateway. The variable may carry flags: EDITOR="code --wait" splits into an argument vector with the file path appended LAST (never a shell string) — IDE users need the wait flag INSIDE EDITOR so the CLI observes the saved file, not just the opened one. The editor's exit code is ADVISORY: whether an edit happened is decided by CONTENT (a byte-compare of the re-encode against the untouched re-encode), never by the exit code — editors that daemonize or fork cannot flip a verdict.

Behavior table.

What you didWhat happens
saved unchangedclean no-op — edit: no changes — nothing pushed, exit 0; nothing is pushed, nothing is prompted
saved a changethe blast-radius summary renders (exactly which members the push would write); without --yes that same summary IS the refusal (exit 2 confirmation_required); with --yes the push rides one overwrite import
the gateway moved since the fetchrefuses exit 2 invalid_input — changed on gateway since fetch — EVEN WITH --yes (forcing would clobber a concurrent Designer edit); re-run to fetch fresh and retry
saved something the codec cannot encoderefuses FAIL-CLOSED exit 2 with the codec's own reason, and the temp edit tree is KEPT — its path is printed (the edit tree is preserved at …); fix the member by hand and re-run to retry

Stdout contract. ign edit writes ZERO bytes to stdout in EVERY mode — the child $EDITOR owns the terminal, so all prose (the no-op line, the blast-radius summary, the pushed line, and every refusal) renders to stderr. The contract is byte-scan pinned at the binary level under maximum diagnostics. Success prose is stable and greppable: edit: no changes — nothing pushed and edit: pushed N member(s) to <PROJECT>.

Streaming output (the stdout exceptions)​

ign logs -f is a STREAM: entries print to stdout as they arrive, so there is no single result to wrap. In human mode that means live lines (ISO-UTC LEVEL logger message, profile header first). Under --json/--compact the tail emits NDJSON — one compact entry object per line, no envelope — the second sanctioned stdout exception (after completions). Every other command still emits exactly one envelope. A --timeout expiry ends the tail cleanly with exit 0; without it the tail runs until Ctrl-C, which uses the process default kill (no envelope — plan for it in pipelines).

ign rig logs is the THIRD exception: its output is raw compose log lines, NOT gateway JSON — so it streams verbatim in EVERY mode (--json changes nothing; there is no envelope to add and no NDJSON transformation to attempt). -f follows until Ctrl-C (default process kill, no envelope), exactly the logs -f pipeline caveat.

ign tags export -o - is the FOURTH exception: the export payload IS the product, so it prints raw in EVERY mode (no envelope even under --json/--compact — the rig-logs precedent). This is what makes the pipe round-trip work: ign tags export [default]P5 -o - | ign tags import --file - --provider other. File-mode exports (the default, -o FILE) keep the normal envelope. Since Phase 11 the exception covers every format: --format xml and --format csv stdout mode write the RAW BYTES — the gateway's XML document (CRLF line endings, no declaration, trailing CRLF) or the generated CSV, byte-for-byte, never re-encoded, never pretty-printed, no added trailing newline. In human mode the artifact summary line (exported … → stdout …) goes to STDERR so a pipe stays pure; json mode keeps stdout data-only. The loss-gate refusal prose (below) is stderr-only for the same reason.

ign edit is the inverted FIFTH exception: it writes NOTHING to stdout in ANY mode — the child $EDITOR owns the terminal and all prose renders to stderr — so it never participates in the envelope system at all (see the edit section).

Destructive operations​

Commands that change gateway state (sessions terminate, logs loggers set/reset, project delete, project import --collision-policy overwrite, project sync (the cross-gateway promotion — overwrite-imports the whole project on its TARGET profile), resource put, resource delete (both re-import the whole project — their refusal messages name the consequence), workspace push (splices local edits into a fresh export of the workspace's project — its refusal message IS the blast-radius preview, and its conflict refusal fires even with --yes), edit (the single-resource loop — its guarded push re-imports the project and its refusal message IS the staged diff summary; its staleness and fail-closed refusals fire regardless of any flag), tags provider delete, tags config delete, and tags import --collision-policy overwrite (abort-policy imports need no --yes — the pre-check fails safely before any write), restart — the big one: it takes the whole gateway down for ~1 min — and rig reset, which deletes the rig's volumes, plus rig trial reset, which restarts the trial window, and rig restore, which REPLACES the gateway's state with a gwbk, and backup restore — rig restore's standalone sibling on any profiled gateway, same whole-state replacement — plus the EAM writes: eam task new for MUTATING types or any non-OnDemand schedule (the typed ladder above; eam_backup + OnDemand needs no --yes and the fleet-destructive trio refuses outright instead of guarding), and eam task force, which dispatches NOW, plus the guarded task lifecycle verbs eam task suspend/resume/cancel/modify/ delete — each refusal carries the blast-radius preview in the message (the two-tier gate above)) refuse without --yes (exit 2, confirmation_required, hint names both the flag and IGNITION_YES=1) — non-interactive by design, so scripts and agents pass --yes once and humans get a speed bump. restart is guarded in BOTH forms: plain and --wait. The guard fires before any network activity: a refusal never touches the gateway. The rig guards (rig reset, rig trial reset, rig restore) fire before even rig DISCOVERY (a refusal does zero work of any kind). project delete and project import --collision-policy overwrite are the doubly-relevant pair: besides the CLI refusal, delete's wire request always carries the gateway's own confirm=true query param, and overwrite REPLACES the entire project (abort-policy imports need no --yes — they fail safely server-side). resource put joined this family in 05-02: its member surgery implicitly overwrite-imports the project (the 03-03 unguarded put is superseded), as does resource delete. Termination, restart, project mutations, and resource writes are audit-logged server-side by the gateway. Non-destructive project mutations (copy, rename, set, export) create, relabel, or read rather than destroy, so they carry no --yes. tags alarms ack likewise carries no guard — acknowledging is state-advancing but never destructive (it cannot un-acknowledge anything), the read-adjacent verb family's line.