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.
Why hierarchical config exists
Section titled “Why hierarchical config exists”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 and component layer
Section titled “Lineage layers and component layer”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:
hierarchyandidentity, 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, andhealth, so operational behavior is consistent.- default
logging, so production components normally run atINFO. - common
credentials,parameters, andstreaming.streamsdefinitions.
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
DEBUGlogging 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.
A line-device example
Section titled “A line-device example”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.
What the merge means
Section titled “What the merge means”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.
How hierarchy is delivered
Section titled “How hierarchy is delivered”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.
Config sources
Section titled “Config sources”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 config document
Section titled “The config document”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.globalis an open object — put any keys your component needs there, and every instance sees them.component.instancesis an array of objects, each requiring a uniqueid. Use it when one component process drives several logical instances (for example, one per production line). Each instance object is also open.tagsholds custom key/value strings that double as template variables (see below) and are stamped as the message envelope’s business-contexttags. Keys match^[a-zA-Z0-9_-]+$.hierarchyandidentitydeclare 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, orts_format. Each SDK reads only its own key and ignores the other three — there is no sharedformatkey.
Numbers in configuration
Section titled “Numbers in configuration”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.
UNS identity: hierarchy and identity
Section titled “UNS identity: hierarchy and identity”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.levelsis 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, orAWS_IOT_THING_NAME). Level names are strict (^[A-Za-z0-9_-]+$, unique).identitysupplies 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(defaultfalse) opts a multi-site broker deployment intoecv1/{site}/{device}/...topics; it is a no-op with a single-level hierarchy.- Zero-config default: with no
hierarchy/identityat all,levelsdefaults to["device"]and the UNS works out of the box asecv1/{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).
Template variables
Section titled “Template variables”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}. |
Sanitization
Section titled “Sanitization”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.
Resolving a template
Section titled “Resolving a template”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 ConfigManagerString path = config.resolveTemplate("/var/log/{ComponentName}-{ThingName}.log");# instance method on ConfigManagerpath = config.resolve_template("/data/{ThingName}/{ComponentName}.log")use edgecommons::config::template;
// free function taking the Config snapshotlet path = template::resolve(&cfg, "heartbeat/{ThingName}/{ComponentName}");import { resolve } from "@edgecommons/edgecommons";
// free function taking the Config snapshotconst path = resolve(cfg, "/data/{ThingName}/{ComponentName}.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.
Reading config in code
Section titled “Reading config in code”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 instancesJsonObject 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 + identityLoggingConfiguration logging = config.getLoggingConfig();HeartbeatConfiguration heartbeat = config.getHeartbeatConfig();String thing = config.getThingName();String name = config.getComponentName(); // short name (after the last '.')config = gg.get_config_manager()
# Global config shared by all instancesglobal_cfg = config.get_global_config()timeout = global_cfg.get("timeout", 5000)
# Per-instance config (multi-instance components)for inst_id in config.get_instance_ids(): inst = config.get_instance_config(inst_id) # raises KeyError if id is absent
# Typed framework sections + identitylogging = config.get_logging_config()heartbeat = config.get_heartbeat_config()thing = config.get_thing_name()name = config.get_component_name() # short name (after the last '.')let cfg = gg.config(); // Arc<Config> — immutable snapshot
// Global config shared by all instanceslet global = cfg.global(); // &serde_json::Value
// Per-instance config (multi-instance components)for id in cfg.instance_ids() { // Vec<String> let inst = cfg.instance(&id); // Option<&Value> — None if id is absent}
// Typed framework sections (parsed snapshot) + identitylet logging = &cfg.parsed.logging;let heartbeat = &cfg.parsed.heartbeat;let thing = &cfg.thing_name;let name = &cfg.component_name;const cfg = gg.config(); // immutable Config snapshot
// Global config shared by all instancesconst global = cfg.global(); // unknown
// Per-instance config (multi-instance components)for (const id of cfg.instanceIds()) { // string[] const inst = cfg.instance(id); // unknown | undefined — undefined if id is absent}
// Typed framework sections (parsed snapshot) + identityconst logging = cfg.parsed.logging;const heartbeat = cfg.parsed.heartbeat;const thing = cfg.thingName;const name = cfg.componentName;Reacting to config changes
Section titled “Reacting to config changes”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;});from edgecommons.config.manager.configuration_change_listener import ( ConfigurationChangeListener,)
class MyListener(ConfigurationChangeListener): def on_configuration_change(self, configuration) -> bool: # `configuration` is the new config dict return True
config.add_config_change_listener(MyListener())use std::sync::Arc;use async_trait::async_trait;use edgecommons::config::{Config, ConfigurationChangeListener};
struct MyListener;
#[async_trait]impl ConfigurationChangeListener for MyListener { async fn on_configuration_change(&self, config: Arc<Config>) -> bool { // `config` is the new snapshot true }}
// Register on the runtime, not the Config snapshot.gg.add_config_change_listener(Arc::new(MyListener));// Register on the runtime. The callback receives the new Config snapshot// and may return a boolean or a Promise<boolean>.gg.addConfigChangeListener({ onConfigurationChange(config) { 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).
Hot reload and validation
Section titled “Hot reload and validation”Hot reload
Section titled “Hot reload”Four of the six sources hot-reload; two are read once at startup:
FILEwatches the config file (and its parent directory, to handle atomic-rename saves) and reloads on change in every language.CONFIGMAPreloads in place via the kubelet..datadirectory swap.SHADOWsubscribes to the device shadow’s update topics through the current ShadowManager / IPC path and reapplies the config when the shadow’sdesiredstate changes.CONFIG_COMPONENTfetches its config by sending a request to the config server’s UNS command inboxecv1/{device}/config/cmd/get-configuration(configis a reserved logical component name) over the active messaging transport, then subscribes to its own command inboxecv1/{device}/{component}/cmd/set-configand reapplies each lineage bundle the ConfigComponent pushes.GG_CONFIGandENVdo not hot-reload — the document is read once at startup. (A Greengrass deployment that revises theGG_CONFIGconfiguration 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.
Schema validation
Section titled “Schema validation”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.
Cross-language differences at a glance
Section titled “Cross-language differences at a glance”- Entry point: Java/Python
getConfigManager()/get_config_manager()return aConfigManagerwith getters; Rust/TypeScriptconfig()return an immutableConfigsnapshot (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
ConfigManagerin Java/Python; on the runtimeggin 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_configre-validates and keeps the prior config on failure, just like the others). It is only fatal during the initial load.