Skip to main content
Version: v5.7.0

Infrastructure nodes

packs:infrastructure

An infrastructure node is one machine-shaped row in the estate tree: a host, hypervisor, network device or link registered under a project, environment or asset, living at <parent path>/infrastructure/<code>. Where the estate nodes themselves (opschain docs getting-started/1-1-projects-environments-assets-and-hosts) are the things changes target and properties converge over, an infrastructure node is an inventory record with a lifecycle: identity (code, name), indexed address columns, a closed type, an estate-membership state, discovered facts and its own properties document. It exists so a host operated by name has one home — the connection profile, the facts and the lifecycle state — instead of user@host strings scattered through workflows. The kind is engineered for tens of thousands of nodes per instance: hot columns are first-class and indexed, lists are keyset-paginated with server-side filters, and registration arrives in bulk.

The node, attribute by attribute​

AttributeMeaning
node_path / parent_pathThe node's own path (…/infrastructure/<code>) and its parent — a project, environment or asset.
codeLowercase letters, numbers and underscores, at most 50 characters; unique among the parent's infrastructure.
name / descriptionDisplay name (max 50) and description (max 1000).
infrastructure_typeOne of the closed registry: host, hypervisor, network_device, link.
address / fqdnNetwork address and fully qualified domain name. Indexed but unconstrained: a same-environment duplicate address warns, never refuses (duplicate_address on the row, a warning on reads).
lifecycle_stateregistered or deregistered (the manual membership states), or one of the reserved integration-asserted states provisioned / destroyed.
archivedShelved but still a member — set and cleared by update.
last_facts_atWhen discovered facts last landed; reads render freshness as last-observed ("facts last collected …"), never a live judgement.
last_used_atWhen a run step last reached the node through a transport session — stamped at dispatch when a host_ref-bound step's session is admitted (a refused session is not a use); unset while no run has touched the node. Last-observed like facts: it says when work last reached the host, never whether the host is up.
properties_id / settings_idThe node's own properties and settings document ids. The long tail of configuration lives in the properties document, served on the get.

The properties document is schema-checked on every write:

  • No credential material, anywhere. Keys like password, private_key, token or credential refuse by name — connection custody belongs to transport providers, and facts writes apply the same refusal.
  • A flat per-type reference vocabulary. A hypervisor's host_ref, a device's mgmt_host_ref and a link's endpoint_a_ref/endpoint_b_ref name sibling infrastructure node paths (the host-targeting keys must name a host); any other *_ref key refuses by name. rpc.provider_ref — the transport-provider binding, a provider code string — is recognised on every type.

The duplicate-address warning scope is the environment subtree for environment- and asset-level nodes, the project for project-level ones.

Lifecycle​

Assertion follows authority. The manual lane — API, CLI and the pack's hand-over actions alike — asserts estate membership only:

  • Register creates the node registered under its parent. Registering a code a deregistered node holds revives the same row (same id, history intact) rather than creating a new one.
  • Update changes attributes in place; omitted fields preserve stored values, and a replacement properties document is validated like registration. Facts land the discovered-facts object under the properties document's facts key and stamp last_facts_at.
  • Deregister closes membership — the soft-close posture: hidden from list defaults and estate reads, full history and events retained, revivable by re-registering the code.
  • Bulk upsert registers or updates a batch under one parent in one set-based write — the lane sync jobs use, since inventories arrive in batches. New codes are created, existing codes updated in place; a per-item validation failure lands in that item's result without failing the batch, and a batch over 10,000 items refuses by name.

The provisioned and destroyed states stay in the schema as reserved slots for the integration that performs those operations; nothing in the manual lane writes them. What the infrastructure pack's hand-over automates is exactly this membership lane: an action that makes a machine exist calls infrastructure:host_register (the ordinary register verb, so the machine becomes estate content), and infrastructure:host_deregister closes membership when it leaves — never an operational state.

How a change targets a host​

A change still targets an estate node; the host enters through the host_ref input on a transport-session step (the infrastructure pack's dialing actions). At dispatch the engine resolves host_ref to the registered host node and re-points the whole transport seam at it: the provider is selected at the host's path (its own rpc.provider_ref, else the deepest-wins scope binding there, else the single enabled provider), and the caller's transport grant is enforced against the host node's path — the grant covers the host, not the declaring scope. Every miss refuses by name: an unresolved expression, an unknown node, a non-host type, and a deregistered or destroyed host each fail the step loudly. The dial profile defaults from the host's rpc.target, rpc.hops and rpc.tofu_store properties.

Expressions enumerate the registry: estate(<path>).infrastructure lists a node's infrastructure, and the two-argument estate('<path>', '<filter>') form is a filtered enumerate over the hot-column fields (code, name, path, type, address, fqdn, lifecycle_state — never the property bag), the fan-out idiom for "every host under prod". See opschain docs expressions.

Why hosts carry no catalog​

An infrastructure node never carries an action catalog of its own. There is no opschain actions list against a host, no per-host action files, and no way to create a change on an infrastructure node. That is a deliberate shape, not a gap, and it rests on four facts about how the platform governs execution.

One governed door into execution. Everything OpsChain runs is a change (or a workflow spawning changes), created against an estate node whose actions come from its pinned template version — and run creation is where every control attaches: approval policies hold matching runs before they start, declared inputs are typed and validated before anything exists, entitlement gates refuse by name, plans preview the step tree, and the whole run lands in the events feed. A second catalog hanging off every host would be a second door into execution, and every one of those controls would have to be built, configured and audited twice. Hosts have no door of their own, so work can only reach a host through the one lane that is already governed.

What runs is kept separate from where it reaches. A template version pins an exact commit of a repository, so what an action does is a reviewable git fact — diffable in review, resolved to a stored commit at write time, lockable for release. An infrastructure node answers the other question: where work lands — the address and connection profile (rpc.target, rpc.hops), the transport-provider selection at the host's path, and the caller's transport grant enforced against that same path. Folding a catalog into the host would collapse that separation and put executable content on what is, by design, an inventory record.

A host has no pinned source to derive a catalog from. An asset's catalog is derived from its template version's resolved commit — remove the pin and there is nothing reviewable left to derive. An infrastructure node carries identity, address columns, a lifecycle and a validated properties document, but no template identity and no repository behind it. A host catalog would therefore have no versioned, reviewable source: nothing to diff, nothing to lock, nothing an approver could read before it ran. The properties document refuses credential material by name for the same reason — an inventory record holds facts about the machine, never the means to act on it.

The host still governs — as an authorisation point. Host-lifecycle operations (patch a fleet, reboot a box, move files, gather facts) are expressed as host-targeted pack actions: the change still runs from an asset's catalog, and the host_ref input names the host. At dispatch the transport provider is resolved at the host's path and the caller's transport grant is enforced against the host node's own path — so the host contributes authority, identity and audit (path RBAC on reads, the transport session events, the registry's own event family) to every operation that touches it. It contributes everything except executable content.

The practical consequence: "run something on this host" is always "run a host-targeted action from an asset's catalog, with host_ref naming the host". The worked exercise in opschain docs getting-started/6-4-hosts-without-a-runner builds exactly that shape end to end — a template, an asset pinned to it, and a fleet of registered hosts the actions fan out over.

The inventory summary​

The questions an operator or an auditor asks of an inventory are aggregate ones — how many hosts per project, which are destroyed but still present, whose facts are stale, which addresses collide, which registered hosts no run has touched in a month, what an integration provisioned or destroyed this week. SummarizeInfrastructure answers them in one read, computed set-based in the database so a large estate never crosses the wire; the GUI dashboard's Infrastructure panel is its consumer, shown only under the packs:infrastructure entitlement.

The summary covers a scope — the whole estate the caller may read when scope_path is empty (a principal whose read grant covers / sees everything; anyone else sees exactly the projects their grant covers), or one project, environment or asset subtree rolled up (an unknown or unreadable scope answers not found). It serves:

  • The matrix — one cell per project × infrastructure_type × lifecycle_state with a non-zero count, archived rows counted in cells of their own so they can be shown apart. Deregistered rows stay in the matrix (they are history, not exceptions).

  • The exceptions — totals over the scope's unarchived, never-deregistered rows, each with a list filter that reproduces it exactly, so a count is always a click away from its rows:

    ExceptionCountsList filter
    stale_factsregistered/provisioned rows whose last_facts_at is older than infrastructure.stale_facts_after, or never setlast_facts_before
    duplicate_addressesregistered/provisioned rows whose address another same-environment node carriesduplicate_address = true
    unusedregistered/provisioned rows whose last_used_at is older than infrastructure.unused_after, or never setlast_used_before
    destroyed_presentrows asserted destroyed that were never deregisteredlifecycle_state = destroyed
    recently_changedrows with an api:infrastructure:provisioned or api:infrastructure:destroyed event in the last 7 dayschanged_since
  • The thresholds it judged against (stale_facts_after, unused_after, the fixed 7-day recently_changed_within) and the instant of judgement (computed_at), so a client rebuilds the matching filters without guessing the server's clock.

Every figure is last-observed: stale means "facts last collected before the threshold", unused means "no run step reached the node within the window" — never a live probe of the machine.

Settings and configuration​

Two stored global settings tune the summary's exceptions (opschain docs settings-reference), both Go duration strings read live on every summary; an absent, malformed or non-positive value falls back to the default:

KeyDefaultMeaning
infrastructure.stale_facts_after168h (7 days)Facts older than this count as stale.
infrastructure.unused_after720h (30 days)A registered node no run step reached within this counts as unused.

Nothing else tunes the component: the bulk-batch cap (10,000 items), the list page caps (default 100, maximum 1000 rows) and the summary's 7-day recently-changed window are built in. The estate read model lives in PostgreSQL: on the embedded SQLite store every infrastructure RPC answers the typed ERROR_REASON_STORE_POSTGRES_REQUIRED error.

Governance and audit​

Authorisation is ordinary path RBAC, 404-on-deny throughout. Lists apply per-row read pushdown (an unreadable node never appears); gets need read on the node's path; the summary pushes the caller's read grant into the aggregate (literal path prefixes bound the SQL; a grant no prefix set reproduces falls back to deciding the scope's rows one by one), so a count never includes a node the caller could not list. Registering (and bulk upsert) needs update on the parent's …/infrastructure rule path and on each new node's own path; update, facts and deregister need update on the node's path. List and get responses carry the caller's own standing (authorisations) so read-only surfaces can drop their edit affordances honestly.

Every write is audited as an ordinary event (opschain docs events):

  • api:infrastructure:registered — a node registered (a revive records the same event).
  • api:nodes:update — a node updated (the same family the estate node writes record).
  • api:infrastructure:bulk_upsert — a batch ran, with item, created, updated and failed counts.
  • api:infrastructure:facts — discovered facts landed.
  • api:infrastructure:deregistered — membership closed.
  • api:infrastructure:provisioned / api:infrastructure:destroyed — reserved for the integration that asserts those states; nothing currently emits them. The inventory summary's recently-changed exception reads exactly these two types, so it stays at zero until an integration asserts a state.

The registry itself is base platform — registering, reading, updating and deregistering nodes carries no entitlement gate, so inventory stays fully available on licence lapse. What is gated is operating hosts: the infrastructure:* actions require packs:infrastructure at run creation, with the polite-lapse posture described on the infrastructure pack page.

The CLI family (opschain docs cli/infrastructure):

  • opschain docs cli/infrastructure-list — filters and pagination.
  • opschain docs cli/infrastructure-show — one node with its properties.
  • opschain docs cli/infrastructure-register — register one node.
  • opschain docs cli/infrastructure-update — attributes or properties.
  • opschain docs cli/infrastructure-bulk-upsert — batch from a YAML file.
  • opschain docs cli/infrastructure-deregister — close membership.
  • opschain docs cli/infrastructure-facts — record discovered facts.

The API service is InfrastructureService (v2 — opschain docs api-v2): ListInfrastructureNodes, GetInfrastructureNode, SummarizeInfrastructure, RegisterInfrastructureNode, BulkUpsertInfrastructureNodes, UpdateInfrastructureNode, DeregisterInfrastructureNode and RecordInfrastructureFacts. The GUI's Infrastructure page registers in bulk through Import nodes… — one line per node as code,type[,address[,fqdn[,name]]] or a JSON items array — over BulkUpsertInfrastructureNodes, each item's outcome and warnings reported after the batch.