Elastic Agent module
Verified on the wire against Elastic Agent 9.5.5 (2026-10-22) with the
spike in spikes/fleet-emulation, and against the source of
elastic/elastic-agent main 44a9334 (2026-10-16), which imports the
Fleet Server API types from github.com/elastic/fleet-server/pkg/api.
Facts from the source alone are marked (source).
Strataplane manages Elastic Agent in Fleet mode without Kibana and without
Fleet Server: the module implements the agent-facing side of the Fleet Server
API. Agents are installed with --url=<strataplane agent listener> and an
enrollment token issued by Strataplane.
1. Scope
- Stock Elastic Agent 9.0 or later in Fleet mode, Linux and Windows.
- Policies rendered from layers of kind
elastic-agent, with the same selectors, precedence and routes as Telegraf: inputs, outputs (via routes), agent settings, processors. - Actions:
POLICY_CHANGE,UNENROLL,SETTINGS(log level).
2. Protocol
Bodies and answers: openapi.yaml, tag agent-fleet
(the fields the server reads). The table adds what the stock agent does.
| Endpoint | Method | Behaviour |
|---|---|---|
/api/status | GET | Called by the agent before it switches to changed fleet client settings from a POLICY_CHANGE, and before a migration; not before enrollment. Only the status code counts: a 500 makes the policy change fail; 429/503 count as recoverable (source). Strataplane answers HEALTHY. |
/api/fleet/agents/enroll | POST | Authorization: ApiKey <enrollment token>. The agent always sends id and replace_token ("" when unset) and may send metadata.tags as null; of metadata.local (ECS metadata), elastic.agent.version and host.hostname are used. Of the answer's item the agent requires id, type and access_api_key; an empty id makes it retry enrollment. |
/api/fleet/agents/{id}/checkin | POST | Authorization: ApiKey <access_api_key>, gzip body. agent_policy_id and policy_revision_idx come from the last applied action's data.policy.id and revision (a JSON number). Long-poll: held open until an action is pending or the poll timeout passes; holds up to 280 s are verified, the agent's own timeout is 10 min. |
/api/fleet/agents/{id}/acks | POST | One ACTION_RESULT event per action, about 30 ms after the checkin response that carried it. The response must contain "action":"acks" and per-item results, or the agent queues the ack and retries it later. |
/api/fleet/agents/{id}/audit/unenroll | POST | Sent by uninstall of an installed agent, reason uninstall or migrated (source). Strataplane marks the instance forgotten. |
Agent backoff: the next checkin follows 1–2 s after each response. On
errors the agent retries with jittered backoff: the first retry after
30–90 s, doubling up to about 10 min per step. In the default checkin mode a
state change does not end a held checkin, so health can lag by up to one
pollTimeout. Strataplane holds checkins for up to pollTimeout (the
elastic-agent Settings object,
spec-format.md §3.8, default 4 min) to keep 10,000 agents at well under 100 requests per second.
Protocol conformance. The Fleet agent API is not a public, versioned contract. Mitigations:
- Rust types mirror the fleet-server API model that the agent compiles
against (
fleet-server/pkg/api), tolerate unknown fields, and are checked against every supported minor version by the conformance suite. - A conformance suite runs real
elastic-agentbinaries of every supported minor version (container on Linux, VM job on Windows) against Strataplane in CI: enroll, checkin, policy change, ack, unenroll, re-enroll after token rotation. - The supported version range is part of the catalog bundle; agents outside
it are accepted but flagged
unsupported_version.
3. Identity and enrollment
| Path | How | Assurance |
|---|---|---|
| Existing PEM host cert (Puppet, ACME, internal PKI; on Windows exportable) | elastic-agent install --url=https://sp:8444 --enrollment-token=<token> --elastic-agent-cert=… --elastic-agent-cert-key=… --certificate-authorities=…. Strataplane binds the instance to the cert SAN at enrollment and requires the same cert on every checkin. | high |
| Token only | --enrollment-token=<token>; optional "requires approval" on first contact. | low |
- Cert-bound enrollment needs
installorenrollwith--elastic-agent-cert; the container image'sFLEET_*variables do not present the client certificate at enrollment. The agent presents it on every later request. Anhttp://URL is refused unless--insecureis given, so the agent listener serves https. host.idfrom the agent's metadata is not unique (cloned images, containers on one VM) and never identifies an instance.- Enrollment tokens are opaque (
spn_…). Labels on the token are applied to the host (sourceenrollment). - The access API key (
spe_…) is per instance, stored hashed. Re-enrollment rotates it and re-binds the existing instance only with proof: the instance's bound certificate (or one from a trust anchor for its host name), or thereplace_tokenit was enrolled with (install --id=… --replace-token=…; stored hashed). Anidthat is already enrolled without that proof is refused (409); without anid, enrollment creates a new instance, and the host name never re-binds one. - Elastic Agent persists the key in
fleet.encand the last policy instate.enc(DPAPI on Windows, AES-GCM with a local seed file on Linux). - Machines built from one image install and enroll at first boot (Intune,
SetupComplete.cmd), never in the image.
4. Policy rendering
The merged tree of an Elastic Agent instance becomes a full agent policy:
{
"id": "sp-7f3c…",
"revision": 42,
"outputs": {
"logs-kafka": {
"type": "kafka",
"hosts": ["kafka-1:9093", "kafka-2:9093"],
"username": "filebeat",
"password": "<inlined at delivery>"
}
},
"fleet": { "hosts": ["https://strataplane.example.com:8444"] },
"agent": {
"monitoring": { "enabled": false },
"logging": { "level": "info" },
"features": {}
},
"inputs": [
{
"id": "filestream-app-logs",
"type": "filestream",
"use_output": "logs-kafka",
"data_stream": { "namespace": "prod" },
"streams": [
{
"id": "app-logs",
"data_stream": { "dataset": "app.logs" },
"paths": ["/var/log/app/*.log"],
"parsers": [{ "ndjson": { "target": "" } }]
}
],
"processors": [{ "add_fields": { "target": "", "fields": { "team": "team-app" } } }]
}
]
}
idandrevisionare set at delivery, not stored in the artifact, so instances that render alike share one artifact.idis stable per agent instance;revisionis thepolicy_seqof thePOLICY_CHANGEaction, +1 over the instance's previous one whenever its artifact changes, so the agent'spolicy_revision_idxmaps back to an artifact through the action that carried it.inputs[].idis the instance key name. Inputs are written in raw agent input form, as with standalone agents.agent.monitoringis off by default (§8).- Secrets are referenced as
{ secretRef = "…" }in the spec and stored as versioned placeholders (name@vN) in the artifact. Values are inlined only into thePOLICY_CHANGEpayload at delivery, after the delivery rule passes. Diffs and the UI show<secret:kafka_logs_password@v3>. Rotating a referenced secret creates a new artifact for exactly the agents that use it. - Routes become
outputs; every input must name ause_outputthat is attached to the agent, checked at L1. fleet.hostsis always injected, so agents follow Strataplane URL changes. It stays byte-identical between revisions unlessserver.agent_urlchanges, because changedfleetsettings make the agent check/api/statusbefore applying the policy.
5. Delivery and applied state
reconciler: new desired artifact for instance X
→ agent_action POLICY_CHANGE (policy_seq = n), supersedes undelivered older ones
→ NOTIFY 'agent_actions' → the replica holding X's checkin wakes it up
checkin response: actions = [POLICY_CHANGE{id, data.policy (secrets inlined)}] → served_artifact
agent applies, acks → acks endpoint: acked_at
next checkin: policy_revision_idx = n, the policy's inputs among components → applied_artifact
- Strataplane sets
applied_artifactwhen the action is acked and a later checkin reportspolicy_revision_idx = nwith every input of the policy among its components. An ack alone does not prove the inputs run: a policy whose inputs cannot start is still acked. Component failures after that count as health, not sync (an unreachable output alone makes componentsDEGRADED). - A failed
POLICY_CHANGEis never acked. The failure shows in the next checkin asstatus: errorwithmessage: Actions failed: …and the oldpolicy_revision_idx; Strataplane stores that message as the action'sack_errorand setssync = out_of_syncwith reasonreload_failed. - Undelivered actions are re-sent on the next checkin. The agent does not
de-duplicate actions by id: a re-sent
POLICY_CHANGEis applied and acked again, so a delivered action is re-sent only after the ack timeout, and never once a checkin has reportedpolicy_revision_idx = n. - Actions survive restarts and replica failover because they live in
agent_action. Theack_tokenis a delivery cursor: the agent echoes the token of the last checkin response it received, whether or not its actions succeeded; only acks confirm. - After acking
UNENROLLthe agent stops its components and sends nothing more, while its process keeps running; the ack is final. - Forgetting an agent that checked in within the last 10 minutes queues
UNENROLLfirst; its ack revokes the credential and forgets the instance. Other agents are forgotten at once.
Long-poll across replicas. A checkin parks on a per-instance
tokio::sync::Notify in the replica that received it. Action inserts
NOTIFY agent_actions, '<instance id>'; every replica's listener wakes local
waiters for that id. On timeout or wake-up, the handler re-reads pending
actions from the database, so a lost notification only delays delivery until
the next poll.
6. Status ingest
| Checkin field | Strataplane |
|---|---|
status online / DEGRADED / error / starting | health healthy / degraded / failed / pending; error with Actions failed is a failed policy change (§5) |
components[].units[] | agent_component rows; a FAILED unit makes health degraded (or failed for outputs) |
local_metadata.host, .os, .elastic.agent.version | host labels (agent source), instance version |
agent_policy_id, policy_revision_idx | sync state (§5) |
upgrade_details | shown on the instance |
Staleness (not_reporting) counts in pollTimeout intervals
(agent_instance).
7. Validation
| Level | Implementation |
|---|---|
| L1 | Every input has a known type, a unique id, a use_output that exists. |
| L2 | Policy JSON Schema (core structure, hand-maintained) plus the input types each component supports in that version, read from the component spec files in the agent package (components/*.spec.yml, extracted to JSON by the catalog bundle builder so the server parses no YAML). Unknown input types are errors; input options are mostly free-form and checked by L3. |
| L3 | Optional sandbox: the matching elastic-agent binary in a container in standalone mode with the rendered policy, checking that components start (elastic-agent status / inspect). Slow, therefore per distinct artifact and only at submit. Needs a container runtime on the server. |
8. Monitoring
agent.monitoring is off by default; health comes from checkins. A layer
enables it with settings.agent.monitoring, pointing at a route, like any
other setting.