This page describes how the Admin Console, the Docker Bootstrap deployment layout, and EMS/Core relate to each other. It is the architecture reference for the Admin path. For the full Admin internals (wizard, release/build-identity gating, network discovery, Docker setup and security) see admin-discovery.md. For the user-facing guide see ../user/admin-console.md. For how Admin and EMS are resolved, aligned and installed as one paired system build, see system-build-pairing.md.
There are three layers, with clear ownership:
- Admin Console = UI / orchestration. The Admin Console (product name EMS SolarFlow Admin) is a local browser application that guides setup, device discovery, config generation, diagnostics, updates, backups and restore. It orchestrates EMS containers and calls EMS/Core tools. It does not run the control loop and it does not own config or backup semantics.
- Docker Bootstrap = standard deployment layout. The Docker Bootstrap path
owns the standard on-disk deployment layout (compose file,
config/,data/) that every path converges on. The Admin Console deploys onto this same layout. - EMS / Core = source of truth. EMS remains the source of truth. EMS owns the control loop, config schema and semantics, diagnostics, and backup/restore behavior. Every Admin Console backup is a normal EMS backup archive, and every config apply is validated by EMS/Core.
The key rule:
EMS remains the source of truth. The Admin Console is UI and orchestration only. The Docker Bootstrap layout is the standard deployment shape both paths share.
Because EMS/Core owns config, diagnostics and backup/restore semantics, the Admin Console never invents its own config format or its own backup format — it calls the same EMS tools a shell user would run directly.
The Admin Console is config-authoritative and does not reconcile the
runtime-state device lifecycle. The one exception is config → runtime
convergence: on maintenance Apply it mirrors the whitelisted overlapping scalar
keys it changed into runtime-state.json, but only through the EMS-owned
runtime-write whitelist (dashboard/runtime_write.py) — the same validated
writer the Dashboard uses — so it introduces no second runtime format and stays
inside the whitelist safety property. It already writes runtime-state.json
wholesale during restore.
All three operating models (Admin Console, Docker Bootstrap, Developer Setup) converge on the same standard layout, so an installation can move between them:
standard layout:
config/config.json EMS static config (source of truth)
data/ EMS runtime data (runtime state, history, analytics)
docker-compose.yml deployment definition
Additional EMS-owned paths under data/:
data/backups/ EMS backup archives
The Admin Console keeps its own orchestration state separate from EMS config and runtime data, under a dedicated directory:
admin state:
data/admin/ Admin Console state, release cache, staging and logs
data/admin/ holds Admin-only data (release cache, staging areas, Admin logs
and UI state). It is not EMS config and not part of the EMS control path.
Removing it does not change EMS behavior; it only resets Admin Console state.
Temporary workflow artifacts under data/admin/ have an owner and a lifecycle.
Guided Setup's are owned by admin/setup_workflow.py — the generated config
(generated/config.json plus its config.meta.json base-revision record) and
the deployment marker (state/.admin-deployment.json) — and are removed by
POST /api/setup/abandon, the single backend-owned reset behind the wizard's
"Start over". Browser state may render workflow state but is never the authority
for its cleanup. See
admin-workflow-state.md for the full inventory,
config write paths and transition matrix.
Each durable record keeps its own authority, and exactly one service reads them together:
| Authority | Owns |
|---|---|
| durable Guided Setup record | Setup identity, status, artifact claims, cleanup state, linked operation id |
| durable pending transition | System Build mode, operation id, stage |
| Guided Upgrade context | the operation-bound, secret-free upgrade execution context |
| Docker / EMS / live config | what is actually installed and running |
AdminWorkflowLifecycleService (admin/workflow_lifecycle.py) |
the only interpretation of those together: may a workflow resume, switch, cancel or be recovered |
The arbiter creates no durable "current workflow" record of its own. It
normalizes the existing authorities into one owner/state verdict, binds the
exact durable facts behind that verdict into a fingerprint, and delegates every
mutation to the owning service — Setup termination through the claim-aware
abandon, transition cancellation through SystemAlignmentService, context
clearing through clear_for_operation. One instance lives in AdminRuntime and
is shared by the HTTP and HTTPS listeners; nothing is constructed per request.
Cross-workflow decisions (which task may start, what a switch stops, whether a
recovery is safe) belong to that arbiter. Operation-specific validation stays in
the owning service: the transition store still decides whether now is a safe
moment to cancel, and _reject_unrelated_transition_write still gates
Maintenance writes on a pending transition.
No route may reach around it, and server.py keeps no owner classifier of its
own. POST /api/admin/start-path creates and resumes Guided Setup through
the arbiter, so an old console, a script or a retry cannot open a Setup beside a
live Guided Upgrade; the narrow system-alignment/cancel primitive asks the
arbiter who owns the transition before it cancels anything. Four rules follow from the
same principle, and each of them fails closed:
- contradictory durable owners are named, not resolved — two records
claiming the console report
workflow_owner_conflict; nothing is cancelled or cleaned until a previewed, confirmed switch or recovery says so; - a no-op never hides a block —
action: "none"may answer successfully only when the requested target is already safely in charge; - readable is not usable — an unsupported transition mode, a contradiction or an unreproducible upgrade context is advanced-recovery material with an exact reason, not a permanent deadlock;
- Docker unknown is not Docker inactive — releasing durable state requires a positive answer that no Admin replacement is running.
The Admin Console is protected by the shared EMS/Dashboard password — there is exactly one local password per host, and no Admin-only password store.
- Shared password file. Admin auth uses the shared dashboard auth file. The
path is resolved from the EMS install context (
EMS_INSTALL_DIR/ install root). A fresh install withoutconfig/config.jsonusesconfig/dashboard-auth.json; when a config exists and setsdashboard.auth_file, that path is honoured (relative paths resolve against the install root). - First visitor creates it. When no password exists, the first browser user
creates the initial password. Creation is atomic (
O_EXCL) so a second concurrent visitor gets a clean409instead of overwriting it. Until the password is set, every setup/maintenance/discovery API stays blocked. - Malformed file is a recovery state, not setup. If the shared file exists
but cannot be parsed,
auth/statusreportsrecovery_required(withauth_configured: true,requires_initial_password: false); the UI shows a repair panel instead of first-password setup.auth/setupandauth/loginreturn409 auth_file_invalidand the file is never auto-overwritten or auto-deleted — the operator repairs or removes it on the EMS host. - Shared password, separate sessions. Admin and the Dashboard share the
password but not the browser session: Admin uses its own
ems_admin_sessioncookie, so a Dashboard login does not grant Admin access and vice versa. - Standard web hardening. Authenticated mutating endpoints require the
session CSRF token (
X-CSRF-Token); read-only endpoints require a valid session. Logins are rate limited. The password is never logged or returned to the UI. No running EMS container is required for any of this.
The password file lives under the mounted install layout
(config/dashboard-auth.json), never only inside the Admin container image, so
EMS reuses the same password later.
The Admin Console sends browser hardening headers on every UI and API response (including auth failures and JSON errors):
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrer- a self-only Content Security Policy with
object-src 'none' Permissions-Policydisabling geolocation, microphone, camera, payment and USB
HTTPS is not forced by default because the Admin Console is designed for trusted
local networks and self-signed certificates are confusing for many users. HTTPS
is available as an optional parallel listener (below), never a forced redirect,
and no HSTS or upgrade-insecure-requests is added.
Admin HTTPS reuses the shared dashboard/https.py helper; certificate
generation and the SSLContext build live there and are never duplicated in
admin/. admin/https.py is a thin wrapper that only adds Admin default paths
and install-root resolution.
The default Admin certificate paths are:
config/admin.crtconfig/admin.key
They are resolved relative to the EMS install root, not the Admin container working directory. If no mounted install root is available, Admin refuses to generate a certificate rather than writing one into the read-only image.
When enabled (--https / EMS_ADMIN_HTTPS_ENABLED), Admin starts HTTP on 8090
and HTTPS on 8091 against the same AdminRuntime object, so auth sessions,
rate limiters, discovery/scan state, the mDNS provider, MQTT discovery and the
upgrade/backup job registries are shared and not duplicated across transports.
Only the per-listener transport flag differs: the HTTPS listener wraps its
socket with the SSLContext and sets https_active=True, which adds the
Secure attribute to Admin session cookies.
Admin and EMS are managed as one strict paired system build through
SystemAlignmentService (admin/system_alignment.py). The running Admin process
writes a staged transition, starts an updater outside the current HTTP request,
returns a reconnect response, and the replacement Admin resumes from
data/admin/state/. Reconnect proves only the Admin stage; it does not complete
the EMS operation or write known-good state.
One operation dispatches at most one replacement. The durable transition stage
decides that a replacement is expected; an exclusive per-operation claim
(ReplacementDispatchCoordinator, admin/replacement_dispatch.py) decides which
of several concurrent callers actually invokes the launcher — both listeners
share it through the single SystemAlignmentService in AdminRuntime. The
sidecar's own claim_admin_update() remains the durable guard inside the
replacement. See docs/technical/admin-workflow-state.md §5.5.
Admin image update decisions are made by digest/build identity, not by tag name alone.
Concretely:
- Server-side target. The browser only ever sends a release tag. The target
Admin image is derived server-side as
ghcr.io/basecubedev/ems-solarflow-admin:<tag>(admin/admin_update.py). The tag must match the strict release pattern (admin.releases.TAG_PATTERN), so an image reference, path, or shell metacharacter — even without whitespace (v0.7.0;rm,v0.7.0$(x),../v0.7.0) — is rejected. When a release catalogue is available the tag must also be a known, selectable release. The browser never supplies an image ref, a compose path, or a command. - Digest decision.
decide_admin_updatecompares the running Admin image identity (EMS_ADMIN_CONTAINER_NAME/EMS_ADMIN_IMAGE+EMS_ADMIN_TAG, plusdocker image inspect) against the target by digest. Equal digests mean the release only retagged an unchanged Admin image (no update). Unknown digests are treated as uncertain and require explicit confirmation. - Strict EMS-upgrade gate. Fresh Setup, Automated Setup and Guided Upgrade
resolve the same Admin/EMS pair and call
SystemAlignmentService. Config and EMS mutations remain blocked until the transition reachesresources_verified; an uncertain or mismatched Admin identity is a hard alignment failure, not a compatibility warning. - Pending state.
data/admin/state/pending-admin-update.jsonis written atomically (temp file + fsync + rename), tolerates a missing file, and surfaces a clear recovery error for corrupt JSON instead of crashing. It holds no passwords or secrets.executerefuses a no-op plan (update_required=false). - Out-of-request updater.
POST …/admin-update/executewrites the pending state, launches the updater, and returnsadmin_update_startedwith a reconnect hint before any pull or recreate. The default launcher (AdminUpdateLauncher) runs a detacheddocker run --rmsidecar built from the pending target image so the process runningdocker compose upis never the container being replaced; the same-process thread worker is opt-in viaEMS_ADMIN_UPDATE_LOCAL_WORKER=1(dev/tests). The updater pulls the target image, keeps the Admin image and tag metadata in sync (image:, composeEMS_ADMIN_TAG,.env.admin), and runsdocker compose … up -d --no-deps --force-recreateon the Admin compose service (EMS_ADMIN_COMPOSE_SERVICE, distinct from the inspect-onlyEMS_ADMIN_CONTAINER_NAME). - Resume. After the restart (which drops the in-memory Admin session), the new
Admin proves success by observing that its own running image now matches the
plan target, moves the pending state to
admin_update_succeeded, and the browser resumes the EMS upgrade via…/admin-update/resume(selecting the resumed release so a fresh plan cannot overwrite it).
The updater only touches the Admin image, the Admin compose/env tag, and the
Admin service. It never pulls or recreates the EMS/InfluxDB containers and never
touches EMS config or data — those changes belong to the Guided EMS Upgrade,
after user confirmation. All Admin update APIs require a valid Admin session and,
for POST, the X-CSRF-Token.
A resolved System Build has one compatibility mode
(admin/system_build.py: system_build_compatibility), decided purely by its
build-id kind, and one resource strategy derived from it
(system_build_resource_strategy):
modern_paired→embedded. A modern release/RC/latest/dev build ships a verified embedded resource bundle inside the running Admin image. The Admin is aligned to the selected build; Step 1 readiness requires the embedded bundle to verify.local→embedded. A local checkout bakes its own bundle and verifies it the same way.legacy_release→release_archive. A pre-contract CI build id (<run>-<attempt>, e.g.123456789-1) predates the embedded bundle and the modern transition/resume protocol. The running modern Admin is kept as the orchestration layer (never downgraded to the historical Admin image), and the selected EMS image's resources are prepared from the exact historical tag/revision throughReleaseManager.prepare(ReleaseArchiveResources) — never the running Admin's embedded bundle and nevermain.
validate() exposes resource_strategy, embedded_resources_applicable and an
embedded_resources_valid that is null (not false) when embedded resources
do not apply, so a legacy release is never blocked by a match it cannot satisfy.
Step 1 readiness gates on the selected strategy, so selecting a legacy release
never leaves both Update Admin Server and Continue disabled while reporting
ready.
Identity separation. For a legacy release the durable transition and the
known-good record store the running orchestrator Admin identity (modern)
separately from the selected EMS build (historical): the transition's
orchestrator_admin block and the known-good admin_* fields hold the modern
Admin, while the flat/ems_*/selected_ems_build fields hold the historical
EMS. Setup discovery authorization and resume compare the running Admin to the
orchestrator identity, not the selected build id. The legacy CI build id is
accepted by the transition parser on its validated format alone (the modern
revision-embedding integrity check still applies to modern build ids). Recovery
never downgrades the Admin to the historical release.
- A single source of truth (EMS/Core) means the Admin Console, the CLI and the
Docker Bootstrap path all produce and validate the same
config/config.json. - Orchestration bugs in the Admin Console cannot silently change control semantics, because those live in EMS/Core.
- Backups and restores behave identically whether triggered from the Admin
Console or the CLI, because both use the EMS backup/restore implementation.
Bundled InfluxDB restore is the clearest case: the Admin Console orchestrates
the existing EMS CLI restore flow (
emsctl.py backup restore) instead of implementing a separate InfluxDB restore engine, and never pushes an InfluxDB archive through the generic file restore path.
- admin-discovery.md — full Admin Console technical reference (wizard, release/build identity, discovery, Docker setup, security).
- architecture.md — EMS project structure and runtime component boundaries.
- backup-restore.md — EMS backup/restore internals.
- configuration.md — EMS config schema and safety flags.