Server behaviour
Normative decisions for myriarchd, grouped by component: persistence, event bus and leader lock, authentication, the OpAMP endpoint, the HTTP and live interfaces, the GitOps reader, blueprint rendering, assignment, the rollout planner and engine, executors, the artifact cache, the release tracker and the notifier. The last section covers how collectors are installed and configured on hosts.
The rules about choosing a version from the UI are in version-selection.md. The contract with the host updater is in updater-protocol.md. Facts about third-party software are collected in upstream-notes.md.
Each section describes what the code guarantees today. Numbers given as defaults are the shipped defaults and can be changed in the configuration unless stated otherwise.
Configuration and startup
- Configuration is a YAML file with strict decoding (unknown keys are errors), starting from built-in defaults or the
development defaults. Durations are written in Go syntax (
30s). ${VAR}and${VAR:-default}are expanded in all string values. Environment variables of the formMYRIARCH_<PATH>override single keys. Keys ending inFile(dsnFile,clientSecretFile,tokenFile, …) read a secret from a file. An empty secret file is an error.- Validation collects all problems into one error. In production it refuses, among others: a plain-HTTP public URL,
a plain listener without
server.tls.terminatedUpstream, an enabled dev login,cookieSecure: false, disabling the enrollment token requirement without client certificates, WebSocket compression without an explicit acknowledgement, and plainhttp://download base URLs without an acknowledgement. myriarchd config validateruns the same checks, optionally also reading the referenced files, so a configuration can be verified in CI before it is deployed.- Modes:
myriarchd serve(production),myriarchd dev(embedded database, development login and token),myriarchd migrate(the database settings only, see Start, migrations and schema compatibility) andmyriarchd healthcheck(a client of a running server, see Health check). myriarchd dev --install-bundle-dir DIRserves the installer bundles from a directory instead of those of a release (Installer bundles). The flag exists in development mode only:serverefuses it with a message that says why, and the start refuses a directory outside development mode before it touches the database.
Start, migrations and schema compatibility
Three numbers describe the fit between a database and a binary:
- The schema version of a database is its highest applied migration (0 for an empty database).
- The schema level of a binary is the highest migration it embeds.
- The compatibility level of a database is the lowest schema level that a binary must have to run on it. It is the column
min_binary_schemaof the tableschema_compat, which holds exactly one row (migration 00006), and it never exceeds the schema version. A database that is older than migration 00006 has no compatibility level (the tools printnone). A schema from migration 00006 on without the table or without its row is damaged: without the level nobody can say whether a binary fits, so the start andmyriarchd migratefail with an error that asks for a restore from a backup.
Start. myriarchd serve and myriarchd dev connect to the database, read the schema version and the compatibility level
and decide before any other component is created:
| State of the database | database.migrateOnStart: true (default) | database.migrateOnStart: false |
|---|---|---|
| Compatibility level above the schema level of the binary (incompatible) | Refuse the start, change nothing | Refuse the start, change nothing |
| Schema version below the schema level of the binary | Apply the pending migrations under the advisory lock, read the state again, then go on (refuse if the schema is incompatible by now) | Refuse the start at once, change nothing |
| Schema version equal to the schema level | Start | Start |
| Schema version above the schema level of the binary, compatibility level not above it | Log a warning and start | Log a warning and start |
A refusal ends the process with exit status 1 and one of these messages (<...> stands for a number):
- Incompatible:
the database schema is incompatible with this myriarchd: the database is at schema version <V> and needs a myriarchd with schema level <C> or higher (compatibility level <C>), this myriarchd has schema level <S>; start a newer myriarchd release or restore a backup of the database from before the upgrade. - Older, with
database.migrateOnStartoff:the database schema is at version <V> but this myriarchd needs version <S> and database.migrateOnStart is false: run "myriarchd migrate" first, or set database.migrateOnStart to true.
The warning for a newer compatible schema has the level WARN and the text the database schema is newer than this myriarchd but compatible with it (after a rollback, or while a rolling update is under way): run the release that matches the schema as soon as you can, with the attributes schemaVersion, compatibilityLevel and schemaLevel. After the schema step the log line database schema ready names the same three numbers.
The check is made at start. A replica that runs is not stopped when another replica raises the compatibility level. /readyz
checks the schema version only until that check first succeeds, which the start already guarantees; after that it reads neither
the schema version nor the compatibility level again. It therefore notices neither a later restore of an older backup nor a raised
compatibility level: after a restore, restart every replica.
myriarchd migrate loads the database settings and nothing else: the section database of the file (an unknown key inside it
is an error, every other section is ignored), ${VAR} references in it, the variables MYRIARCH_DATABASE_* and database.dsnFile.
It validates only these settings, so it runs where the public URL, the login path and the secrets of other features do not exist,
for example in a job before the server is updated. It runs the schema step of the start with database.migrateOnStart set: it
applies the pending migrations, refuses an incompatible schema and warns about a newer compatible one, and a second run changes
nothing. Results go to stdout, log lines and errors to stderr.
- Without
--checkit printsschema version before: <V>,schema version after: <V>andcompatibility level: <C>(nonewhen there is none). - With
--checkit changes nothing and printsschema version,compatibility level,schema level of this myriarchdandstatus(current,pending,newerorincompatible). - Exit codes: 0 done (with
--check: nothing is pending and this binary can run on the schema), 1 any error (settings, connection, a failed migration), 3--checkfound pending migrations, 4 the schema is incompatible with this binary (with and without--check).
Writing migrations. A migration is never edited after it may have been applied anywhere. From 00006 on, its header (the comment
before -- +goose Up) has two lines that agree with each other. -- Rolling update: says what a replica of the previous version
does on the changed schema. -- Compatibility level: is unchanged, or raised to 00NNN when binaries below the level NNN
cannot work with the schema. There are two valid combinations:
-- Rolling update: expand only.(an explanation may follow) with-- Compatibility level: unchanged.Expand only is a promise that the previous version keeps working: new tables, nullable columns and columns with a default, indexes that are not unique, a constraint that is relaxed. The level stays. A new unique index or a newNOT NULLorCHECKconstraint tightens what the previous version may write, so it is not expand only.-- Rolling update: not compatible, <what stops working>with-- Compatibility level: raised to 00NNN.The migration is one that binaries below some level cannot work with (it drops, renames or tightens something that they use, or changes what stored data means). Up setsmin_binary_schemato NNN, which is greater than the previous value and at most the number of the migration, and Down sets the previous value again. The level NNN is the number of the oldest migration whose binaries still work on the new schema.- A text that starts with neither form, and any other combination (“expand only” with a raised level, “not compatible” with an
unchanged level), fails
TestMigrationHeaders.TestCompatibilityLevelFollowsTheHeadersandTestLevelCheckRejectsViolationsininternal/store/migrationsapply every migration up and down and compare the level in the database with the header. - Every consequence that a user must know before an upgrade goes into the section “Upgrade notes” of the changelog; the development guide describes the steps.
Rollback. A binary that is rolled back to an older release which has this mechanism starts on the newer schema when the compatibility level allows it (with the warning), and is refused otherwise. v0.1.0 does not read the compatibility level, and its migration library accepts a database with higher versions, so it starts on any later schema without a check. A rollback to v0.1.0 is not supported (ADR-0036): back up the database before an upgrade, and go back by restoring the backup.
Health check
Available from v0.2.0. myriarchd healthcheck asks a running server whether it is ready and whether its OpAMP endpoint is in
place, and tells the result by its exit code. The release image has neither a shell nor curl, so this command is the health check of a
container (["CMD", "/usr/local/bin/myriarchd", "healthcheck"]) and the program of the Pod that helm test runs. It is a client of
the server: it reads no configuration, opens no database connection and needs no loopback address.
| Flag | Default | Meaning |
|---|---|---|
--url | http://127.0.0.1:8080 | Base URL of the server: http or https, a host and an optional port. A path, a query, a fragment and credentials are refused. |
--opamp-path | /v1/opamp | Path of the OpAMP endpoint (opamp.path of the server). |
--token-file | none | File with an enrollment token. |
--ca-file | none | PEM file with the CAs that may have signed the certificate of an https server, and no others. Without it the system roots apply. |
--timeout | 10s | Time allowed for both requests together. |
Requests. Two requests, in this order; the second is made only when the first succeeded.
GET /readyzmust be answered with 200. The server answers 503 while its database does not answer, the schema does not fit or its event bus is not connected.- A WebSocket upgrade at
--opamp-path: the request of an OpAMP agent, without its first message. With--token-fileit carriesAuthorization: Bearer <token>and must be answered with 101. Without it the server must answer 401, which shows that the route and the authentication are in place. A server that takes connections without a token (the development mode,opamp.requireEnrollmentToken: false) answers 101, which is accepted as well. After 101 the command sends the normal WebSocket close message (code 1000), waits at most two seconds for the answer and ends the connection.
What the command does not do.
- It sends no
AgentToServermessage. No session is created, no agent is registered and nothing is written to the database. What the server records belongs to the connection: the gauge and counters of the OpAMP connections, the use of the token (its usage statistics) and the access log at debug level. Because the command ends the connection with the normal close message, the server logs at most a debug line about it, and no warning. - It sends no token to another host. It follows no redirect (a 3xx answer is a failure) and uses no proxy, whatever the proxy
variables say, and it checks the certificate of an
httpsserver (there is no switch that skips the check). - It does not take the token from an argument, from the environment or from standard input, and it never prints it. The file must be a
regular file of at most 4 KiB that holds one enrollment token (
myre_, then 32 to 120 letters, digits,_or-; white space around it is ignored). Anything else, such as the database DSN or the token of the bootstrap admin, is refused with exit code 2 before a request is made.
Metrics. A check without a token is an unauthorized connect: myriarch_opamp_connect_rejected_total{reason="unauthorized"} and
myriarch_auth_agent_requests_total{result="unauthorized"} rise by one for each check. The alert MyriarchUnauthorizedConnects of the
example rules, which the Helm chart renders with
prometheusRule.enabled, compares the rate of the first over ten minutes with one per second, so a check every ten seconds (0.1 per
second) stays far below it. A check with a token counts as an authenticated connect and as a use of the token.
Output. One line per request that succeeded goes to stdout, with <url> standing for the address of the server:
readyz ok: GET <url>/readyz answered 200
opamp ok: the upgrade at /v1/opamp was refused with 401 (no token given, as expected)
opamp ok: the upgrade at /v1/opamp was accepted with 101 (the server accepts the token)
opamp ok: the upgrade at /v1/opamp was accepted with 101 (no token given; the server takes connections without one)
The first line is followed by one of the other three. The reason for a failure goes to stderr as one line myriarchd: <request> failed: <reason>,
for example myriarchd: readyz failed: GET <url>/readyz answered 503, want 200: the server is not ready, its log names the cause.
| Exit code | Meaning |
|---|---|
| 0 | Healthy: /readyz answered 200 and the OpAMP endpoint answered as expected. |
| 1 | Not healthy: the server answered, but not as expected (/readyz is not 200, the upgrade was refused although a token was given, or it was answered with something other than 101 or 401). |
| 2 | Wrong usage: a flag or value is invalid, or the token file or the CA file cannot be used. No request has been made. |
| 3 | No answer: the connection was refused or reset, the name was not resolved, the certificate was not trusted, or --timeout ran out. |
Persistence
The store interfaces define the semantics; the PostgreSQL implementation follows them.
- Errors: “no rows” maps to
ErrNotFound, a unique violation toErrConflict. JSONB maps are never returned as nil. JSON that carries domain values (GitOps objects, policy snapshots, annotations, details) is always read with a decoder that keeps integers as integers, otherwise renderings would differ from those ofmyrctl. - Callers assign UUIDs and creation times. The store assigns serial IDs and the update time.
- Transactions run through an
InTxhelper whose sub-stores use the transaction. - Agents. The description upsert inserts or updates the descriptive columns, sets
labelsonly on insert (from the enrollment token), and parses the version into major, minor and patch columns (NULL when unparsable). Reads return fully hydrated agents, including the linked updater in both directions and the package status.- Listing translates the filter into SQL. A text query matches host name, ID, version and attribute values. Attribute
filters combine with OR inside one key and AND across keys, and match identifying, non-identifying and
labels.<k>attributes. “Outdated” and “up to date” compare the version triple, and imply the kindcollector. Sorting always ends with the ID so pagination is stable. - Facets are disjunctive: the count of one facet ignores the filter on its own dimension. Facets exist for status, health, kind, version, fleet (empty means unassigned), OS, site type, country and config sync state, sorted by count.
last_seenis written in batches. Setting the desired config clears the failed marker when the hash changes. A conditional variant sets the desired config only if none is set (bootstrap).
- Listing translates the filter into SQL. A text query matches host name, ID, version and attribute values. Attribute
filters combine with OR inside one key and AND across keys, and match identifying, non-identifying and
- GitOps state. Applying a snapshot is one transaction: upsert every object as JSON (including its source), delete the
ones that disappeared, detect changes by content hash, record the sync, and return the changed object keys
(
<Kind>/<name>, sorted). The active snapshot is the one of the last sync with resultapplied; before any sync the status isnever. - Rendered configurations are content addressed by the SHA-256 of the canonical YAML. Writing an existing hash only touches it. Unreferenced configurations are removed after the retention period (7 days by default).
- Rollouts follow a strict concurrency model: status changes only by compare-and-set, wave approvals only through their
own operation, progress through its own update, and targets by an optimistic update guarded by their update time.
Conflicts are re-evaluated on the next tick. Get and list return waves and counts (
in_progresscountsverifyingas well). - Releases are ordered by semantic version, descending; prereleases are flagged.
- Audit filters accept an action prefix with a trailing
*. - Migrations: the first migration is a contract and never edited. Further changes are new numbered up/down files with an advisory lock so that concurrent replicas are safe. The header of every migration from 00006 on states whether the previous version keeps working, and the database records the result as its compatibility level (Start, migrations and schema compatibility).
Event bus
- Events are JSON envelopes with ID, time and origin replica. The size limit is 7,500 bytes (PostgreSQL allows 8,000 for a notification payload); ID lists are chunked by 100.
- The PostgreSQL bus holds one dedicated connection running
LISTENon the channelmyriarch_eventsand publishes withpg_notify. Delivery is best effort: consumers re-read the authoritative state from the database. - Each local subscriber has a buffer of 256 events. On overflow the event is dropped and counted.
- After a lost connection the bus reconnects with backoff from 1 to 30 seconds, listens again and delivers a local
BusReconnectedevent so that caches and the live hub resynchronize. - A pooler in transaction mode breaks
LISTENand session advisory locks, so myriarchd needs a direct or session pooled connection.
Leader election
A session advisory lock on a dedicated connection decides the leader. A candidate tries the lock every five seconds (with 20 percent jitter).
The failover after a hard failure of the leader’s node (no FIN or RST) is bounded by ha.leaderSessionTimeout T (default
30 seconds, 10 seconds to 10 minutes) as one end-to-end budget: the server must drop the silent session within
D = T − 1.2 × min(5 s, T/6) − T/20 (whole seconds), the time the follower needs for its retry (every min(5 s, T/6),
20 percent jitter) and the acquisition fill the rest. The lock session and the listener session of the event bus get their
own pool (internal/pgsession) and send tcp_keepalives_idle = tcp_keepalives_interval = I = D/6 (at least 1 second),
tcp_keepalives_count = D/I − 1 and tcp_user_timeout = D − I in the startup packet. On Linux the user timeout replaces the count
as the drop criterion, so PostgreSQL drops a silent client at the first probe time (multiples of I) that is at least D − I
after the last packet, i.e. after D − I and before D; a server without tcp_user_timeout drops after ⌊D/I⌋ × I, which is above D − I and at most D. A server that
refuses tcp_user_timeout (SQLSTATE 22023) is used without it and a warning is logged. The pool of ordinary connections is
not changed. A unit test checks the budget with models of both drop rules for all timeouts.
The leader pings its lock session every T/6 with a timeout of T/3 and gives up leadership at the latest T/2 after the last
answer: it first clears the leader flag (IsLeader and the metric myriarch_leader), then cancels the context of the leader
jobs. That is before the server can release the lock, so there is no second leader. After giving up, the replica asks the
server over a regular connection to end the lock session (pg_terminate_backend, only a backend that holds the lock), and
then competes for the lock like a follower. A planned stop unlocks explicitly and closes the session, so a follower takes
over within one retry interval. Operations: High availability.
Leader-only jobs: GitOps polling, the rollout planner and engine, the release tracker, artifact prefetch, the offline sweeper, housekeeping and the periodic gauges. Everything else runs on every replica.
Authentication and authorization
- Tokens. API tokens start with
myra_, enrollment tokens withmyre_and download tokens withmyrd_. A token is 32 random base62 characters; only the SHA-256 is stored, together with a display prefix. Lookups are by hash. Tokens can be revoked or expire, and a token created by another token is capped by the creator’s role and expiry. A download token is signed, not stored, and comes in two kinds: the token of a package offer (bound to one updater and one artifact) and, from v0.2.0, the install grant of a host that is not enrolled yet (Artifact cache). - Browser sessions are server side. The cookie holds 32 random bytes and the database key is their SHA-256. The cookie is
HttpOnly,SameSite=LaxandSecureby configuration. A session has a sliding idle timeout (default 2 hours) and an absolute lifetime (default 12 hours), after which identity provider changes take effect. - CSRF. A cookie-authenticated request with a mutating method must carry the session’s token in a header (double
submit); otherwise the answer is 403
csrf_failed. Bearer-token requests are exempt. - OIDC login uses the authorization code flow with PKCE, state and nonce, held in a short-lived HMAC-signed cookie whose
key is shared between replicas. The ID token is verified (audience, nonce). The role is the highest one matched by
the group mapping, else the default role; an empty default role denies login (403). Manually assigned roles survive.
The post-login redirect must be a relative path. Failures redirect to the login page with
access_deniedoroidc_errorand are audited. Identity providers that omit the groups claim for users with many groups need app roles as the claim source. - Roles are viewer, operator and admin. Permissions are checked per route from the OpenAPI
x-permissionvalue. - Development login exists only when explicitly enabled outside production and compares in constant time.
- Caches: sessions and API tokens for 5 seconds, enrollment tokens for 60 seconds (unknown tokens 10 seconds, kept apart from the valid ones). Logout, revocation, disabling and role changes publish invalidation events, so every replica drops its cache at once.
- Failed logins and invalid bearer tokens are rate limited per client IP; IPv6 clients are counted per /64 prefix, and an
IPv4-mapped address counts as the IPv4 address, in every authentication failure limiter and in the OpAMP connect
guard (
opamp.connectRatePerIp; one key function,realip.LimiterKey). Behind a proxy the client IP is real only for addresses listed inserver.trustedProxies. - Enrollment token lookups. Only tokens of the documented form (
myre_followed by 32 to 120 letters, digits,_or-) are looked up. Unknown tokens are cached negatively for 10 seconds in a separate cache of 1024 entries, so they cannot evict valid tokens from the cache of 4096 entries; concurrent lookups of one token are coalesced; lookups of tokens without any cache entry are limited per replica to 100 per second (burst 200) and answered with429andRetry-Afterbeyond that. Refreshing a token seen before is not charged. Usage statistics are written with one statement per token every 5 seconds. - Agent authentication. An
Authorization: Bearer myre_...header must be valid when present. Withopamp.requireEnrollmentTokena token is mandatory. Withopamp.requireClientCerta verified client certificate is mandatory too (token and certificate both). A certificate is either verified by myriarchd’s own TLS or forwarded in a configured header by a trusted proxy and verified against the client CA. - Install routes. The plan for installing a host from the server and its downloads need a valid enrollment token,
always: also where
opamp.requireEnrollmentTokenis off (Installation from the server). The rules of the OpAMP endpoint apply, including the client certificate rule, the token cache and the bound on lookups. Failed authentications on these routes are limited per client address as well, because the routes can be reached by anyone and share the lookup bound with the OpAMP endpoint. The download of an installer bundle is such a route. Rendering the commands that a person copies to a host is not: it is an ordinary call with a session or an API token and the permissionread. - Credential binding. An instance UID is bound to the enrollment token (or certificate subject) it first registered
with. A connection that presents another credential for that UID is rejected, an
identity_conflictevent and an audit entry are written, and an administrator re-enrolls the agent. Optionally an agent whose bound token was revoked or expired moves to a valid one automatically (agent.rebind). - The download-token secret (32 bytes) comes from a file or is created once in the key-value store.
OpAMP server
myriarchd mounts the OpAMP handler of the Go library into its own HTTP server, so UI, REST and OpAMP share one listener. The library’s connection-context hook must be installed on that server; without it the plain HTTP transport fails.
Connection admission
For each WebSocket handshake and each HTTP poll, in this order:
- During shutdown: 503 with
Retry-After: 10. - Per-client-IP token bucket: 429 with
Retry-After: 5. - WebSocket only: above
maxConnectionsPerReplica(default 10,000): 503 withRetry-After: 30. - Authentication (above): 401 with
WWW-Authenticate: Bearer, or 503 when the credential store is unavailable. - Replica-wide token bucket (default 200 new connections per second, burst twice that): 429 with
Retry-After: 5. It is charged after authentication so that bad credentials cannot exhaust it for real agents. - Optional per-token connection limit.
The connection then gets its own callbacks bound to its state: credential, remote address, transport and the last sequence number.
Message processing
For each agent message, in this order:
- The instance UID must be 16 bytes, else a bad-request error response. A WebSocket connection speaks for exactly one instance UID (plus a UID the server issued on it); a switch closes the connection.
- Check the credential binding and detect cloned images. When a message for a UID comes from another host while the
original is still online, the newer connection receives a new UUIDv7 through
AgentIdentificationand a request for the full state, plus anidentity_conflictevent. - The first message of a WebSocket registers the connection under its UID and replaces an older connection of the same agent. A known agent is marked online with replica, transport and address; an unknown one only after step 6.
agent_disconnectis handled like the end of the connection.- Last-seen is recorded in memory and flushed in batches (15 seconds).
- The server asks for the full state when the message has no description and the agent is unknown, the message is the first of its connection, or the sequence number does not follow the previous one. Reconnecting clients send an almost empty message, so this request is essential.
- A description updates the agent: attributes are converted to strings (bool as
true/false, integers in decimal, floats in the shortest form, bytes in base64, lists and maps as JSON). The kind isupdaterfor the updater’s service name,collectorwhen the agent accepts remote config, elseunknown. Events:registered,version_changed,description_changed. For collectors the assignment runs synchronously (errors are logged and the connection stays up). For updaters the collector link is stored, subject to the linking rule. - Health is stored recursively with times; an event is written when healthiness flips.
- The effective configuration is stored by hash when it changed and is within the size limit (1 MiB default); an empty content type means YAML.
- A remote config status is stored.
FAILEDfor the desired hash marks that hash as failed. Eventsconfig_appliedandconfig_failed. A report ofAPPLYINGalso feeds the rule for a staleAPPLYING. - Available components are stored by hash. If only the hash arrives and the set is unknown, the server asks for the components. When the set of a collector changed, the assignment runs again, because the rendering validates against it.
- Package statuses are stored; an event on status change. Download progress is compared at whole percent.
- If anything changed,
AgentStateChangedis published. - The reply is built by the reconcile step.
Reconcile: what the reply contains
- The server capabilities are always set (accepts status, offers remote config, accepts effective config, offers packages, accepts package status). The library does not set them.
- Remote config is offered when the agent accepts it, a desired hash exists, the agent has not reported that hash as
applied, and the hash is not marked failed (a resync clears the mark). The same hash is not offered twice within 60
seconds on one connection. A hash that the agent reports as
APPLYINGis not offered again, except under the rule for a staleAPPLYING. The message carries exactly one entry, keymyriarch.yaml, content typetext/yaml, and the hash. An empty configuration map is never sent, because the supervisor would start its no-op pipeline. - A supervisor that rolls back reports
APPLIEDfor the old hash, so the failed marker must be respected or the offer would loop. A repeated failure of the same hash sets the marker again. - Package offers go only to updaters that accept packages, only when offers exist and the reported package hash differs. An empty offer set is never sent because the client would delete its local packages. Download tokens are minted when the offer is sent (see updater-protocol.md).
- A restart command is always a separate message, because a command replaces all other fields. It is sent only to agents that accept it; otherwise the REST caller gets a precondition error. An OpAMP Bridge is never sent one, and it is offered remote config only under the rules of Bridge agents.
- Connection settings are not offered. The supervisor stores them only in memory and disables certificate checks when no certificate is offered.
- For performance, heartbeats must not query the database. Each connection caches the desired and failed hashes, the
reported hash, the offer hash and the capabilities. The cache is refreshed on own writes, on desired-state,
assignment and command events, after a bus reconnect and at least every five minutes. Every write that changes
desired state, failed marker or offers must call
DesiredStateChanged. A status that another replica stores is none of these events. The one decision that rests on the stored status is the offer for a staleAPPLYINGof a pooled HTTP session (see Stale APPLYING after a reconnect).
Bridge agents
The OpAMP Bridge of the OpenTelemetry Operator speaks for many collector resources, and a configuration message that lacks the key of a resource deletes it (see the upstream notes and ADR-0037). The server therefore recognises a Bridge and holds back every action that changes something. All of it fails closed.
- Recognition (
descriptionUpdate,internal/opamp/convert.go). An agent is a Bridge when it reports theservice.nameio.opentelemetry.operator-opamp-bridgeor the attributeopentelemetry.io/opamp.bridge.mode, as an identifying or a non-identifying attribute. The test comes before the capability test, so a Bridge is never a collector. The kindbridgeis stored inagents.kind(migration00007_agent_kind_bridge.sql) and shown askindin the REST API. An agent that was stored as a collector becomes a Bridge with its next description, and the effective configuration stored before is forgotten. - Assignment. The assignment service matches a Bridge like a collector, so that it is a member of the fleet that selects
it. It renders a configuration for it, and sets the desired hash, only when
domain.BridgeConfigLockfinds no reason: the fleet setsspec.bridge.manageand the Bridge reports the modestandalone. Otherwise the assignment keeps the fleet name, stores the reason as the assignment error and sets no hash; the lock is not a render error (no event text, the metric label isbridge_locked). A Bridge gets no target version, with the opt-in as well. The mode isstandaloneonly when every reported value of the mode attribute (identifying and non-identifying) says so; a contradiction is the unknown mode and stays locked. - Guard in the reconcile step (
bridgeConfigLocked,internal/opamp/bridge.go). Whatever the assignment did, reconcile offers a Bridge no remote config unlessBridgeConfigLockallows it. It reads the fleet of the agent from the stored snapshot, so the opt-in applies at once, and a fleet that cannot be read locks the Bridge. It runs for a Bridge that has a fleet even when the assignment set no desired hash, which is the normal case of a locked Bridge, so the lock itself is recorded. Every offer goes through reconcile (first message, heartbeat, push, resync, reconnect), so this is the one place that has to hold. - No restart. The REST action answers
412(internal/coreapi/agents.go);sendRestartand the polling path of HTTP agents refuse the command as well, in case a command was queued by another replica or an older release. - No effective configuration.
processEffectiveConfigstores nothing for a Bridge, and the REST endpoint answers404. - Rollouts. A Bridge is never a member of a version rollout. It is a member of config rollouts only while its fleet
configures it (
BridgeConfigLockfinds no reason), so waves, approvals and audit apply to its configuration; a locked Bridge is not. The tick skips a target whose agent became a Bridge after the rollout began (bridgeHeldBack), so no executor is called for it. For a fleet that has an effective target version the planner says once per agent and version (per term of leadership) that the Bridge is left out, as an agent event and an audit entry. - Events and audit. Each held-back action is an agent event of the type
bridge_locked(severitywarning; detailsaction,reason) and an audit entryagent.bridge_held_back. A configuration offer is noted once per reason and desired hash on a connection, not per message. - Limits. The recognition rests on what the agent reports, so it is a safeguard against mistakes, not an authentication (ADR-0037, “Limits”). During a rolling update the replicas of an earlier release still treat a Bridge as a collector.
Stale APPLYING after a reconnect
A supervisor that restarts while it applies a config has stored the status APPLYING, reports it again after it
reconnects, and has no timer that ends it
(upstream notes). The base rule never offers a hash again
that the agent reports as APPLYING, so the target of a configuration rollout would wait for the applyTimeout of its
policy (10 minutes for configuration by default). The server therefore offers the desired config once more under this
rule. It is evaluated per connection from the cached state of the session, so heartbeats stay free of database access;
only a pooled HTTP session reads the agent, once, when an offer is due.
- A window opens when the agent reports
APPLYINGfor a hash on a connection and either this connection did not offer that hash itself (a new WebSocket connection, another replica, a new pooled HTTP session), or the server had stored the very same status already (the agent reported it again: after a reconnect, after a restart on a long-lived pooled HTTP session, or because the full state was requested). The window starts with that report. A status that this connection caused with its own offer opens no window: theapplyTimeoutof the rollout bounds it. Any other status closes the window, andAPPLYINGfor another hash is judged by the same rules. - A status that the server stores anew is a new episode. The status differs from the one stored before. It replaces an open window, also one for the same hash, and ends the record of an earlier stale offer. A session that did not see the report that ended an earlier episode (the polls of one agent are spread over the replicas) therefore does not reuse its old window when the agent applies the same hash again, as after a rollback.
- The offer goes out with the next reply of the connection (a heartbeat, a poll or a push) when all of these hold:
opamp.staleApplyingAfteris greater than 0; the window of the desired hash has been open for at least that long (default 90 seconds); the cached status of the agent is stillAPPLYINGfor the desired hash; the hash is not marked failed; this connection has not made such an offer for the hash yet; and the conditions of every offer hold (the agent accepts remote config, the rendered body exists and is not empty, the same hash is not sent twice within 60 seconds on one connection). The message is the ordinary remote config with one entry. A supervisor answers it withAPPLIEDat once when its merged configuration did not change, without restarting the collector. - A pooled HTTP session decides on the stored status. It shares its agent with the sessions of the other replicas, and a
status that one of them stores does not reach its cache: after
APPLYINGon one replica andAPPLIEDon another, the first still cachesAPPLYINGuntil its next refresh. When the cache says that an offer is due, the session therefore reads the agent again, replaces its cache and makes the offer only if the stored status is stillAPPLYINGfor the desired hash. A read that fails makes no offer, and the next reply tries again. That is one read for an offer that is due. Heartbeats before that, WebSocket sessions (their connection is the only writer of the status of its agent) and a resync stay free of database access. - At most once per connection and hash. The record of that offer ends when the agent reports another status or the server stores a new episode of the hash.
- The time counts from the report on the connection, not from the time the status was stored. A connection that ends
before the time has passed never causes an offer, so an agent that flaps cannot be offered the same config on every
connection, and an agent that comes back after a long absence is not offered the config before it has told its current
status. An agent that reports no status in its full state opens no window, and its target waits for the
applyTimeoutas before. The time of the store only postpones an offer: the status cannot have been unchanged for longer than it has been stored, so when another replica stored a newer status of the same hash after this session opened its window, the count starts at that time. A stored time in the future of the clock of this replica is ignored. - Visible as one agent event
config_offeredwith severitywarninganddetails.reasonstale_applying(withhash,resync: falseandapplyingSeconds), as the countermyriarch_opamp_config_reoffers_total{reason="stale_applying"}and as a log line. The rollout engine is unchanged: the target reachesverifyingwhenAPPLIEDarrives. - Off with
opamp.staleApplyingAfter: 0. The value must not be negative. Keep it above theconfig_apply_timeoutof the supervisors (30 seconds in the shipped templates), so that a supervisor that is merely slow is not offered the config again: the repeated offer makes it reportAPPLIEDbefore the collector has proven healthy, and withautomatic_config_rollbackit records the configuration as the last working one.
Push path
A subscription on desired-state, command and assignment events reloads each affected local WebSocket agent, reconciles,
and sends a non-empty reply. The library’s send call ignores its context and a write deadline on the socket is reset
by the WebSocket library, so each send runs in a goroutine and a connection that does not accept the data within
sendTimeout (10 seconds) is closed. At most one push is in flight per connection. HTTP agents receive changes with
their next poll response. A resync command clears the failed marker and requests the full state.
Connection end, leader jobs and shutdown
- When the current WebSocket connection of an agent ends, last-seen is flushed, the agent is marked offline and
AgentStateChangedis published. HTTP polls never mark an agent offline; the sweeper does. - Leader jobs: mark agents stale after
opamp.offlineAfter(90 seconds, three heartbeats) every 15 seconds; remove replicas without a heartbeat for 30 seconds every 10 seconds and mark their agents offline; refresh gauges every 30 seconds. Every replica sends its own heartbeat with connection count and leader flag. - Shutdown: new connections get 503, existing ones are closed gradually (at most 100 per second), last-seen is flushed and the replica’s agents are marked offline.
Pitfalls to remember
- The connected callback receives an already cancelled request context for WebSocket, and the message callback receives a background context: use explicit timeouts for database calls.
- The full state arrives only with the first connection of a client; the available components arrive as a hash first.
- The first effective configuration of a supervisor comes from the supervisor itself with an empty content type, and the collector’s follows (about 30 KB with defaults). Only a changed hash is stored.
- Health status strings are
StatusStarting,StatusOKandStatusStopping; the top-levelhealthyflag decides. - Heartbeats are empty messages with an increasing sequence number.
- A remote config status that comes back after a reconnect is not progress.
APPLYINGcan be the stored status of a supervisor that restarted while it applied (see Stale APPLYING after a reconnect).
HTTP server
- Routes are registered from the route table as
METHOD /api/v1<pattern>. Duplicate operation IDs and pattern conflicts are startup errors.x-permission: publicmaps to no authentication,customto handler-level authentication (used for webhook and artifact download), anything else to a required permission. - Middleware order: request ID, real client IP, access log, metrics, recovery, body size limit, authentication by route mode, CSRF, permission check (403).
- The OpAMP path skips user authentication;
/healthz,/readyzand/metrics(on an optional second listener) exist;/licensesservesLICENSEandTHIRD_PARTY_NOTICESastext/plainwithout login (route decisionpublic); everything else serves the embedded UI. - Only a header read timeout is set. A global write or read timeout would cut WebSocket and SSE connections. Response writer wrappers must pass through hijacking (WebSocket upgrade) and flushing (SSE).
- Graceful shutdown uses
server.shutdownTimeout; the grace period of the pod must be at leastpreStopSleepSeconds+server.shutdownTimeout+ 5 seconds (High availability).
REST notes for the core resources
- Agent list:
outdated=truecompares with the newest stable release of the distribution (unknown release: none are outdated), repeatedattr=k=vparameters follow the OR-within-key rule, and a summary carries site attributes, config sync state and whether an updater is linked. - Restart requires the capability and an online agent, else 412. Resync clears the failed marker first, also for offline agents. Delete works only for offline agents. All three are audited.
- Rollout targets list: without
sort(or withsort=wave) targets are ordered by wave, hostname and agent ID, stable while statuses change.sort=activityorders by status: in_progress and verifying, waiting_window, failed and rolled_back, pending, succeeded, skipped, cancelled; ties by wave, hostname, agent ID. Rows can move between pages while the rollout runs. The UI usesactivity. Any other value is a 400. The optionalagent(agent ID) filter returns only the target of that agent; the UI polls it for the open detail view, which therefore stays current when the target moves to another page. Anagentthat is not a UUID is a 400. - The system info endpoint derives the OpAMP URL from the public URL (
httptows,httpstowss) plus the OpAMP path.
Live stream
GET /events/stream is a server-sent event stream. The hub turns bus events into agent.updated (coalesced per agent,
at most one per second), agent.removed, rollout.updated, fleet.updated, gitops.synced, release.new,
stats.changed and resync. A client with a full buffer (256) is sent resync and its buffer is cleared. A : ping
comment goes out every 15 seconds. Headers include Cache-Control: no-cache and X-Accel-Buffering: no, and the
stream is flushed after every event. There is no replay: after a reconnect the client refetches what it shows.
GitOps reader
- Source: a local directory (
dir:<path>, revision is a hash over sorted paths and contents) or a Git remote. Git uses HTTPS with a token file or SSH with a key and a known-hosts file, a configurable CA, a sub-path and an in-process clone that is fetched and hard reset to the remote branch. - Parsing. All
*.yamland*.ymlfiles below the path are read, except directories starting with.andsamples/. A file may hold several documents. Each has the envelopeapiVersion: myriarch.dev/v1alpha1,kind,metadata.name(lowercase DNS-label style) andspec. Unknown fields are errors, found by walking the YAML node tree against the target types so that line numbers survive; free-form areas are exempt (blueprint config, fragment config, fleet parameters, update params, inventory parameters, executor extra variables). Duplicate names per kind are errors. - Bounds: symbolic links and other non-regular files are rejected (
not_regular_file); a file may be at most 4 MiB; there is a limit on the number of YAML files (2,000) and their total size (64 MiB); a time budget applies. Violations are errors (file_too_large,too_many_files,repository_too_large,repository_too_complex,time_budget_exceeded). - Secrets never belong in the repository. Blueprints use
${env:VAR}or${file:/path}, which the collector resolves on its host. Executor definitions only name environment variables or files on the myriarchd host. - A sync computes the revision. If unchanged, it only updates a status timestamp (an “unchanged” entry is logged at most
hourly). Otherwise it parses, validates the whole snapshot including a rendering of every sample agent, and applies it
only if it is completely valid. An invalid state keeps the previous one, sets the status to
errorand sends agitops.sync_failednotification once per commit. After applying, assignment and the planner are triggered and an audit entrygitops.applyis written. A failing post-apply step is retried in the next cycle. - Trigger: initial sync at startup, polling, a manual request and a webhook (
POST /gitops/webhook). The webhook accepts a shared-secret header or an HMAC signature header (X-Hub-Signature-256), compared in constant time, and answers 404 when no secret is configured. - Sample agents (
samples/agents/*.yaml) are inputs formyrctland validation; they are not stored as objects.
Blueprint rendering
Rendering is a deterministic function of the snapshot, a fleet, the agent’s attribute view, inventory parameters and
optional preview overrides. myriarchd and myrctl run the same code.
Attribute view
A flat string map: all non-identifying attributes, overlaid by identifying attributes, plus server labels under the
prefix labels.. Server labels come from the enrollment token and from inventory entries. The prefix belongs to the
server: reported attributes with it are discarded. Except for labels.*, attributes are self reported, so an attribute
selector is a convenience and not an access boundary. Where fleets must be kept apart, select on labels.*.
Algorithm
- Resolve the chain from the fleet’s blueprint through
extendsto the root. Missing blueprints and cycles are errors. - Declare parameters from root to leaf. A child may redeclare a parameter with the same type; the redeclaration
replaces the parent’s completely. A different type is
parameter_type_conflict. Resolve values; later wins: default, fleet parameters, value from an attribute (fromAttribute), inventory parameters, preview overrides. Then check type, enum, pattern (RE2, whole string) and range. Missing required values aremissing_parameter; a fleet parameter that is not declared isunknown_parameter. - Merge layers without evaluating templates: for each blueprint in the chain merge its config, then each fragment whose
whenis true (in file order, merge then apply itsremove), then apply the blueprint’sremove. Record the applied layers. - Evaluate templates over the finished tree (keys and string values).
- Validate. Structural validation always runs; component validation runs when the agent’s component set is known. Errors produce no result.
- Serialize canonically and hash.
Merge semantics
Maps merge recursively. Everything else, including lists, scalars and null, is replaced entirely by the overlay.
A null in an overlay means “default configuration of the component”, not removal; removal is only through remove,
which uses confmap paths with :: separators. Extending a list therefore means writing the complete list. Keys that
contain :: are forbidden. The supervisor additionally concatenates service::extensions and merges resource
attributes by name when it combines the remote configuration with local files, so blueprints need not mention the
opamp extension.
Templating
- Delimiters are
[[and]](Go templates, missing keys are errors). Collector syntax (${env:...},${file:...},$$) is not interpreted. Values starting with[[must be quoted in YAML; the validator reportsunquoted_templatefor the typical mistake. - A string that is exactly one expression is evaluated typed: the result keeps its type (a number stays a number, a list a list, a string with leading zeros stays a string). Anything else is an interpolation that yields a string.
- Rule GIT-4: lists and maps are never printed implicitly into text. Interpolating one is a
template_error; the author first converts it withjoin,toJsonorstr. - Data available:
.params,.agent.id,.agent.attributes,.fleet.name,.fleet.labels,.blueprint.name. Functions:attr,attrOr,hasAttr,param,default,required, string helpers,contains,hasPrefix,hasSuffix,join,split,list,dict, conversions, integer arithmetic andtoJson.define,templateandblockare forbidden; templates cannot reach the environment, files or the network. - Resource bounds, which apply to every template string including map keys and executor extra variables: text produced
by any helper or direct output is limited to 64 KiB, and a value is checked before it is converted to text so that
composite values cannot blow up memory; all helpers together produce at most 8 MiB per string;
listandsplityield at most 10,000 elements;printfallows no*width or argument index and widths of at most 256;rangeaccepts only lists and maps, nests three deep and iterates at most 10,000 elements; evaluation of one string is limited to 5 seconds, checked on every pipeline that executes. - Error messages never quote untrusted values in full: type errors name only the type, values are quoted up to 40
characters, and the message of
requiredup to 200.
Canonical form and hash
The result tree holds only maps, lists, strings, booleans, integers, floats and null. It is serialized with the YAML library at indent 2, sorted keys, no comments and no document marker. The configuration hash is the SHA-256 of exactly those bytes. The golden files in the repository are the byte-exact reference.
Validation
ValidateSnapshot checks schema, references, cycles, parameters, templates, selectors, policies and executors (statically
only: no environment variables, no network access), then renders each fleet with each matching sample agent and validates
the result. fleet_ambiguous and sample_unassigned are warnings. ValidateConfig checks structure (top-level keys,
component IDs, pipelines referencing defined components, unused components) and, with a component list, that every
component type exists. Deprecated component names are accepted as aliases (warning deprecated_component), and aliases
resolve in both directions because the reported component sets contain old and new names while the CLI list holds only
the new ones. The exporter otlp is an alias, the receiver otlp is not. There is no binary validation of rendered
configurations on the server: a collector’s validate fails on receivers that exist only on another operating system.
The supervisor’s validate_config option validates on the agent instead.
Issue codes are stable. The classes are: syntax and schema (yaml_syntax, unknown_field, invalid_name,
duplicate_object, invalid_field, not_regular_file), references (blueprint_not_found, policy_not_found,
executor_not_found), blueprint structure (extends_cycle, parameter and template codes), policies (wave_invalid,
window_invalid, policy_invalid), executors (executor_invalid), selection (selection_invalid), and configuration
checks (config_invalid_toplevel, invalid_component_id, invalid_pipeline, undefined_component,
unknown_component, deprecated_component, unused_component, env_syntax). myrctl validate exits 0 when valid
(warnings allowed), 1 on validation errors and 2 on usage or internal errors; --strict turns warnings into failures
and --output json prints the issues.
Fleet assignment
Fleets and agents are matched by selectors over the attribute view.
- Selectors combine
matchAttributes(exact equality) andmatchExpressions(In,NotIn,Exists,DoesNotExist,Glob) with AND. An empty selector matches everything. - Inventory labels apply first. Among the matching fleets the highest priority wins. Equal highest priorities leave the
agent unassigned with the error
ambiguous: fleets a, b have equal priority N. No candidate means unassigned without an error. Only collectors are assigned; an updater follows its collector. - Each replica keeps the active snapshot in memory and reloads it on GitOps and bus-reconnect events and at least every
five minutes.
AssignAgentis called synchronously during registration and must be fast. - For one agent: compute effective labels (token labels plus inventory labels, inventory wins) and persist them when they changed; match; render with the agent’s component set when known; on a render error keep the previous assigned hash and record the error; store the rendering with its provenance; record the assignment (fleet, assigned hash, target version, error, time).
- Bootstrap versus rollout. Each agent has three hashes: assigned (what the current GitOps state renders for it), desired (what is offered) and failed. A new agent, or one that changes fleet, gets desired set to assigned at once. Otherwise desired changes only through rollouts. A rollout that finished with an offline agent skipped leaves it behind; if the agent’s assigned hash equals the skipped target’s hash, the change is treated as approved and desired is set directly (catch-up), without a new rollout.
- Reassigning everyone runs on the leader with a small worker pool, then publishes desired-state changes and an assignment event in chunks.
- Several replicas.
AssignAgentruns on whichever replica holds the connection, andReassignAllon the leader, so two replicas can evaluate the same agent at the same time. The stripe locks and the record of recent writes (written) hold only inside one process and are an optimisation, not the guard. The guard is the database: the assignment is written as a compare-and-set (AgentStore.CompareAndSetAssignment) against the assignment that the caller read (fleet, assigned hash, target version and error; no extra column and no migration, the previous assignment is the version). The fleet-change event and the reset of the desired state (which also clearsfailed_config_hash) follow only a write that took effect. A replica that loses the race records nothing, re-reads the agent and evaluates again: it then finds the assignment settled, or a newer one that it judges itself. After three attempts in all it gives up and reports an error. A change therefore takes effect once, however many replicas see it. - Stale listings.
ReassignAllreads the agents in one listing; an agent that this process wrote after the listing is read again before it is judged. A listing older than five minutes counts as stale for every agent, because the record of writes is pruned after that age. The record is pruned at most once a minute, and only when it is large, never as a scan of the whole map under the lock on every write.AssignAgentreads the snapshot after it has the agent’s lock, so a call that waited works on the snapshot that is current when it runs.
Rollout planner and engine
Policy
A RolloutPolicy defines waves, maxConcurrent, healthWait, applyTimeout, offlineGracePeriod, a failure threshold,
onFailure (pause or rollback), a timezone attribute and default timezone, and maintenance windows. Defaults, which
are copied into the policy snapshot of each rollout:
| Setting | Default |
|---|---|
| waves | one wave all at 100 percent |
maxConcurrent | 0 (unlimited) in a policy, 50 in the built-in policy |
healthWait | 60 seconds |
applyTimeout | 10 minutes for configuration, 30 minutes for versions |
offlineGracePeriod | 15 minutes |
| failure threshold | maxFailed: 1 when both numbers are 0; setting only a percentage gives a pure percentage threshold |
onFailure | pause |
| timezone attribute | site.timezone, then the policy default, then rollouts.defaultTimezone |
A fleet without a reference uses the policy default; when no object has that name the built-in policy applies.
Waves
A wave has either a selector or a percentage from 1 to 100, never both (wave_invalid). Percentages must strictly
increase. Targets are assigned when the rollout is created: a selector wave takes all unassigned targets that match; a
percentage wave takes unassigned targets in a stable pseudo-random order (ascending sha256(rolloutID || agentID))
until the cumulative share ceil(percent * total / 100) is reached. If the last wave is below 100 percent an implicit
rest wave is added. Empty waves are skipped. Waves run in sequence: the next one starts when all targets of the
current one are final and the failure threshold was not reached. A wave with requireApproval waits in
waiting_approval until approveRolloutWave. The percent of a wave is a cumulative share of all targets of the
rollout, not progress; the UI labels it as such (“up to N % of all targets”).
Targets
A target is one agent in a rollout. Statuses: pending, waiting_window, in_progress, verifying, succeeded,
failed, skipped, cancelled, rolled_back.
- Start (pending to in_progress) requires: the agent is online, its local maintenance window is open and
maxConcurrentis not exhausted. A configuration rollout sets the desired hash (clearing a failed marker for a retry first) and pushes. A version rollout calls the executor once per tick and wave with all startable targets, after an environment-dependentValidateonce per rollout and tick. Errors fail the affected targets with the message. - Report (in_progress to verifying): for configuration,
APPLIEDfor the target hash; for version, the collector reports the target version. - Success (verifying to succeeded): the agent stays online and healthy for
healthWait. Unhealthy reports that were sent before enteringverifying(the old process stopping) do not count, and the target waits up to two minutes for the new process. The normal start-up sequence of the new process (unhealthy withStatusStarting,StatusStoppingor no status and no error) does not count either, for at mostapplyTimeout. Any other unhealthy report fails the target. Crash loop (version rollouts): if the collector was healthy on the new version once and then falls back to a start-up report more than once, the target fails. A single restart after the update, for example due to a configuration that was applied meanwhile, is allowed. - The health wait ends at
verifiedAt + healthWaitof the policy snapshot (60 seconds when the policy sets none, see Policy). The engine evaluates it on each tick, and only for a rollout that isrunning,pausedorwaiting_approval: theverifyingtargets of afailedrollout stay as they are until an operator decides. The API has no field for the end. A client computes it fromverifiedAtof the target (GET /rollouts/{id}/targets) andpolicy.healthWaitof the rollout (GET /rollouts/{id}), and the web UI shows the remaining time that way, for a target and for the latest end among the targets of a wave, with the name,healthWait, path and commit of the policy snapshot as the source. - Failure:
FAILEDfor the target configuration hash,INSTALL_FAILEDof the updater for the offered version (only reports after the start of the current attempt count), unhealthy during the wait, offline during the wait,applyTimeoutexceeded, or an executor error. - Offline targets wait; after
offlineGracePeriodfrom the start of the wave they areskipped. Targets outside their window arewaiting_window. When a rollout is aborted, targets that never started becomecancelled. - At most one configuration rollout and one version rollout run per fleet at the same time. If a version update restarts a host while its configuration target is in the wait, that configuration target fails (retry it, or finish the version rollout first).
Maintenance windows
A target may start only inside at least one window; running targets are never interrupted. No windows means always. The
zone comes from the window’s timezone, or agent (the agent’s timezone attribute), falling back to the policy and
then the server default, using the IANA database including daylight saving. days names the weekday on which the window
begins. If end <= start the window crosses midnight; start == end means 24 hours from the start.
Failure threshold, pause and rollback
The threshold is reached per wave when maxFailed > 0 and failed >= maxFailed, or maxFailedPercent > 0 and failed * 100 >= maxFailedPercent * targets of the wave. The wave becomes failed.
pause: the rollout ispausedwith a reason and a notification. Running targets finish their evaluation, no new ones start. The operator can resume (which acknowledges the failures so far), retry failed targets, abort or roll back. Retrying failed targets increases the attempt counter, so a repeated failure of the same agent counts again.rollback: all started targets return to their original state (configuration: desired is set back to the previous hash; version: the previous version is offered or the executor is called with it) and becomerolled_back; the rollout goesrolling_backthenrolled_back.- The rollout status
failedarises only if a rollback cannot finish withinapplyTimeout, or the rollout cannot proceed at all (for example every startable target of a wave fails in the executor). - A rollback contradicts Git: the agent shows drift until Git is reset or a new rollout starts. Targets cancelled by a rollback are not rolled out again automatically.
Planner
The planner runs on the leader after each applied GitOps state and every minute. Runs are serialized. There is at most one active rollout per fleet and kind.
Relevant agents are assigned collectors without an assignment error: for configuration those with a desired hash different
from the assigned one; for versions those whose version differs from the effective target, provided the executor is not
none and a target exists (not in hold). Exclusions, evaluated from the latest target of the agent for this target value:
- The latest target is
failedorrolled_back: only a new commit, a new version or an operator’s retry starts it again. - The latest target is
cancelledin a rollout that isaborted,rolling_backorrolled_back: the operator or the failure policy stopped it on purpose. cancelledthroughsupersededdoes not exclude.- A
skippedtarget in a completed configuration rollout leads to catch-up. For versions there is no catch-up: such agents are relevant again, so that windows andmaxConcurrentapply, except an agent whose target was skipped because it was offline, which becomes relevant only after it has connected since.
Per fleet and kind: first the version selections are reconciled with Git (see version-selection.md).
If an active rollout has open targets (pending, waiting_window, in_progress, verifying) with a different target than
their agent’s current one, it becomes superseded (open targets cancelled, the executor is told to cancel), and a new
rollout may be created. If all open targets still match, relevant agents without a target (“latecomers”) are added to the
last wave. With no active rollout a new one is created for all relevant, non-excluded agents; none relevant means none
created. A rollout in failed or rolling_back is never replaced automatically. A paused rollout can be superseded by
a fix. Creating a rollout writes the audit entry rollout.create and the notification rollout.started.
After a fleet was planned without an error, the planner records the effective target version of the fleet in the key-value store
(rollout/planned-target/<fleet>; the value is the version without v, and empty for a fleet without a target), when the record changes
and after it created the rollout for that target; for a version chosen in the UI it also sets plannedAt of the selection. Both say
that the planner has handled the target. A target reaches every replica (the event of a Git sync or of a selection) before the leader
has created the rollout for it, and the installation of hosts from the server reads these marks, so that a new host does not take the
new version past the waves (Installation from the server). A leader writes the records of all fleets
again when it starts a term, because another leader may have written them in between. They are not purged.
Engine
The engine is stateless between ticks, so leadership can change at any moment and every step is idempotent. A tick runs
every rollouts.tickInterval (5 seconds) and earlier after agent or rollout events, throttled to once per second. Each
tick for an active rollout: derive target transitions from agent state, check the failure threshold, advance waves
(notification on approval wait and completion), start startable targets, and write targets, progress and status through
the guarded store calls. Operator actions (pause, resume, abort, approve, rollback, retry-failed) run on any
replica, change state only by compare-and-set and are audited; an invalid transition is a precondition error.
Executors
The executor interface has Validate (environment dependent), Start (per wave, with all startable targets) and Cancel.
updater: for each target, find the linked updater (error if none or offline), ensure the artifact for the host’s operating system and architecture, store a package offer with the target version, and notify that the updater’s desired state changed.Cancelclears the offers. See updater-protocol.md.manual: see version-selection.md.webhook: a JSON payload (event: collector.update,deliveryId,rolloutId,wave,fleet,distribution,toVersion,params, andtargets[]with agent ID, host name, OS, architecture, current version and attributes), sent in batches. HeadersX-Myriarch-Delivery,X-Myriarch-TimestampandX-Myriarch-Signature: sha256=<hex>where the MAC is HMAC-SHA256 overtimestamp + "." + body. A 2xx answer means accepted. Delivery is at least once, with a deterministic delivery ID for deduplication. Cancel sendscollector.update.cancel.awx:POST <url>/api/v2/job_templates/<id>/launch/with a bearer token and{"limit": "<hosts>", "extra_vars": {..., "myriarch": <payload>}}. The limit comes from an agent attribute (defaulthost.name) and every value must be a plain host name, otherwise only that target fails. The job ID is stored asawx-job:<id>; cancel isPOST .../jobs/<id>/cancel/on a best-effort basis.- Secrets. A repository names an environment variable or a file; the myriarchd configuration decides what is readable: names
must start with a configured prefix, files are relative to a secret directory without
.., and in production every secret is bound to the hosts it may be sent to, and target URLs must be in an allowlist. HMAC secrets have at least 32 bytes. Static checks happen in validation, environment checks inValidatewhen a rollout starts. - The end of a version update is always recognized from the version the collector reports.
Artifact cache
- Source layout:
<sourceBaseUrl>/<tag>/<asset>with<asset>.sha256and<asset>.sigstore.jsonbeside it. The default source is the public release page of the upstream collector releases; an internal mirror must keep the layout. Ensurecreates a database entry, loads the checksum file (64 hex characters), loads the Sigstore bundle, verifies the bundle against the digest (identity, issuer and the requirement that the tag equals the artifact version, using the trusted root file or TUF), then streams the archive into a temporary file, compares its SHA-256 with the checksum file, moves it atomically into the cache and marks it ready. Concurrent calls for the same artifact run once. Downloads per replica are limited to three at a time, with an idle timeout, an overall size cap and allowed redirect hosts.- Releases are mutable upstream. Once an artifact is ready, its pinned SHA-256 wins; a different value later is logged as an error.
- Download endpoint (
downloadArtifact): verifies the token (signature, artifact ID, and that the agent currently has an offer referencing this artifact; age only when a TTL is set). A replica that lacks the file fetches it from the source and checks it against the stored SHA-256. It serves ranges withContent-Type: application/gzip. - Install grants (from v0.2.0). The endpoint also accepts the second kind of
myrd_token, the install grant of a host that is not enrolled yet. The signed payload holds the artifact ID, the ID of the enrollment token that the host presented to get its plan, the time and the kindi(the token of an offer has no kind, and the kind is part of what is signed, so neither can be turned into the other; an unknown kind or a grant without an enrollment token is refused). A grant is accepted while it is younger thaninstall.grantTtland only while its enrollment token exists and is neither revoked nor expired; the token is read from the database on every request, so a revocation applies at once on every replica, and a token that is gone, revoked or expired looks the same. It is accepted only whileinstall.enabledis on: a server that runs with the installation turned off (also after a restart on the same database) refuses every grant with 401, whatever its lifetime is, and mints none. It needs no package offer. Besides the artifact it gets<filename>.sha256(<hex> <filename>, likesha256sum) and<filename>.sigstore.jsonfrom the record, the files that the installer checks the artifact with; the token of an offer gets neither. A grant is issued, and a file is served, only for an artifact that isready, has a pinned SHA-256 and, where signatures are required, a verified bundle. The log names the token of a grant by its ID, never the grant. - Preparation without waiting (
Prepare). The install plan needs an artifact now and cannot wait for a download of 100 MB.Preparereturns a ready artifact, and otherwise startsEnsurein the background (once per replica however many callers ask) and reports that it is pending. A record in the statusdownloadingthat changed within ten minutes belongs to another replica or to the prefetch of the leader and is not started again; an older one belongs to a replica that is gone and is taken over. A failed record is tried again after a minute, and until thenPreparereports the failure, so a host that asks every few seconds cannot make the server request an upstream release in a loop.Ensurealso fetches a record that claims to be ready but has no pinned hash or no verified bundle again, instead of returning it. - The installer bundles of the release that the server runs are not artifacts. They have no row in the table
artifacts(the API enumDistributionknowsotelcol-contribandopampsupervisoronly), are kept byinternal/installin the subdirectoryinstallof the cache directory, and the eviction below neither counts nor removes them (Installer bundles). No migration is needed. - Prefetch on the leader every 10 minutes: for each fleet with executor
updaterand a target, fetch the platforms of the members plusprefetchPlatforms, then evict least recently used artifacts until the size cap is met. Target versions of fleets are never evicted.
Release tracker
- Polls the releases list of the upstream collector releases repository (
per_page=30, always the same parameters, so the ETag matches) with a conditional request; a 304 changes nothing. A token raises the rate limit; even 304 answers count against the unauthenticated limit of 60 per hour, so a token is recommended, and rate-limit answers are shown in the status. A GitHub Enterprise API base URL or a fixture directory can replace the source. - Tags
v0.X.Yare distribution releases,cmd/opampsupervisor/v0.X.Yare supervisor releases; everything else (nightlies, builder tags) is ignored, as are drafts. Prereleases followincludePrereleases. Versions belowminVersionare ignored on the first import. Assets are parsed into operating system, architecture and format. - Breaking changes and deprecations come from the upstream changelogs at the release tag: a version heading is a
token with major version 0 and no prerelease; sections whose heading contains “Breaking changes” or “Deprecations”
are read with a bullet pattern that has a component, a text and optional issue numbers, with continuation lines.
Combined headings such as
## v1.1.0/v0.162.0occur. If the file is missing at that tag, the changelog of the newest tag is used because it contains all older sections. Supervisor releases use the entries of the componentcmd/opampsupervisorfrom the contrib changelog. - A new version is stored, announced on the bus and notified as
release.new, orrelease.breakingwhen it has breaking changes (dedup key per distribution and version). Comparing two versions reads the stored releases in the interval.
Notifier
A dispatcher routes notifications by event name (a trailing * is a wildcard) and minimum severity. Duplicates are
suppressed with a dedup key in the key-value store. Each channel is sent in parallel with a 10 second timeout and three
attempts. Events: release.new, release.breaking, rollout.started, rollout.waiting_approval, rollout.paused,
rollout.failed, rollout.completed, rollout.rolled_back, gitops.sync_failed and test.
webhook: the JSON notification with an absolute link, headersX-Myriarch-Delivery,X-Myriarch-Timestampand, when a secret file is set,X-Myriarch-Signaturewith the same MAC scheme as the webhook executor.teamsandslack: an adaptive card or blocks with title, summary, facts and a link button.- A notification’s link is a UI path that the dispatcher prefixes with the public URL. Channel listings redact URLs to scheme and host.
Local database for development and tests
myriarchd dev and the test helper start an embedded PostgreSQL. Decisions that keep it reliable: a free port with retry, a
private data directory per process, a shared binary directory protected by a file lock, locale C and UTC, TCP only, a
watchdog for orphaned servers, and a template database per test run. It must never run as root. Tests may use an
external database through a DSN variable; their queries must then be limited to their own database.
Collector installation on hosts
These decisions concern how a collector and its supervisor are installed and configured on hosts. The behaviour of the update itself is in updater-protocol.md.
Supervisor configuration
The shipped templates for Linux, Windows and Kubernetes use placeholders written as @@NAME@@, which the install
scripts replace (no Go templating, so ${env:...} stays untouched). Required content:
server.endpointandserver.headers.Authorization: "Bearer ${env:MYRIARCH_ENROLLMENT_TOKEN}": the token reaches the supervisor through the service environment, never through the file. An optionalserver.tls.ca_file.- Capabilities:
accepts_remote_config,accepts_restart_command,reports_effective_config,reports_health,reports_available_componentsandreports_heartbeaton.accepts_opamp_connection_settingsstays off on purpose (see above). The defaults ofaccepts_remote_configandreports_available_componentsarefalseupstream, so they must be set. - Agent:
executablepoints at the stable path of the active collector (current/<binary>on Linux,bin/<binary>on Windows);config_apply_timeout30 seconds,bootstrap_timeout15 seconds,validate_config: true,automatic_config_rollback: true, andstartup_fallback_configswith a minimal valid configuration for the case that the server is unreachable at start. - Site attributes under
agent.description.non_identifying_attributes:site.type,site.id,site.name,site.country,site.region,site.timezoneanddeployment.environment.name(all strings, leading zeros kept). storage.directorymust not be pre-filled in golden images:persistent_state.yamlholds the instance UID, and cloned state produces duplicate UIDs.- A local health endpoint (
healthcheck.endpoint) for the supervisor. - Kubernetes variant:
passthrough_logs: trueso collector logs reach stdout; pod and node attributes through the downward API with${env:...}; storage on a persistent volume (workloads) or a host path (per-node agents).
Installation scripts and services
- Linux and Windows scripts are idempotent. They download the collector archive and its
.sha256, verify the checksum and the Sigstore bundle, unpack intoversions/<v>, set thecurrentlink (or copy on Windows), install the supervisor binary after the same verification, render the supervisor and fallback configuration, write the token to a file readable only by the service account (0600) or to the service’s environment in the registry with a restricted ACL, and enable and start the service. The updater binary is installed only when its hash comes from a trusted source, because it runs as root or SYSTEM. - Operating system packages and installers of the collector are deliberately not used: the contrib deb and rpm start their own service immediately and the MSI installs a second service, which would run a second collector next to the supervisor.
- The service runs as a dedicated unprivileged user with restart on failure, a raised file-descriptor limit and
hardening options. Windows assets are
.tar.gztoo and unpack with the built-intar. - The supervisor reads
agent.executableat start and detects version and components during bootstrap. After a binary change the supervisor service is restarted.
Installation from the server
Available from v0.2.0. ADR-0046 decides it: the server provides data
(versions, artifacts, the endpoint) and the host verifies code (SHA-256 and Sigstore, against an identity that the
installer builds itself, never one that the server names). Hosts download from myriarchd only. The settings are
install.enabled (default true; off, every route of the tag install answers 404 before it looks at a token),
install.grantTtl, install.bundleSourceBaseUrl and install.platforms (linux/amd64, linux/arm64 and
windows/amd64); see the configuration reference. Four routes serve it: the plan
(getInstallPlan) and the download of an installer bundle (downloadInstallBundle) with an enrollment token, the list of
the bundles (listInstallBundles) and the rendering of the commands (renderInstallCommands) with a session or an API
token. A person copies the commands to a host; they download the bundle, and the installer in it asks for the plan.
Authentication. A request needs a valid enrollment token, always: Authorization: Bearer myre_..., also where
opamp.requireEnrollmentToken is off. The token authenticates the request and binds the grants; it is never part of a
body, a URL, an answer, a log line, a metric label or an audit entry (the log names it by its ID). Unknown, revoked, expired
and missing tokens get the same 401. The rules are those of the OpAMP endpoint (see Authentication and
authorization); in addition 30 failed authentications per client address are free,
then one more is allowed per second, and an address without budget gets 429 before any lookup happens.
The plan. POST /api/v1/install/plan (getInstallPlan; a body of at most 16 KiB that holds no secret). The request
names the platform (os linux or windows, arch amd64 or arm64; x86_64 and aarch64 are accepted), the
attributes the host would report, an optional fleet and an optional collectorVersion. The attributes may be
host.name, site.type, site.id, site.name, site.country, site.region, site.timezone and
deployment.environment.name, with values of 1 to 253 characters without control characters or surrounding spaces;
os.type and host.arch come from the platform and the labels from the token. The server checks all of this against
allowlists first (422, one issue per problem) and decides, in this order:
- The fleet.
assignment.Service.Match, the function that assigns a connected agent, with the attributes, the platform and thelabels.*of the token. No fleet: 409no_fleet. Several fleets of the same highest priority: 409fleet_ambiguous. Afleetin the request that is another than the one found: 409fleet_mismatch; nothing is delivered. A fleet that selects on other attributes than the ones above cannot be found. A distribution other thanotelcol-contrib: 412unsupported_distribution. - The version (below).
- The releases. The release tracker must know the release of the collector and the release of the supervisor for the
version (the supervisor has the version of the collector). Otherwise 412
unknown_versionorunknown_supervisor_version; for a version that the host asked for, 422 with the same codes. - The artifacts.
Preparestarts both downloads at once. Until both are ready the answer is 503 with the reasonpreparingandRetry-After(10 seconds; 30 after a failed attempt). The first request for a platform downloads about 112 MB, so this can last minutes. Nothing is started for a request that failed one of the steps before.
The version. The plan gives the effective target of the fleet (version.source is git or ui; see
Version selection). A fleet on hold or without a target: 412 no_target_version. While a version
rollout of the fleet is pending, running or waiting_approval, it gives the fromRef of that rollout instead
(version.source is rollout), so that the new host becomes a straggler of the last wave and waves and approvals apply to
new hosts as well; a rollout that is paused, failed or rolling_back, or whose hosts start from different versions
(no uniform fromRef), is 409 rollout_blocking. A collectorVersion of the request is accepted if it equals the version
the plan would give, the target of the fleet or a version inside the frame spec.update.selection in Git (version.source
is then request); otherwise 422 version_not_allowed.
A target that the planner has not handled. A target reaches every replica (through the event of a Git sync or of a selection in the
UI) before the leader has created the rollout that brings the fleet to it. A host that asked in between would find the new target and no
rollout, take the new version at once and skip the waves and approvals. So the plan does not hand out a target before the rollout planner
has handled it (Planner): for a version chosen in the UI until the selection has plannedAt (a new selection of the fleet,
also of the same version, clears it), for any other target (the one in Git, also after a selection was cleared) until the record of the
planner for the fleet equals the target. Until then the answer is 503 preparing with Retry-After (5 seconds), and nothing is prepared.
The planner usually needs seconds. A fleet that the planner has never handled has no record, so a new fleet waits for its first run, and so
does every fleet right after an update from v0.1.0 (the records stay in the database, so the restart of a leader does not make hosts wait
again). Without a leader nothing is planned, and the hosts of a fleet whose target changed wait until there is one; the other fleets are not
affected. If the planning of a fleet keeps failing, its hosts wait and the log of the leader names the reason.
The reads are ordered so that a concurrent change cannot send a host past the waves: the target comes from the memory of the assignment
service, which follows the database and never runs ahead of it, then the record of the planner, and the rollouts last. The planner writes
its record after it created the rollout, so whoever sees the record that belongs to the target sees the rollout as well; a rollout that
the leader creates after the target was read is seen with the fromRef that belongs to the previous target, or the target is not handed out
yet. The combination “new target, no rollout yet” does not reach a host.
The answer. A plan of format 1 with the OpAMP endpoint (wss:// for an https public URL, plus opamp.path), the fleet,
the source of the version, the version, the URL of the artifact on the origin of server.publicUrl and an install grant for
each of collector and supervisor, and updater.install (true when the executor of the fleet is updater). It is
text/plain by default, with Vary: Accept, and JSON with Accept: application/json. The text is one key=value line per
key, in this order: format, fleet, version.source, opamp.endpoint, collector.version, collector.url,
collector.grant, supervisor.version, supervisor.url, supervisor.grant, updater.install. A value consists of the
characters A-Z a-z 0-9 . _ : / @ + = - and nothing else; the three URL values may also contain the brackets of an IPv6 literal in
the authority (wss://[2001:db8::1]:8443/v1/opamp); there are at most 64 lines and 8 KiB; lines end in \n; a reader accepts
only these keys, each once, with format first, and stops at a format that it does not know. The server writes nothing that
breaks the rules (a server.publicUrl with another character is a 412 server_settings), and install.ParseText is the
reference reader with the rules that the installers implement. The plan is data: the installer takes the values as they are,
checks each against a pattern and runs nothing that it contains. The URLs of artifacts.agentBaseUrls do not apply to a host
that is not enrolled.
| Status | code | Reason (issues[0].code) |
|---|---|---|
| 400 | bad_request | invalid JSON, an unknown field, a body above 16 KiB (415 for another content type) |
| 401 | unauthorized | none: the same answer for every token problem |
| 404 | not_found | install.enabled is off |
| 409 | conflict | fleet_mismatch, no_fleet, fleet_ambiguous, rollout_blocking |
| 412 | precondition_failed | no_target_version, unsupported_distribution, unknown_version, unknown_supervisor_version, server_settings |
| 422 | validation_failed | invalid_value, unknown_attribute, platform_not_supported, version_not_allowed, unknown_version, unknown_supervisor_version |
| 429 | too_many_requests | none; Retry-After |
| 503 | unavailable | preparing (with Retry-After: the artifacts are being prepared, or the planner has not handled the new target of the fleet), or none for a database failure |
Observability. myriarch_install_plans_total{result} counts the requests (ok, disabled, unauthorized,
rate_limited, bad_request, invalid_request, unavailable, error, or the reason of a refusal such as fleet_mismatch
or preparing). A plan that is issued is logged at info level with the ID of the token, the fleet, the platform and the
version; it is not an audit entry, because it is a call of a host with its token and changes no configuration (the
creation of the token is audited).
Installer bundles
The installer scripts, the templates that they render and the myriarch-updater binary reach a host as one archive: the
updater archive of the release, myriarch-updater_<version>_<os>_<arch>.tar.gz for Linux and .zip for Windows, the file that
a person would download from the release by hand. The server serves the archive of the release that it runs itself and no
other. It is the bytes of the release and nothing else: the server does not template, repack or re-encode them, so the hash
rules of App Control (which match byte-identical files) and later Authenticode signatures stay valid.
- Which server has bundles. One that runs a release of the project: the version is
MAJOR.MINOR.PATCHorMAJOR.MINOR.PATCH-rc.N, from 0.2.0 on. A build from a Git checkout (git describe), a version with build metadata or any other text is a local build and has none: the list sayssourcenonewith the reasonlocal_build, the server logs it once at the start, and rendering commands is 412local_build. A development server can serve a directory instead (myriarchd dev --install-bundle-dir,sourcedirectory): the archives in it are named like the ones of a release, the highest version in the directory wins, only regular files count (links are not followed), and the directory is read again each time commands are rendered. Nothing in it is signed, because the developer put it there; the server copies each archive into its store and delivers it like the others. - Where it comes from, and what is checked first. The base is
install.bundleSourceBaseUrlwith the layout<base>/v<version>/<asset>, which a mirror keeps. The server loadschecksums.txtandchecksums.txt.sigstore.jsonand verifies the keyless signature ofchecksums.txtwith the trusted root ofartifacts.verify(a file, or TUF). The identity is^<brand.Repository>/\.github/workflows/release\.yml@refs/tags/v<version>$with the issuerhttps://token.actions.githubusercontent.comand the tag equal to the version: the release workflow of this project at the tag of the version this server runs, derived from the code and not from a setting, whatever source delivered the files. Thenchecksums.txthas to have exactly one line for the archive (two lines are as bad as none, also with equal hashes), and the archive has to have the SHA-256 of that line. Only then it is stored. The signature is checked before any archive is requested, and a failure of any step leaves the bundlefailedand delivers nothing. - How it is fetched. Like every outbound connection of the server: the destination policy for every resolved address
(
outbound.allowedCidrs), the proxy ofartifacts.proxyUrl, redirects to https and toartifacts.allowedRedirectHosts(the GitHub asset hosts by default) and the host of the source only, 1 MiB for each of the two small files and 256 MiB for an archive, 60 seconds without data, one download at a time per replica. The source gets no credential: it is the public release, or a mirror that can be read without one. - Where it is kept. Under its SHA-256 as
<artifacts.dir>/install/sha256/<hex>(mode 0640), in the subdirectory that the eviction of the artifact cache leaves alone. The state is in memory: every replica keeps its own bundles, and the list shows the replica that answers. A restart finds the files, hashes them again and fetches only the two small documents again. Archives of an earlier version are removed after a day. - When. Shortly after the start (15 seconds, as the artifact prefetch does), on every replica, and whenever commands are rendered or a bundle is asked for and a bundle is pending or failed a minute ago. A failed attempt is not repeated within a minute, so hosts that ask every few seconds cannot make the server go to the source in a loop. A server that is stopped stops the preparation and waits for it.
status | Meaning |
|---|---|
pending | Not fetched yet |
preparing | The server is fetching and checking it |
ready | Stored, with a pinned SHA-256 (sha256, size); delivered |
failed | The last attempt failed (error says why); nothing is delivered; the server tries again after a minute |
Delivery. GET /api/v1/install/bundles/{filename} needs an enrollment token, with the rules of the plan: the same 401 for every
problem of a token, 429 for too many failed authentications or lookups, and the token in the header only (a request that carries it in the
URL has none). A name that is not a bundle of this server (another version, another platform, a checksum or a path) is 404; the answer repeats no
part of the path. Before every delivery the server opens the stored file without following a link and hashes it again against the pinned
SHA-256, and it sends the bytes of that open file (Range is supported), so a path that is replaced meanwhile changes nothing. An archive that
is gone or differs is discarded, the bundle becomes pending and is fetched and checked again, and the request gets 503 with Retry-After. The
answer has Content-Type application/gzip or application/zip, Content-Disposition: attachment, Cache-Control: private, no-store and
ETag: "sha256-<hex>". A bundle that is not ready is 503 with Retry-After and the reason bundle_preparing (5 seconds) or
bundle_failed (30 seconds).
Observability. myriarch_install_bundle_downloads_total{result} counts the requests (ok, disabled, unauthorized, rate_limited,
not_found, preparing, failed, unavailable, error). A delivery is logged at info level with the ID of the token, the file and its
hash, not as an audit entry; the token is in no log line, label or answer.
Install commands
POST /api/v1/install/commands (renderInstallCommands, permission read, the CSRF header for a cookie session; a body of at most 16 KiB)
renders the command for each platform that is asked for. The server renders it and never sees a token. The person who copies it (the dialog
of the web UI) puts the token in.
| Request field | Meaning |
|---|---|
variant | token_inline (the default): the token is part of the command, at the place tokenPlaceholder (@@MYRIARCH_ENROLLMENT_TOKEN@@), which the UI replaces in the browser, in every command. token_file: the command reads the token from a file on the host and has no place for a token. lab: token_inline over plain http with --insecure (-Insecure) for the installer; only a development server renders it (422 variant_not_available otherwise) |
platforms | os/arch entries of install.platforms; none means all of them |
fleet | The fleet that the person expects. The installer passes it to the plan, which refuses a host that the server assigns elsewhere |
attributes | site.type and site.id (required: the installers stop with option_missing without them), and optional site.name, site.country, site.region, site.timezone, deployment.environment.name. The installers get them as their options |
collectorVersion | A version that the host asks for; the plan accepts it inside the Git frame only |
tokenFiles | linux and windows: the path of the token file on the host, required for each operating system that is rendered under token_file |
caFile | The path of the PEM file with the CA of the server on a Linux host (--cacert for the download, --ca-file for the installer); a Windows host uses the certificate store of the machine |
The answer has the variant, the server that the commands use (server.publicUrl), the version of the bundles, insecure, the
tokenPlaceholder and for each platform the shell (bash or powershell), the command, the bundle that it pins (name, sha256, size)
and, under token_file, the tokenFile. A command that carries a token needs server.publicUrl to be https (412 server_settings). A server with
opamp.requireClientCert renders no command at all (412 server_settings, checked before the bundles): the plan and the download of a bundle ask for a verified client
certificate there, as the OpAMP endpoint does, and neither curl nor Invoke-WebRequest in the command presents one, so every copy would fail at the download. Hosts of
such a server are installed on the manual path (Connect a host). A bundle that is not
ready is 503 bundle_preparing or bundle_failed and the request starts the preparation; the commands of all platforms that are asked for
come together or not at all.
Inputs. The text of each command is a constant. Every value (the server, the name and SHA-256 of the bundle, the options of the installer) is an
argument of its own, quoted with single quotes, so that no value is ever pasted into code; a value is checked first, and a refusal is 422 with the issue at
its path (invalid_value, unknown_attribute, platform_not_supported, variant_not_available) and never repeats the value.
| Value | Accepted |
|---|---|
site.type, deployment.environment.name | A letter or a digit, then letters, digits and . _ -, at most 63 characters |
site.id | The same and : |
site.country | The same, at most 16 characters |
site.name, site.region | A letter or a digit, then letters, digits, spaces (not at the end) and . , : / @ + ( ) # % = ~ -, at most 100 characters |
site.timezone | A letter, then letters, digits and . _ + / -, at most 64 characters |
fleet, collectorVersion | The rules of the plan: the name of a fleet, a version such as 0.162.0 |
Paths (tokenFiles, caFile) | An absolute path in the form of the host (/etc/..., C:\...), at most 240 characters, without . or .. segments, without a space or a separator at the end |
Refused everywhere: quotation marks of every kind (', " and the typographic ones of U+2018 to U+201E and U+00AB, U+00BB, which PowerShell reads as quotes), the backtick,
$, ;, control characters, everything outside printable ASCII, @@, a value that looks like a token (myre_, myra_, myrd_), a leading dash (it could pass
for an option) and a value above its length. A second check refuses to quote a value that has a quote, so the quoting needs no escape. The check looks at the value as it came, before any normalization: collectorVersion and each platform are refused (422) with white space or a control
character anywhere, also at the edges. A platform may be written in upper case or with the alias of an architecture (LINUX/x86_64), and a version with a leading v; the plan
request (getInstallPlan) trims and folds its values the same way, because it renders nothing: the planner only compares them with what the server knows.
What the Linux command does. sudo sh -c '<script>' sh <values> <<'MYRIARCH_TOKEN_END' followed by the placeholder, for a root shell (bash or
another POSIX shell; the here document does not exist in fish). The script, in this order:
- stops unless it is root, and unless
curl,tar,gzip,sha256sumandmktempexist (privilege_required,tool_missing); - makes a private directory with
mktemp -dbelow/root(directory_unsafe) and removes it, with everything in it, when the command ends, also after an error or an interrupt; - reads the token with the builtin
readfrom the standard input (varianttoken_inline) or from the file (varianttoken_file), and stops unless it consists ofA-Z a-z 0-9 _ . ~ + / = -(option_missingfor no token or an unreplaced placeholder,option_invalid,file_unreadable); undertoken_inlinethe builtinprintfwrites it to a file of the private directory (mode 0600); - downloads the bundle with
curl -fsSg --retry 3 --proto =https --tlsv1.2 -K -, to whichprintfgives the headerAuthorization: Bearer <token>on the standard input (--proto =http,httpsfor the lab variant): the token is in no argument vector and no environment variable, the URL is built from the server and the name of the bundle only, and redirects are not followed (download_failed); - checks the SHA-256 that the command pins with
sha256sum -cbefore it unpacks anything (checksum_mismatch), unpacks the archive in the private directory (archive_invalid) and startsbash deploy/scripts/install-linux.sh --server <server> --token-file <file> [--ca-file ...] <options>from there, with no standard input; the exit status of the installer is the status of the command.
What the Windows command does. A script block & { param([hashtable]$P) ... } @{ ... } for an elevated Windows PowerShell or a session of SYSTEM, which is
the constant script followed by the values in a hashtable. It uses cmdlets and native programs only (whoami.exe, icacls.exe, powershell.exe) and no
.NET method or type, because under App Control the typed command runs in Constrained Language Mode. It, in this order:
- stops unless the session is elevated or runs as SYSTEM (
privilege_required, from the mandatory integrity level thatwhoami.exe /groupslists: High,S-1-16-12288, is an elevated administrator; System,S-1-16-16384, is a session of SYSTEM, the context of scheduled tasks, software distribution and management agents, which run the varianttoken_file; an administrator who is not elevated has Medium and is refused, although the group Administrators is in the token “for deny only”) and unless Group Policy leaves room for a script: a policy of the scopesMachinePolicyorUserPolicythat isAllSignedorRestrictedwins over-ExecutionPolicyand would stop the unsigned installer later, so the command says so first (execution_policy_blocked); then it checks the token as above; - makes the working directory directly below
%ProgramFiles%with a name that cannot be guessed. Standard users and processes without elevation cannot write there, so no handle with write rights to the new directory (which could set a reparse point) can exist. Users may read there, so the command replaces its DACL withicacls.exe /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F(SYSTEM and the administrators) before the first download (acl_failed) and makes the administrators its owner (icacls.exe /setowner *S-1-5-32-544, alsoacl_failed). The directory is removed in afinally. This sequence can be checked on real Windows only (an end-to-end test on a Windows host, which does not exist yet); the tests of the command cover its structure and its order; - writes the token to a file of the working directory with
Set-Content(varianttoken_inline) and downloads the bundle withInvoke-WebRequest -Headers, without following a redirect (download_failed); .NET has to negotiate TLS 1.2, which Windows 10 and Windows Server 2019 and newer do by default; - checks the SHA-256 with
Get-FileHashbefore it unpacks (checksum_mismatch), unpacks withExpand-Archive, runsUnblock-Fileon the verified script and starts it aspowershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File install-windows.ps1 -Server ... -TokenFile ...: the policy is set for that process only, neverBypass, never for the machine or the user (installer_failedwith the exit code when it fails).
The codes are those of the installers (Connect a host); execution_policy_blocked and installer_failed are new and belong to the command.
What the command cannot hide. Under token_inline the token is text of the command, and what records typed text keeps it: the clipboard, the history of a shell, the
I/O log of sudo (log_input), the history file of PSReadLine, PowerShell transcription and script block logging (event 4104), the recording of a terminal session. A token that such a record kept counts as
leaked. The variant token_file keeps the token out of the command, and fits production, automation and every host that records its input. The first installation
trusts the server that displayed the command (trust on first use): the pinned hash shows that the host received the bundle that the server vouched for after it verified
checksums.txt, not that the server is honest. The manual path of Connect a host stays for sites that cannot accept this.
The installer on Linux (server mode)
deploy/scripts/install-linux.sh --server <origin> --token-file <file> is what the command starts. The origin is https://host[:port] without a
path (http only together with --insecure, the lab variant), and every request of the installer goes to it. The installer, in this order:
- validates its options (
option_conflictfor the ones that the plan decides:--with-updaterand--updater-*,--release-base-url,--supervisor-version,--verify-signatures skip,--collector-version latest), reads the token from the file, and checks the host (conflicting_install, before the server is asked, so no token leaves a host that cannot be installed); - asks
POST /api/v1/install/planwith the platform, the attributes it would report (host.namewhen the name of the host is plain, the site options and the environment), the fleet hint (--fleet) and the version it asks for (--collector-version), with the token asAuthorization: Bearerread bycurl -K -from standard input. It does not follow a redirect by itself: a redirect is followed (at most three) only when its target is on the origin of--serverand https, else it ends the run (https_requiredfor http,plan_invalidfor another origin); a connection error and the statuses 408, 500, 502 and 504 are tried again for a few seconds, thenplan_failed. 503preparingis awaited afterRetry-After(1 to 60 s, 10 s without it) for--plan-waitseconds (900), thenplan_unavailable; another 503 and 429 areplan_unavailableat once; any other refusal isplan_rejectedwith the code of the first issue of the problem document (else the class, elsehttp_<status>), shown only if it has the shape of a code, with the next step for the codes of the server; - reads the plan as data (
parse_plan;install.ParseTextis the reference): at most 8 KiB and 64 lines, only the characters of the grammar,format=1first, only the eleven known keys, each once, each value against the pattern of its key; then the rules that need the origin: the URLs of collector and supervisor are on the origin of--server, https (unless--insecure), with a plain path and the file name the installer expects (otelcol-contrib_<v>_linux_<arch>.tar.gz,opampsupervisor_<v>_linux_<arch>);opamp.endpointnames the host and port of--serverwith the scheme that goes with it (wssorhttpsfor an https server), because the token goes there next; the versions are plainX.Y.Z[-pre]; the fleet and the collector version equal what--fleetand--collector-versionasked for (plan_invalid); - takes the
myriarch-updaterthat lies next todeploy/in the installer bundle (a regular, executable file, no link), copies it to the private working directory and uses the copy as the verifier (--verifieroverrides it;sigstore_verifier_missingwithout either) and, when the plan saysupdater.install=true, as the updater to install (hashed again when it is copied into place). The TUF cache of the check is in the working directory, unless--trusted-rootnames a file; - downloads collector and supervisor from the plan URLs with the grant of each as the bearer (retry policy of the other downloads,
download_failed), checks.sha256, and verifies the Sigstore bundle with the identity of the upstream release workflow at the tag of the plan’s version, built by the installer. Then it continues as the manual path does: it unpacks, installs, writes the configuration and starts the services; the supervisor file is checked at the end (supervisor_missing).
The explicit --opamp-endpoint wins over the one of the plan. Without --server, the version is --collector-version, else the version the release was tested with
(DEFAULT_COLLECTOR_VERSION, the OTELCOL_VERSION of ci/versions.env), and latest looks at the redirect of the releases page of the upstream project. The test
internal/install/installer_server_test.go runs the script against a test server for every rule above.
The installer on Windows (server mode)
deploy\scripts\install-windows.ps1 -Server <origin> -TokenFile <file> is what the Windows command starts (the script is in the zip of the
updater release, with myriarch-updater.exe in the root of the archive). It follows the steps of the Linux installer above with the same limits and patterns
(Read-Plan is the PowerShell counterpart of parse_plan, install.ParseText the reference) and these differences:
The language mode is the first action of the run, before an option is read (
language_mode_unsupported); the check for packages of the upstream project (conflicting_install) comes before the working directory and before the first request, so no token leaves a host that cannot be installed.-Fleet,-PlanWait(seconds, 1 to 99999) and-Serverare new parameters;-WithUpdaterand the-Updater*options,-ReleaseBaseUrl,-SupervisorVersionand-VerifySignatures skipareoption_conflictwith-Server, and-Fleetand-PlanWaitareoption_conflictwithout it.Requests use
System.Net.HttpWebRequestwithAllowAutoRedirectoff, notInvoke-WebRequest, because Windows PowerShell 5.1 and PowerShell 7 report a 3xx or a 4xx of the cmdlet in different ways and the installer has to see the status and theLocationof every answer. The token and the grants are theAuthorizationheader and nothing else. The certificate is checked against the certificate store of the machine; there is no option for a CA file (a private CA is imported into the store first), and-CaFileonly goes into the configuration of the supervisor and the updater. The plan answer is limited to 1 MiB on the wire and to 8 KiB by the grammar. Retries,Retry-After, the waiting forpreparingand the codes are those of the Linux installer.The plan is read from bytes: at most 8 KiB, only the characters of the grammar (a byte table, so no culture or Unicode rule can widen it),
\nline ends, case-sensitive keys and values, patterns anchored with\z(the$of .NET also matches before a final line end). Nothing is evaluated: noInvoke-Expression, no expansion of a plan value into a command, andConvertFrom-Jsonreads the problem document of a refusal only (itscodecounts when it has the shape of a code, itsdetailis cut to harmless characters). The file names that the installer expects areotelcol-contrib_<v>_windows_amd64.tar.gzandopampsupervisor_<v>_windows_amd64.exe.The verifier and the updater are the
myriarch-updater.exethat lies two directories above the script: a regular file, no reparse point, copied into the working directory and hashed again when it is installed. The identity, the issuer and the version ofverify-bundleare the installer’s own, and the TUF cache is in the working directory.Outside staging mode the working directory of the installer is made below
%ProgramFiles%, like the one of the command, and its DACL is restricted to SYSTEM and the administrators before the first download (a temporary folder admits standard users). The supervisor goes to the path of the service and is checked there (supervisor_missing).Before the first request, outside staging mode, it checks that the directory of the installer bundle (the one of
myriarch-updater.exe) can be changed by SYSTEM, the administrators and TrustedInstaller only, as it does for a-Verifier(directory_unsafe): the updater of the bundle verifies what a SYSTEM run installs and is itself installed as a SYSTEM service. The command unpacks the bundle below%ProgramFiles%, which meets the condition.
The test internal/install/installer_windows_server_test.go runs the script in staging mode (-Root) against a test server for every rule above and for every error code.
Its stand-ins for the verifier and the collector are one small Go program built when the tests start, so the same tests run on Windows, where each case runs with Windows
PowerShell 5.1 (powershell.exe) and with PowerShell 7, and elsewhere with PowerShell 7. What staging mode leaves out (ACLs, services, the registry, Group Policy, App Control)
and the tests with a TLS test server (Linux only) need a real Windows host; the end-to-end test on a Windows host is a follow-up task.