Skip to content

Configuration

Configuration gives an EdgeCommons component its operating context. It tells the component where it is running, how it should connect, what defaults it should inherit, and what application-specific work it should perform.

At runtime, component code reads one effective JSON config document. Direct sources such as FILE, ENV, CONFIGMAP, GG_CONFIG, and SHADOW provide that document directly. CONFIG_COMPONENT can assemble it hierarchically: ordered catalog layers define enterprise, site, zone, line, or any other user-defined scopes, and a component-specific layer adds or overrides only what that component owns.

Hierarchical config lets a fleet define common behavior once and inherit it consistently, while still allowing individual components to override details such as logging level during diagnostics. This guide starts with that inheritance model, then covers config sources, document structure, typed accessors, and live reload. For the exhaustive field list see the configuration schema reference; for the full accessor surface see the config API reference.

Industrial edge devices usually run more than one component. A single line device might run an OPC UA adapter, a Modbus adapter, a telemetry processor, a file replicator, and a bridge to a site broker. Those components are different applications, but they live in the same physical context: the same factory, the same production line, the same edge device, the same local broker, the same default heartbeat and logging posture.

Hierarchical config separates shared enterprise/site/zone/line context from each component’s application-specific behavior.

The right side of the diagram follows edge-north-1. Its inherited line layer explicitly sets logging.level = "INFO". The Modbus adapter and telemetry processor inherit that value because their component layers do not mention logging. The OPC UA adapter’s component layer sets logging.level = "DEBUG", so only that component’s effective config changes to DEBUG.

Think of hierarchy layers as the configuration that answers: where is this device, and how should components in this enterprise/site/zone/line behave by default? Think of the component layer as the configuration that answers: what does this particular component need to do?

For the edge north-1 device in the diagram, every component on that device should agree on the same identity:

{
"hierarchy": { "levels": ["factory", "line", "device"] },
"identity": { "factory": "north", "line": "line-1" }
}

The last hierarchy level, device, is the resolved thing name, such as edge-north-1. With that inherited context, the OPC UA adapter, Modbus adapter, and telemetry processor all publish under the same device identity even though each component has different work to do.

Lineage layers are where you put defaults for all components in a scope. A catalog can define as many scopes as the user’s hierarchy needs, for example enterprise, site, building, zone, and line:

  • hierarchy and identity, so every component stamps the same factory/line/device context.
  • messaging, so every component connects to the same local and northbound brokers.
  • tags, so every component carries the same site, line, or cell metadata.
  • heartbeat, metricEmission, and health, so operational behavior is consistent.
  • default logging, so production components normally run at INFO.
  • common credentials, parameters, and streaming.streams definitions.

The component layer is where you put the component’s own behavior:

  • an OPC UA adapter’s endpoint, subscriptions, and node ids.
  • a Modbus adapter’s unit id, register map, and polling interval.
  • a telemetry processor’s routes, filters, and projection rules.
  • a one-off override, such as setting only one component to DEBUG logging during investigation.

The SDK merges lineage layers from the highest shared scope to the component layer. Later layers win. If the line layer says logging.level = "INFO" and one component says logging.level = "DEBUG", only that component gets DEBUG logging. Other components on the same line continue to use INFO.

For a line device named edge-north-1, the inherited line-level config might contain only the context and defaults that all components need:

{
"hierarchy": { "levels": ["factory", "line", "device"] },
"identity": { "factory": "north", "line": "line-1" },
"logging": { "level": "INFO" },
"heartbeat": { "enabled": true, "intervalSecs": 10 },
"tags": { "factory": "north", "line": "line-1" },
"component": { "global": { "maintenanceWindow": "02:00Z" } }
}

An OPC UA adapter on that same device then keeps only its own settings in its component layer:

{
"component": {
"global": {
"endpoint": "opc.tcp://10.10.7.20:4840",
"pollSecs": 3
},
"instances": [
{ "id": "press-1", "node": "ns=2;s=Press1.Speed" },
{ "id": "press-2", "node": "ns=2;s=Press2.Speed" }
]
}
}

If that adapter needs extra diagnostics, its component layer can override the inherited logging level:

{
"logging": { "level": "DEBUG" },
"component": {
"global": {
"endpoint": "opc.tcp://10.10.7.20:4840",
"pollSecs": 3
}
}
}

That override affects only the OPC UA adapter. The Modbus adapter and telemetry processor on the same line device keep the inherited INFO logging default.

The merge is intentionally simple and applies to the ordered layers[].config values in a CONFIG_COMPONENT lineage bundle:

Value shape Merge behavior
Object Merge key-by-key. Keys present only in earlier layers are inherited.
Array Replace the earlier array with the later array. Arrays are not concatenated.
Scalar Replace the earlier value.
null Replace the earlier value with null, then validate the effective document.

After merge, the SDK validates the effective document against the same schema used for single-document config. Startup fails if the effective document is invalid. On hot reload, an invalid update is rejected and the previous valid config remains active.

Hierarchical config is delivered by CONFIG_COMPONENT. The built-in server role is the dedicated Rust component com.mbreissi.edgecommons.ConfigComponent. Ordinary SDK components are clients only: they do not automatically subscribe to the config rendezvous or serve configuration. The server bootstraps from its own non-CONFIG_COMPONENT source, loads a catalog with hierarchy.levels, nodes, and components, and serves ordered lineage bundles to consumer components.

The same authoring model works on HOST/supervisord, Kubernetes, and Greengrass. What changes by platform is the transport used between clients and the ConfigComponent:

Platform ConfigComponent bootstrap Client transport
HOST / supervisord FILE or ENV MQTT
Kubernetes CONFIGMAP MQTT
Greengrass GG_CONFIG IPC

Direct providers (FILE, ENV, CONFIGMAP, GG_CONFIG, and SHADOW) continue to load a single effective document. They do not resolve sidecar shared files, extends, or provider-specific base locations.

There are six config sources, identical across all four languages. You pick one with the -c/--config <SOURCE> [args...] CLI flag. When -c is omitted, the source is chosen by the resolved platform profile: GREENGRASS defaults to GG_CONFIG, HOST to FILE, and KUBERNETES to CONFIGMAP.

Source Select with When to use
FILE -c FILE [path] (default config.json) Local development, HOST/Docker, bare hosts. Watches the file and hot-reloads on change. Default on HOST.
ENV -c ENV [var] (default CONFIG) Pass the entire config JSON through an environment variable.
CONFIGMAP -c CONFIGMAP [mountDir] [key] (defaults /etc/edgecommons, config.json) Kubernetes. Reads a mounted ConfigMap directory and hot-reloads via the kubelet ..data swap. Default on KUBERNETES.
GG_CONFIG -c GG_CONFIG [component] [key] (default key ComponentConfig) On-device: read the config delivered by the Greengrass deployment. Default on GREENGRASS.
SHADOW -c SHADOW [name] (default: the component name sanitized to [A-Za-z0-9:_-]) Read config from an AWS IoT named device shadow. The current implementation reaches shadows through the Greengrass ShadowManager / IPC path.
CONFIG_COMPONENT -c CONFIG_COMPONENT Read hierarchical config served by ConfigComponent over the configured messaging transport.

GG_CONFIG loads over Greengrass IPC. The current SHADOW implementation also uses the Greengrass ShadowManager / IPC path. CONFIG_COMPONENT uses the active messaging transport, so it works over Greengrass IPC or HOST/Kubernetes MQTT as long as a messaging client/provider is available. FILE, ENV, and CONFIGMAP need nothing extra.

The top level of the effective document is strict: only the known sections are allowed and component is the only required one. The framework sections (logging, metricEmission, heartbeat, tags, hierarchy, identity, topic, messaging, streaming, credentials, parameters, health) configure the SDK subsystems; the component section carries your application config, split into a shared component.global object and an optional component.instances array for multi-instance components.

{
"logging": { "level": "DEBUG", "rust_format": "{timestamp} [{level}] [{component}] {target} - {message}" },
"heartbeat": { "enabled": true, "intervalSecs": 5, "measures": { "cpu": true, "memory": true }, "destination": "local" },
"metricEmission": { "target": "log", "namespace": "edgecommons" },
"hierarchy": { "levels": ["site", "device"] },
"identity": { "site": "factory-1" },
"credentials": { "vault": { "path": "./.vault/vault", "keyProvider": { "type": "file", "keyPath": "./.vault/vault.key" } } },
"parameters": { "source": { "type": "env", "prefix": "GG_PARAM_" }, "refreshIntervalSecs": 0, "sync": { "names": ["/skeleton/region", "/skeleton/poolSize"] } },
"tags": { "site": "factory-1" },
"component": {
"global": { "publish_interval": 3 },
"instances": [ { "id": "main" } ]
}
}

A few things worth knowing about the structure:

  • component.global is an open object — put any keys your component needs there, and every instance sees them.
  • component.instances is an array of objects, each requiring a unique id. Use it when one component process drives several logical instances (for example, one per production line). Each instance object is also open.
  • tags holds custom key/value strings that double as template variables (see below) and are stamped as the message envelope’s business-context tags. Keys match ^[a-zA-Z0-9_-]+$.
  • hierarchy and identity declare the component’s UNS identity — the enterprise hierarchy and the location values above the device.
  • The per-language logging format key is language-scoped: java_format, python_format, rust_format, or ts_format. Each SDK reads only its own key and ignores the other three — there is no shared format key.

An integer-typed setting accepts any numeric encoding with an integral value, regardless of the config source: 5000, 5000.0, and 5e3 all configure the same value. The SDK delivers numbers to your component in canonical integer form, so component.global, component.instances, and tags read the same in every language and from every source.

A fractional value in an integer-typed setting is rejected rather than rounded or truncated. Numbers in float-typed settings, and values outside the signed/unsigned 64-bit integer range, are delivered exactly as written.

Two top-level sections drive the component’s Unified Namespace identity:

{
"hierarchy": { "levels": ["site", "factory", "zone", "device"] },
"identity": { "site": "dallas", "factory": "finishing", "zone": "zone-3" },
"topic": { "includeRoot": false }
}
  • hierarchy.levels is the ordered, freely-named enterprise hierarchy; the last level is the physical node, and its value is always the resolved thing name (from -t, the Kubernetes Downward API, or AWS_IOT_THING_NAME). Level names are strict (^[A-Za-z0-9_-]+$, unique).
  • identity supplies the values for every level except the last. A missing value — or a key equal to the last level name, or not a declared level — is a startup error (fail-fast, once, at config construction). Values pass through the template sanitizer.
  • topic.includeRoot (default false) opts a multi-site broker deployment into ecv1/{site}/{device}/... topics; it is a no-op with a single-level hierarchy.
  • Zero-config default: with no hierarchy/identity at all, levels defaults to ["device"] and the UNS works out of the box as ecv1/{thing}/{component}/main/{class}.

The resolved identity is stamped on every config-bound message and is exposed as Java configManager.getComponentIdentity(), Python config.get_component_identity(), Rust cfg.identity(), and TypeScript cfg.componentIdentity — see the UNS API reference.

The library also announces the effective configuration on the UNS: at startup and on every config change it publishes a redacted snapshot (secrets and credentials masked, $secret refs never resolved) to ecv1/{device}/{component}/cfg — so a console or auditor can review any component’s live config with an ecv1/+/+/cfg subscription (add ecv1/+/+/+/cfg for instance-scoped publishers).

String values such as log paths and MQTT topics can contain template variables that the SDK substitutes at resolution time. The supported placeholders are identical in all four languages:

Variable Resolves to
{ThingName} The resolved IoT thing / identity name.
{ComponentName} The short component name (the segment after the last .).
{ComponentFullName} The full component name.
{<tagKey>} Any string key under the tags section — e.g. a tag site is usable as {site}.

Only the substituted values are sanitized — the template literal itself is left intact, so legitimate separators you write in the template (slashes in a path, / in an MQTT topic) survive. Within each injected value, the characters /, \, +, #, and control characters become _, and any remaining .. traversal sequence is collapsed to _. This prevents an attacker-controlled thing name or tag from escaping a path or topic.

The resolution entry point differs by language: Java and Python expose an instance method on the config manager, while Rust and TypeScript expose a free function that takes the Config snapshot.

// instance method on ConfigManager
String path = config.resolveTemplate("/var/log/{ComponentName}-{ThingName}.log");

The SDK also auto-resolves templates inside the streaming, credentials, and parameters sections before handing them to those subsystems — you only call resolve yourself for your own templated strings.

How you reach the config differs by language. Java and Python hand you a ConfigManager object with per-section getters. Rust and TypeScript hand you an immutable Config snapshot directly — re-call config() after a reload to see updates, because the snapshot is replaced atomically.

ConfigManager config = gg.getConfigManager();
// Global config shared by all instances
JsonObject global = config.getGlobalConfig();
long timeout = global.has("timeout") ? global.get("timeout").getAsLong() : 5000L;
// Per-instance config (multi-instance components)
for (String id : config.getInstanceIds()) { // Collection<String>
JsonObject inst = config.getInstanceConfig(id); // null if id is absent
}
// Typed framework sections + identity
LoggingConfiguration logging = config.getLoggingConfig();
HeartbeatConfiguration heartbeat = config.getHeartbeatConfig();
String thing = config.getThingName();
String name = config.getComponentName(); // short name (after the last '.')

Register a change listener to be notified after a hot reload. Here too the SDKs diverge: Java and Python register the listener on the ConfigManager; Rust and TypeScript register it on the top-level runtime (gg). The callback shape also differs — Java’s callback takes no argument (you read the new values back from the manager), while the others receive the new config.

// Register on the ConfigManager. onConfigurationChanged() takes NO argument —
// read the updated values back from the manager. The return value is ignored.
config.addConfigChangeListener(() -> {
JsonObject updated = config.getGlobalConfig();
// ... re-apply what you need
return true;
});

The listener method name is past-tense in Java (onConfigurationChanged) and present-tense elsewhere (on_configuration_change / onConfigurationChange). Java and Python listeners are synchronous; Rust is async; TypeScript may return a Promise<boolean> | boolean. The boolean return is logged but not otherwise acted on. To stop receiving callbacks, use the matching removeConfigChangeListener / remove_config_change_listener (Rust removes by Arc pointer identity).

Four of the six sources hot-reload; two are read once at startup:

  • FILE watches the config file (and its parent directory, to handle atomic-rename saves) and reloads on change in every language.
  • CONFIGMAP reloads in place via the kubelet ..data directory swap.
  • SHADOW subscribes to the device shadow’s update topics through the current ShadowManager / IPC path and reapplies the config when the shadow’s desired state changes.
  • CONFIG_COMPONENT fetches its config by sending a request to the config server’s UNS command inbox ecv1/{device}/config/cmd/get-configuration (config is a reserved logical component name) over the active messaging transport, then subscribes to its own command inbox ecv1/{device}/{component}/cmd/set-config and reapplies each lineage bundle the ConfigComponent pushes.
  • GG_CONFIG and ENV do not hot-reload — the document is read once at startup. (A Greengrass deployment that revises the GG_CONFIG configuration restarts the component, so the new values are picked up on the next start rather than in place.)

Every reloading source funnels through the same applyConfig seam, so the re-validation and reject-and-keep behavior below applies uniformly: on each reload the new document is re-validated against the schema before it is applied.

Configuration is validated against the single canonical schema (schema/edgecommons-config-schema.json), which each SDK embeds or loads. Validation is fail-closed in all four languages — a missing schema or a validation error at startup is a hard error, not a silent skip. Each SDK uses its native validator (networknt / JSON Schema draft V7 in Java, jsonschema in Python, the jsonschema crate in Rust, Ajv in TypeScript).

The top level of the schema is strict (additionalProperties: false, required: ["component"]), so an unknown or mistyped top-level section is rejected. The component.global, component.instances, and tags subtrees are intentionally open so you can put arbitrary application keys there.

Python is also the only SDK that can opt out of validation, by constructing the config manager with validate_config=False. Java, Rust, and TypeScript embed the schema and cannot disable validation.

  • Entry point: Java/Python getConfigManager() / get_config_manager() return a ConfigManager with getters; Rust/TypeScript config() return an immutable Config snapshot (re-call after reload).
  • Template resolution: instance method (resolveTemplate / resolve_template) in Java/Python; free function (template::resolve(&cfg, s) / resolve(cfg, s)) in Rust/TypeScript.
  • Listener registration: on the ConfigManager in Java/Python; on the runtime gg in Rust/TypeScript.
  • Listener callback: Java takes no argument; Python/Rust/TypeScript receive the new config. Rust is async.
  • Missing instance: null (Java) / None (Rust) / undefined (TypeScript) / KeyError (Python).
  • Invalid reload: reject-and-keep in all four SDKs — a schema-invalid hot reload is logged, the previous config is kept, and listeners are not notified (Python’s apply_config re-validates and keeps the prior config on failure, just like the others). It is only fatal during the initial load.