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:
- Tier 0 — pure precheck (no network): an empty or whitespace
task name (or a
modifythat targets no keys) refuses exit 2invalid_inputbefore anything is resolved. - 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_foundHERE, before any prompt: the preview IS the pre-flight. - Tier 2 — confirm-with-preview: without
--yesthe CLI refuses exit 2confirmation_requiredand 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. - With
--yesthe 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: falseon 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 class | examples | guard |
|---|---|---|
| benign | eam_backup | none with the default OnDemand schedule (it never auto-fires; force owns the dispatch) |
| mutating | eam_restart, eam_sendProject, eam_sendResource, eam_sendTags, eam_activateLicense, eam_updateLicense, eam_unactivateLicense | --yes — they act on their agent targets when dispatched |
| fleet-destructive | eam_restoreBackup, eam_installModules, eam_remoteUpgrade | REFUSED 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=Vauto-types scalars: a value that parses cleanly astrue/falseor an integer serializes as a JSON bool/number; anything else stays a string. Arrays and objects are OUT of scope for K=V. -
--definition PATHis the typed/array path: a full-JSON file whose top-level object deep-merges over the composedconfig.settings(objects merge recursively; arrays and scalars REPLACE). Example (the live-capturedeam_backupsettings shape):{
"targetGateways": ["gw-a", "gw-b"],
"targetGroups": [],
"concurrentBackups": 2,
"forceBackups": true
}(
--setting+--definitiontogether 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:
| State | Meaning | What push does |
|---|---|---|
clean | both sides match checkout | nothing |
local_edit | changed locally, gateway at baseline | writes the member |
gateway_drift | gateway moved, local at baseline | untouched (re-checkout to refresh) |
conflict | BOTH sides diverged since checkout | refuses — see below |
deleted (local) | member removed locally | removes gateway-side under --delete, else reported as skipped |
deleted (gateway) | member removed gateway-side | untouched (re-checkout to refresh) |
added | exported gateway-side since checkout | untouched (re-checkout brings it in) |
untracked | a local file that is not a member | ignored — 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 did | What happens |
|---|---|
| saved unchanged | clean no-op — edit: no changes — nothing pushed, exit 0; nothing is pushed, nothing is prompted |
| saved a change | the 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 fetch | refuses 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 encode | refuses 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.