Skip to main content

Config Sync

The agent config sync system enables operators to manage agent operational configuration centrally from the backend API, without redeploying or restarting agents. Configs are stored at two levels — environment defaults and per-host overrides — merged by the backend, and delivered to agents via both push and pull mechanisms.

Architecture Overview

Config Types

The backend maintains a type registry that validates and controls merge behavior for each config type. Only registered types are accepted by the API — requests with unknown types receive a 400 Bad Request response.

TypePurposeData Shape
collectorsMetric collector definitions (e.g., MySQL, Redis, HTTP checks)JSON array of collector objects
intervalsPolling intervals for metrics, inventory, heartbeat, processesJSON object with duration strings
labelsStatic key-value labels and dynamic label generatorsJSON object with static and dynamic keys
change_detectionFile change detection paths, intervals, and enable/disable toggleJSON object
log_collectionLog file paths, journald units, and collection settingsJSON object with files and journald keys
agent_settingsAgent-level tuning: collector timeout, memory limitsJSON object
watch_filesFile watch scanner paths, excludes, sensitive files, max file sizeJSON object
healthcheckHealth check intervals and timeoutsJSON object
tracingDistributed-tracing toggle and trace sample ratio (0–1)JSON object
log_shippingAgent self-log shipping toggle and minimum level (error/warn/info/debug)JSON object
Source of truth

The type registry is defined in backend/internal/agentconfig/types.go. Each entry specifies a Validate function (called on API write) and a Merge function (called during config resolution).

Two-Level Config Model

Configs are stored at two levels in PostgreSQL:

LevelTableScopePurpose
Environmentenvironment_configsAll hosts in an environmentFleet-wide defaults
Hosthost_configsSingle hostPer-host overrides

Both tables share the same structure: config_type, config_data (JSONB), and version (auto-incrementing on each update).

Config Resolution

When a config is needed for a specific host, the Resolver merges the environment-level default with any host-level override. The merge strategy depends on the config type.

Resolution Rules

ScenarioResultSource Field
Environment config onlyUse environment data as-is"environment"
Host override onlyUse host data as-is"host_override"
Both existType-specific merge — host keys take precedence"merged"
Neither existsNo config (nil)

The version of the effective config is the maximum of the environment and host versions.

Type-Specific Merge Strategies

Each config type declares its own merge function. The backend applies the correct strategy automatically during resolution.

Shallow Object Merge

Applies to: intervals, agent_settings, change_detection, watch_files, healthcheck

Top-level JSON keys from the host override replace the corresponding environment keys. Keys present only in the environment config are preserved. Nested objects are replaced entirely, not recursively merged.

Collectors: Identity Merge by {type, name}

Applies to: collectors

Collectors are JSON arrays. Each entry is identified by its {type, name} pair. Merge rules:

  1. Start with the environment collector list (preserving order).
  2. For each host entry, find the environment entry with the same {type, name}.
  3. If found, the host entry replaces the environment entry in-place.
  4. If not found, the host entry is appended to the end.
  5. If a host entry has "enabled": false, the matching environment entry is removed entirely.
// Environment: [{type: "mysql", name: "primary"}, {type: "redis", name: "cache"}]
// Host: [{type: "mysql", name: "primary", interval: "30s"}]
// Result: [{type: "mysql", name: "primary", interval: "30s"}, {type: "redis", name: "cache"}]
Disabling inherited collectors

To disable a collector defined at the environment level for a specific host, set "enabled": false in the host override:

{"type": "mysql", "name": "primary", "enabled": false}

This removes the collector from the merged result for that host only.

Labels: Nested Merge

Applies to: labels

Labels have two sub-sections with different merge behaviors:

  • static — Shallow key-value merge (host keys override environment keys).
  • dynamic — Array merge by name field (host entries with the same name override environment entries; new entries are appended).

Other top-level keys in the labels object use shallow merge.

Log Collection: Files by Name, Journald Shallow

Applies to: log_collection

  • files — Array of file objects merged by name field (same identity-merge logic as labels dynamic).
  • journald — Shallow object merge at the journald level. If both sides have units, the host value fully replaces the environment value.
  • Other top-level keys use shallow merge.

Credential Resolution

Collector configs can reference stored credentials via a credential_id field. Before the backend pushes a resolved config to an agent, the Credential Resolver decrypts and injects secret fields inline.

Resolution Rules

RuleDetail
ScopeOnly applies to collectors config type. Other types are passed through unchanged.
All-or-nothingIf any single credential fails to resolve (not found, client mismatch, decryption error), the entire push for that host fails. No partial configs are delivered.
Client scopingThe credential's client_id must match the host's client. Cross-client credential references are rejected.
Environment scopingIf the credential has an environment_id, it must match the host's environment.
Field injectionDecrypted secret fields are injected directly into the collector config object.
ID removalThe credential_id key is removed from the payload before delivery.
Secrets never reach disk

Resolved secrets are injected into the NATS payload only. They are never written to the agent's disk cache. When the agent restarts and loads its cache from disk, collector entries that originally had credentials are flagged as redacted and skipped by the fallback resolver. The agent waits for the backend to re-deliver full credentials via NATS.

Delivery: Push + Pull

Configs reach agents through two complementary mechanisms:

Pull: Startup Config Fetch

On startup, the agent fetches all configs from the backend via NATS request-reply:

Request payload:

{"agent_id": "550e8400-e29b-41d4-a716-446655440000"}

Response payload:

{
"configs": {
"watch_files": {"paths": ["/etc", "/opt"], "exclude_patterns": ["*.swp"]},
"healthcheck": {"interval_seconds": 60, "timeout_seconds": 5}
},
"versions": {
"watch_files": 3,
"healthcheck": 1
}
}

Push: Runtime Config Updates

When an operator updates a config via the API, the backend pushes the resolved config to affected agents immediately:

Push is best-effort — errors are logged but do not fail the API request. If an agent is offline, it will receive the updated config on its next startup via the pull mechanism.

Fallback Chain

When the agent needs a config at startup, the Fallback Resolver walks a priority chain to find the best available value:

PrioritySourceNotes
1 (highest)NATS push/pullFresh config from backend with resolved credentials
2Disk cachePersisted from last successful NATS delivery
3 (lowest)nilNo config for this type; agent uses local YAML or built-in defaults
Redacted collector cache

Collector configs that were resolved with credentials are cached with redacted secret values after a restart. The fallback resolver skips these entries because redacted credentials cannot connect to real services. The agent waits for the backend to re-send full credentials via NATS.

Apply Acknowledgement

After applying (or failing to apply) a config update, the agent publishes an acknowledgement message to NATS. The backend persists the result for observability.

Ack payload:

{
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"config_type": "collectors",
"version": 5,
"status": "applied"
}

On failure:

{
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"config_type": "collectors",
"version": 5,
"status": "failed",
"error": "dial tcp: connection refused"
}

The host_config_apply_status table uses a UNIQUE(host_id, config_type) constraint, so each UPSERT replaces the previous status for that host+type pair. This gives operators a per-type view of which config version each host last applied and whether it succeeded.

Boot Jitter

On startup, the agent sleeps for a random duration between 0 and 5 seconds before issuing its NATS config fetch request.

jitter := time.Duration(rand.Int63n(5000)) * time.Millisecond
time.Sleep(jitter)
Why jitter?

When many agents restart simultaneously (e.g., after a fleet-wide update or infrastructure event), they would all hit the backend and Vault at the same instant. The random jitter spreads these requests over a 5-second window, preventing a thundering herd on config resolution and credential decryption.

Agent Config Cache

The agent maintains a persistent disk cache at {data_dir}/config.cache.json for restart resilience:

[
{"config_type": "watch_files", "config_data": {"paths": ["/etc"]}, "version": 3},
{"config_type": "healthcheck", "config_data": {"interval_seconds": 60}, "version": 1}
]

Cache Behavior

OperationBehavior
StartupLoad cache from disk (missing/corrupt file starts empty)
Remote fetch successPopulate cache with all received configs
Remote fetch failureFall back to cached configs
Command push receivedUpdate cache entry, persist to disk
WritesAtomic via temp file + rename (no partial writes)
PermissionsFile: 0600, directory: 0700

Cache Fingerprinting

The cache computes two values reported in every heartbeat:

  • config_hash — SHA-256 hash of all cached config data (sorted by type for determinism). Enables drift detection.
  • config_version — Maximum version across all cached configs. Enables staleness detection.

Heartbeat Reporting

Every heartbeat includes the agent's current config state:

{
"agent_version": "1.0.0",
"config_hash": "a1b2c3d4e5f6...",
"config_version": 3,
"config_versions": {
"collectors": 5,
"intervals": 2,
"labels": 1,
"watch_files": 3
}
}
FieldTypePurpose
config_hashstringSHA-256 of all cached config data. Enables drift detection across the fleet.
config_versionintMax version across all cached configs. Quick staleness check.
config_versionsmap[string]intPer-type version map. Allows operators to verify that each individual config type is at the expected version.

The backend persists these values in the hosts table on each heartbeat, enabling operators to query which hosts are running outdated or mismatched configurations.

ConfigHandler (Agent)

The ConfigHandler is a generic command handler that routes config_update commands by config_type:

Handle Flow

  1. Validate config_type is non-empty
  2. Call cache.Put(configType, payload, version) to persist
  3. Look up registered callback for the config type
  4. Call callback (if registered) for hot-reload
  5. Publish apply acknowledgement (success or failure) to NATS
  6. If no callback is registered, the config is still cached for next restart

Hot-Reload Capabilities

All 10 config types have registered callbacks:

Config TypeHot-Reload Behavior
labelsReplaces static labels and restarts dynamic label goroutines via labels.Manager.Update()
intervalsSends new duration to running scheduler goroutines via channels (scheduler.UpdateInterval())
collectorsSwaps plugin collectors while preserving system collectors (metrics.Task.ReplacePluginCollectors())
change_detectionForwards to scanner.UpdateConfig() and updates scan interval
watch_filesForwards to file watch scanner.UpdateConfig()
healthcheckForwards to healthcheck runner.UpdateConfig()
log_collectionLogs a restart-required warning (Fluent Bit requires process restart)
agent_settingsApplies dynamic log level change by updating a slog.LevelVar that backs the active handler
tracingApplies the new tracing toggle and sample ratio to the live OTel provider (tracing.Provider.Apply())
log_shippingToggles agent self-log shipping and adjusts the minimum level on the running controller

Startup Config Application

Remote configs are also applied at agent startup (not just on subsequent reloads). After the agent fetches its initial config from NATS/cache, it applies labels, intervals, collectors, and change_detection configs before starting the scheduler. This ensures console-managed configs take effect immediately on boot.

Registering Callbacks

configHandler.RegisterCallback("labels", func(ctx context.Context, configType string, data json.RawMessage, version int) error {
return configsync.ReloadLabels(ctx, labelMgr)(ctx, configType, data, version)
})

Callbacks enable live config application without agent restart. Components use sync.RWMutex or channel-based updates to safely apply new configuration while tasks may be running concurrently.

API Endpoints

Environment-Level Configs

MethodPathDescription
GET/api/v1/environments/{envID}/agent-configsList all configs for an environment
GET/api/v1/environments/{envID}/agent-configs/{configType}Get a specific config
PUT/api/v1/environments/{envID}/agent-configs/{configType}Create or update a config (pushes to all online hosts)
DELETE/api/v1/environments/{envID}/agent-configs/{configType}Delete a config

Host-Level Overrides

MethodPathDescription
GET/api/v1/hosts/{hostID}/agent-configs/{configType}Get effective (resolved) config for a host
PUT/api/v1/hosts/{hostID}/agent-configs/{configType}Create or update a host override (pushes to agent)
DELETE/api/v1/hosts/{hostID}/agent-configs/{configType}Delete a host override (reverts to environment config)

Request/Response Examples

PUT environment config:

curl -X PUT /api/v1/environments/{envID}/agent-configs/watch_files \
-d '{"config_data": {"paths": ["/etc", "/opt"], "exclude_patterns": ["*.log"]}}'

Response (200):

{
"data": {
"id": "...",
"environment_id": "...",
"config_type": "watch_files",
"config_data": {"paths": ["/etc", "/opt"], "exclude_patterns": ["*.log"]},
"version": 1
}
}

GET effective config for a host:

{
"data": {
"config_type": "watch_files",
"config_data": {"paths": ["/etc", "/opt", "/var/log"], "exclude_patterns": ["*.log"]},
"version": 3,
"source": "merged"
}
}

Resilience

ScenarioBehavior
Agent offline during pushConfig cached in DB; agent pulls on next startup
Backend unreachable at startupAgent falls back to disk cache (via fallback chain)
Disk cache missing or corruptAgent starts with no cached configs; uses local YAML
Disk cache has redacted collectorsFallback resolver skips; agent waits for NATS re-delivery
NATS connection lostAgent reconnects automatically; dispatcher re-subscribes
Push publish failsLogged but does not fail the API request
Credential resolution failsAll-or-nothing: entire push for that host is skipped
Callback errorLogged; config is still cached; apply ack reports "failed"

Key Files

FileDescription
backend/internal/agentconfig/types.goConfig type registry with validation and merge functions
backend/internal/agentconfig/resolver.goTwo-level config resolution with type-specific merge
backend/internal/agentconfig/credential_resolver.goCredential resolution for collector configs
backend/internal/domain/environment_config.goDomain types (EnvironmentConfig, EffectiveConfig)
backend/internal/store/environment_config_store.goPostgreSQL CRUD for environment configs
backend/internal/store/config_apply_store.goCRUD for host_config_apply_status table
backend/internal/api/agent_config_handler.goREST API handler for all config endpoints
backend/internal/api/command_push.goNATS publish helper for config commands
backend/internal/worker/config_fetch.goRequest-reply worker for agent config pulls
backend/internal/worker/config_apply.goNATS subscriber for apply acknowledgements
agent/internal/configcache/cache.goPersistent disk cache with hash/version
agent/internal/command/config_handler.goGeneric config command handler with callbacks and apply ack
agent/internal/configsync/fallback.goFallback resolver (NATS > cache > nil)