Config module
Source: modules/config/.
Verified against HEAD (DI composition root, scope collapse, ent ORM) — 2026-06-22.
At a glance
Section titled “At a glance”| CLI | shc config |
| Subcommands | 5 (get, set, unset, ls (alias list), edit) |
| HTTP endpoints | see the route reference |
| Cross-module deps | auth (write handlers gated on platform:manage) |
Source layout
Section titled “Source layout”modules/config/├── register.go # module wiring — builds the cobra subtree + chi router├── service.go # Service — scoped DB-overlay reads/writes (ent ORM)├── daemon_client.go # CLI → daemon HTTP client for shc.config.* methods├── db_provider.go # global-tier DBProvider for the core/config koanf tree├── router.go # /api/method/shc.config.* handlers├── errors.go├── events.go├── models.go # ConfigModel row mapping (table "config")├── types.go # ConfigEntry, ListFilter, request/response shapes├── editor.go # `shc config edit` round-trip├── local_store.go├── state.go└── commands/ # get / set / unset / list (ls) / edit cobra leavesResponsibilities
Section titled “Responsibilities”The config module is the operator-facing CRUD for SHC’s
configuration cascade. It does not define the cascade itself (the scope
ladder lives in core/scope, and the file/global koanf tree in
core/config + default.yaml), nor does it know anything
about individual keys; it just reads and writes values scoped to one of
the five tiers global, tenant, environment, stack, or
deployment.
The HTTP API (/api/method/shc.config.{get,set,unset,list}) and CLI
(shc config {get,set,unset,ls,edit}) live here. The module’s Service
(constructed via NewService / NewServiceWithOptions and injected at
the DI composition root) writes scoped overlays to the database through
the ent ORM; core/config at runtime layers the global DB
tier on top of default.yaml values, while per-tenant/stack/environment/
deployment overrides resolve through Service.Get.
The cascade
Section titled “The cascade”Configuration loads in two layers:
File layer
Section titled “File layer”Loaded once at startup, lowest to highest priority:
default.yaml— bundled vendor defaults/etc/shc/config.yaml— system-wide config/etc/shc/config.d/*.yaml— system drop-ins (alphabetical)~/.config/shc/config.yaml— user config (non-root only)dev.yaml— developer overrides (dev mode only)
File-config changes require a server restart.
Database layer
Section titled “Database layer”Runtime, hot-reloadable, cascades by scope. Lowest to highest
specificity (the single ladder defined in core/scope):
- Global (base configuration)
- Tenant (tenant-specific overrides)
- Environment (tenant + environment)
- Stack (tenant + stack)
- Deployment (tenant + stack + environment — most specific)
Stack outranks Environment: a value set at (tenant, stack) wins over one
set at (tenant, environment). Service.Get walks this cascade
most-specific-tier first and returns the first hit (precedence ranks
0=Global … 4=Deployment per scope.Rank).
The global DB tier always overrides file config for overlapping
keys in the core/config tree; the narrower tiers are resolved
at query time through the scoped Service.Get read path.
Scopes
Section titled “Scopes”A row’s scope is not encoded into the key — it is determined by which
of the nullable tenant_name / stack_name / environment columns on
the config table are populated (ConfigModel.Scope() delegates to
scope.Classify). The five tiers, broadest to narrowest:
| Scope | Populated columns | scope.Rank |
|---|---|---|
global | (all NULL) | 0 |
tenant | tenant_name | 1 |
environment | tenant_name, environment | 2 |
stack | tenant_name, stack_name | 3 |
deployment | tenant_name, stack_name, environment | 4 |
The unique constraint is (path, tenant_name, stack_name, environment),
so one path may carry one row per tier.
If --scope is omitted on set/unset, the CLI derives the default
from the active context (defaultScope): stack when both tenant and
stack are set, tenant when only a tenant is set, otherwise global.
The --scope flag accepts global (alias base), tenant,
environment (alias env), stack, and deployment.
# Read (always merges all scopes)shc config get backup.retention_daysshc config get backup.retention_days --scope tenant # specific scope, no inheritanceshc config get # entire effective configshc config get --scope tenant
# Write (scope defaults from active context: stack > tenant > global)shc config set backup.retention_days 14shc config set backup.retention_days 14 --scope tenantshc config set backup.retention_days 14 --scope deploymentshc config unset backup.retention_days
# Vault and env referencesshc config set database.password 'ref+vault://db_password'shc config set smtp.api_key 'ref+env://SMTP_API_KEY?default=test-key'Value parsing
Section titled “Value parsing”Values are parsed as YAML types unless quoted:
| Input | Stored as |
|---|---|
true / false | boolean |
42, 3.14 | int / float |
[a, b], {k: v} | list / dict |
"true" | string "true" |
"0 2 * * *" | string (quote required for cron) |
'["data"]' | list (JSON form) |
Programmatic API
Section titled “Programmatic API”Two disjoint surfaces (B11 “no hybrids” — a key resolves from exactly one of them, never both):
Scoped reads — modules/config.Service
Section titled “Scoped reads — modules/config.Service”The module’s Service (injected at the DI composition root) owns the
scoped DB-overlay walk. Lookups take a scope.Ctx (the active
tenant/stack/environment) and the shared scope.Scope tier type.
import ( configmod "gitlab.com/bitspur/selfhosted-cloud/ce/modules/config" "gitlab.com/bitspur/selfhosted-cloud/ce/core/scope")
svc := configmod.NewService(db) // or NewServiceWithOptions(ServiceOptions{...})sc := scope.Ctx{Tenant: "acme", Stack: "web", Environment: "prod"}
// Effective value — walks Deployment > Stack > Environment > Tenant > Global,// returns the first hit (nil, nil on a miss).value, err := svc.Get(ctx, "backup.retention_days", sc)
// Exact scope, no precedence walk.value, err = svc.GetAtScope(ctx, "backup.retention_days", scope.Tenant, sc)
// Write / remove at a specific tier (rejects startup-only prefixes).err = svc.Set(ctx, "backup.retention_days", 14, scope.Stack, sc)ok, err := svc.Delete(ctx, "backup.retention_days", scope.Stack, sc)There is no package-level config.Get / GetScoped / Scope, and no
types.ScopeTenant / types.ScopeLocal — the scope ladder collapsed
into the single core/scope.Scope type
(Global/Tenant/Environment/Stack/Deployment); there is no
Local tier.
Ambient global tree — core/config
Section titled “Ambient global tree — core/config”The process-wide koanf tree carries the global DB tier plus the file
layers. It is a *Config value (not package-level functions) with typed
accessors and a secret-resolving read:
import "gitlab.com/bitspur/selfhosted-cloud/ce/core/config"
cfg, _ := config.Load(ctx, config.LoadOpts{ /* … */ })
host := cfg.String("server.host")n := cfg.Int("cluster.peer_timeout")
// Resolve secret references against the registered providers.value, err := cfg.GetResolved(ctx, "database.password")Secret references
Section titled “Secret references”Config values may reference external sources rather than store the literal:
| Provider | Syntax | Description |
|---|---|---|
vault | ref+vault://secret_key | Vault secret lookup (scope-resolved) |
env | ref+env://VAR?default=value | Environment variable on the server process |
Note: the env provider reads from the server process environment,
not the CLI user’s shell. For CLI-triggered operations that need
secrets, use the vault provider.
System vs scoped keys
Section titled “System vs scoped keys”A key can be system-only, scoped-only, or both:
| Setting | Scope level | Default |
|---|---|---|
server.* | system only | — |
cluster.peer_timeout | system only | 30 |
cluster.heartbeat_interval | system only | 5 |
cluster.ports.* | system only | — |
database.* | system only | — |
logging.level | system + scoped | INFO |
logging.format | system + scoped | json |
collector.* | startup-only | — |
backup.retention_days | scoped only | 7 |
backup.schedule | scoped only | — |
quota.disk_enabled | system only | false |
quota.* (limits) | scoped only | — |
dev.* | startup-only | — |
keycloak.* | system only | — |
System-only keys are loaded once at startup; setting them at a narrower
scope has no effect. Startup-only prefixes go further: Service.Set
rejects writes under dev, vault, collector, and rate_limit with
X400050CFGSTP — those layers must come from the yaml files. The
config keys reference
lists every key.
Vault path hierarchy
Section titled “Vault path hierarchy”Vault paths serialise the same scope ladder (modules/vault/paths.go,
BuildPath). Every key is rooted under the shc prefix; the broadest tier
(scope.Global) maps to the system segment, and the narrower tiers nest
under tenants/{tenant}/…:
shc/├── system/{key} # scope.Global└── tenants/{tenant}/ ├── {key} # scope.Tenant ├── environments/{env}/{key} # scope.Environment ├── stacks/{stack}/{key} # scope.Stack └── stacks/{stack}/environments/{env}/{key} # scope.Deployment| Path pattern | Scope | Operator-writable |
|---|---|---|
shc/system/* | global | no (server / startup-only) |
shc/tenants/{tenant}/* | tenant + narrower | yes (shc vault set) |