You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Implement @mieweb/os-cloud-provider: a DeployProvider for os.mieweb.org #475
mieweb/cloud now defines a provider-agnostic deploy contract (@mieweb/deploy-contract, added in mieweb/cloud#14) that @mieweb/cli drives for the deploy lifecycle (deploy/dev/tail/login/logout/whoami/destroy). Cloudflare is the reference implementation (@mieweb/deploy-wrangler, which wraps wrangler). This issue tracks building the opensource-server provider — the implementation of that same interface that provisions/updates a container on a site via this repo's Manager API.
This is the concrete follow-up to the design in mieweb/cloud#5 (the mieweb/os.mieweb.org target). The provider is what makes mieweb deploy --target mieweb actually talk to the Manager.
Package:@mieweb/os-cloud-provider, living in this repo so it can import the Manager's own OpenAPI-derived types and stay in lockstep with the API. It depends on @mieweb/deploy-contract (see §1). The CLI resolves it from the app's node_modules (via targets.mieweb.provider), so it does not need to be a dependency of @mieweb/cli.
Language & build: authored in TypeScript, run directly by modern Node's native TypeScript at dev time — no dev build step. This requires type-strippable (erasable) syntax only: no enum, namespace, parameter properties, or const enum; use import type / verbatimModuleSyntax and .ts extensions on relative imports. The published package is compiled with tsc to ESM .js + .d.ts (build only at publish time). Verified: Node ≥ 22 runs type-strippable .ts directly (unflagged on Node 24).
v1 blockers (all in §4): the converged app image (images/cloud, §4.1) and the loopback login route (§4.3) are net-new Manager work owned by this issue; the persistent container volume is split out into #421 (§4.2) and must land before the converged deploy has durable state. Read §4 first if you're scoping effort.
The contract we must implement
@mieweb/deploy-contract exports a single interface. Verbatim shape (from packages/deploy-contract/src/index.ts at mieweb/cloud@HEAD):
interfaceDeployProvider{readonlyname: string;// 'opensource-server'supports(target: DeployTarget): boolean;// true for 'mieweb'deploy(ctx: DeployContext): Promise<DeployResult>;// REQUIREDdev?(ctx: DeployContext): Promise<DeployHandle>;tail?(ctx: DeployContext): Promise<void>;destroy?(ctx: DeployContext): Promise<void>;whoami?(ctx: DeployContext): Promise<AuthStatus>;login?(ctx: DeployContext): Promise<void>;logout?(ctx: DeployContext): Promise<void>;}// Provider module exports a factory (we use this form — see §3):typeDeployProviderFactory=(env: ProviderEnv)=>DeployProvider;// ProviderEnv = Readonly<Record<string,string|undefined>>interfaceDeployContext{readonlyroot: string;// repo rootreadonlytarget: DeployTarget;// active target namereadonlymanifest: Readonly<Record<string,unknown>>;// parsed wrangler.jsonc (opaque)readonlymanifestPath?: string;// absolute path to it, if on diskreadonlymieweb: Readonly<Record<string,unknown>>;// parsed mieweb.jsonc (non-secret)readonlytargetConfig: Readonly<Record<string,unknown>>;// mieweb.jsonc → targets[target] (NON-SECRET, recursively redacted by the CLI)readonlyargv: readonlystring[];// passthrough args after the verbreadonlylogger: DeployLogger;// info/warn/error — provider MUST use this, not console.*readonlysignal: AbortSignal;// cancellation}interfaceDeployResult{readonlyurl?: string;readonlyresources: readonlyResourceHandle[];}interfaceResourceHandle{readonlybinding: string;readonlykind: ResourceKind;readonlyid: string;}// id is OPAQUE to the CLI// ResourceKind ∈ 'database'|'bucket'|'kv'|'queue'|'vector'|'stateful'|'ai'|'container'interfaceAuthStatus{readonlyauthenticated: boolean;readonlyaccount?: string;readonlymethod?: string;}classAuthErrorextendsError{constructor(provider: string,target: DeployTarget,hint?: string);}interfaceDeployHandle{readonlyurl?: string;readonlyclosed?: Promise<void>;stop(): void|Promise<void>;}
Hard contract obligations (the CLI + conformance test-kit enforce these):
Secrets never come from DeployContext.targetConfig/manifest are non-secret (the CLI recursively redacts targetConfig before we see it). Our API token comes from createProvider(env) (the environment) or the machine-local cache written by login — see §3.
Throw AuthError (not a bare Error) on backend 401/403 so the CLI shows a login hint.
Use ctx.logger, not console.*, for our own output.
ResourceHandle.id is opaque — we choose the string; the CLI just reports it (write-back into the manifest is a future CLI feature, so don't rely on it being persisted yet).
deploy must be idempotent — re-running converges to the same container (see §2, hostname-keyed upsert).
1. How to require the contract package today (it is NOT on npm yet)
⚠️Reality check — do not write npm install @mieweb/deploy-contract and expect it to resolve. As of today:
@mieweb/deploy-contract@0.2.1 is not published to npm (npm view @mieweb/deploy-contract → 404). Its sibling @mieweb/cloud@0.2.1is published, but the contract isn't — it will only hit npm after feat(deploy): provider contract + wrangler reference provider cloud#14 merges and the Changesets/OIDC release workflow runs.
The repo root (mieweb-cloud) is private: true, and the package lives in the subdirectorypackages/deploy-contract. Plain npm install github:mieweb/cloud would try to install the private root, not the subpackage, and npm proper cannot install a subdirectory of a git repo.
The package has no build step: files: ["src"] and exports point straight at src/*.mjs (runtime) + src/*.ts/*.d.ts/*.d.mts (types). The install ships src/ verbatim — nothing to compile.
Install method: pnpm subdir git dependency, pinned to a SHA
pnpm natively supports installing a subdirectory of a git repo via its path: parameter (no third-party service). Pin a commit SHA for reproducibility. The whole team already uses pnpm (this repo + mieweb/cloud), so this is the path — do not use gitpkg or expect npm's (nonexistent) #path: support.
The full-URL form git+https://github.com/mieweb/cloud.git#<sha>&path:/packages/deploy-contract resolves identically under pnpm; use the shorthand above. Pin the SHA (not main) so builds are reproducible. Once the contract is published to npm, this specifier collapses to a plain range ("^0.2.1"); flip it deliberately (tracked in Acceptance).
Import shape
// runtime values (plain ESM — loads under bare node, no TS loader)import{AuthError,RESOURCE_KINDS}from'@mieweb/deploy-contract';// typesimporttype{DeployProvider,DeployContext,DeployResult}from'@mieweb/deploy-contract';// conformance test-kitimport{runProviderConformance}from'@mieweb/deploy-contract/testkit';
The contract package targets bare node (no build) and strict node16/nodenext type resolution — both verified in mieweb/cloud#14 — so it consumes cleanly from a native-TS provider.
2. Mapping the contract to existing OpenAPI operations
The provider's job: given ctx (a parsed wrangler.jsonc + non-secret targetConfig), converge a single converged container per app on a site (the model chosen in mieweb/cloud#5, Option A) and expose the app publicly. Everything below uses real operationIds from this repo's OpenAPI spec.
Identity & idempotency — hostname is the key
Per mieweb/cloud#5: read name from wrangler.jsonc (must be a valid DNS label) and use it as the container hostname, which the Manager enforces unique per site. So deploy is a hostname-keyed upsert:
list_containers — GET /sites/{siteId}/containers?hostname=<name> (the query param is literally hostname).
If a match exists → update_container (PUT /sites/{siteId}/containers/{id}); else → create_container (POST /sites/{siteId}/containers).
siteId is required in config — mieweb.jsonc → targets.mieweb.siteId (an integer). The provider reads it from ctx.targetConfig.siteId and errors clearly if absent; it does not guess a site by name.
Hostname uniqueness is enforced per site at the DB level — a unique index on (siteId, hostname) (verified in models/container.js), and hostname is DNS-label-validated by the same model (^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$). So the list→create/update upsert is safe, and under concurrent deploys of the same hostname a create can lose the race and return 409 conflict (SequelizeUniqueConstraintError). The provider must treat a create 409 as "it now exists" → re-list_containers and update_container instead (retry-as-update), keeping the upsert idempotent.
Verb → operation table
Contract verb
Manager operation(s)
Notes
deploy
list_containers → create_container | update_container; then poll get_job (GET /jobs/{id}) for the returned jobId/creationJobId until Job.status is terminal
200 → { deleted, dnsWarnings[] }; 409 hostname_mismatch must surface as an error.
whoami
get_session (GET /session) with the bearer key → { user, isAdmin }
map to AuthStatus; see §3.
login / logout
see §3 (interactive loopback handoff) — mints/revokes an API key via create_api_key / delete_api_key.
dev
omitted — no remote analogue; the CLI degrades gracefully. Local dev uses the existing local/mieweb host-harness path, not this provider.
tail
omitted for v1 — the Manager streams only job output today (stream_job_output), not running-app stdout/stderr; the CLI reports tail unsupported. Revisit when a container-logs stream exists.
Building the create_container / update_container body from ctx
hostname = wrangler.jsoncname (validate DNS-label; the CLI rejects non-conforming names).
template = the converged app image built in this repo (§4). Configurable via mieweb.jsonc → targets.mieweb.image, defaulting to ghcr.io/mieweb/opensource-server/cloud:latest when unset. Because the CI tag scheme also publishes :<branch> and :<sha> (see §4), a developer points image at a pre-release tag to test before :latest exists. The provider reports the resolved image reference in its output today; once the CLI's manifest write-back lands (a mieweb/cloud follow-up), that reported value is persisted for reproducibility.
⚠️template/image is a create-only field — update_container cannot change it (verified: the PUT accepts only username/services/environmentVars/entrypoint/restart). See the image-change rule in On update_container below.
services = keyed map of ServiceInput. Expose the app with one http service — { type: 'http', internalPort: <app port>, externalHostname: '<name>.<domain>', externalDomainId: <id>, authRequired: <bool> }. The app serves plain HTTP internally; the Manager's nginx fronts it and terminates TLS externally (ACME), per the os.mieweb.org integration: architecture for the mieweb target on opensource-server cloud#5 "only the app is exposed; nginx + TLS in front" model. externalDomainId is resolved via get_new_container_form (which returns { externalDomains, nvidiaAvailable } for exactly this). Any SRV/TCP/UDP services the app declares map to type: 'srv'|'tcp'|'udp'.
nvidiaRequested = derive from the app (e.g. presence of an AI binding), since node placement is automatic (there is no node-selection field; create_container returns 409 no_node/no_nvidia_node if unplaceable). Create-only — like template, it is not accepted by update_container, so changing it also requires delete+recreate.
On update_container — semantics & the image-change rule (important)
update_container accepts onlyusername, services, environmentVars, entrypoint, and restart (verified against openapi.v1.yaml / routers/api/v1/containers.js). It does not accept template/image, nvidiaRequested, or collaborators. Consequences:
Image change ⇒ delete+recreate, not update. When a redeploy changes the resolved image (e.g. a new converged-base tag) — or needs to change nvidiaRequested — the provider cannot update in place: it must delete_container then create_container. This is safe because the data volume is retained on delete (the retain-on-delete behavior specified in Support user-defined shared volumes for containers (replace hardcoded quick_and_dirty mp0) #421): the recreated container on the same hostname reattaches the same host directory, so datastore state survives. Same image + no nvidia change ⇒ ordinary in-place update_container.
environmentVars on update is a full replacement set — "Omitting it clears all user env vars." Always send the complete desired env, not a delta.
services on update is a keyed map of ServiceUpdate = ServiceInput + { id?, deleted? }. To reconcile: entries with an existing id update in place; { id, deleted: true } removes a service; entries without id create one. Diff the container's current services (read via get_container) against desired and emit the right add/update/delete set.
Config changes alone do not restart the container — pass restart: true when a redeploy must take effect immediately.
Producing DeployResult
url = the app's public URL — read from Container.httpEntries[].externalUrl (a https://<externalHostname>.<domain> value) on the same post-job get_container read-back.
resources = one ResourceHandle for the converged container: { kind: 'container', binding: '<name>', id: '<vmid>' }. ⚠️Two different containerIds exist — create_container's response containerId is the Manager DB row id, whereas the Container schema's containerId (populated after provisioning) is the Proxmox VMID / Docker id. Use the latter for the opaque handle: after the create/update job completes, GET the container (get_container) and read its containerId (VMID). The CLI treats id as opaque; the stable backend VMID lets a future deploy short-circuit.
Response/async cross-cutting facts
Success responses are wrapped { "data": ... }; errors are { "error": { code, message } } (codes: unauthorized, site_not_found, no_node, hostname_mismatch, conflict, already_reviewed, …). Map 401/403 → AuthError; map others → a descriptive Error via ctx.logger.error + throw.
create/update/delete are async — they return job ids. Poll get_job (GET /jobs/{id}) for Job.status (pending|running|success|failure|cancelled) — that is the terminal verdict. ⚠️get_job_status (GET /jobs/{id}/status) returns output log rows, not a status verdict; use it (or stream_job_output) only for logs. Treat failure/cancelled as a failed deploy (surface via ctx.logger.error + throw); respect ctx.signal while polling.
3. Authentication: env-token (non-interactive) + interactive web login
The Manager's auth model (verified against the spec): machines use BearerAuth API keys; interactive humans use a browser session (username/password login, or oidc_login→oidc_callback which sets the connect.sid cookie). There is no device-code flow in the API today. Both flows below therefore land on an API key the provider uses as a Bearer token. Verified in middlewares/api.js: apiAuth accepts a Bearer API key (looked up by keyPrefix, verified via validateKey) on all container CRUD routes, and CSRF is skipped for pure-Bearer requests (no session cookie) — so the provider sends only Authorization: Bearer <key>, no CSRF token.
Where credentials & instance URL come from (fixed precedence)
Secrets come from the environment (or the login cache), surfaced via the createProvider(env) factory — never from ctx.targetConfig.
API token resolution order: env.MIEWEB_OS_TOKEN → the machine-local login cache for the active instance. (No config fallback — a token is never read from targetConfig/manifest.)
exportfunctioncreateProvider(env: ProviderEnv): DeployProvider{consttoken=env.MIEWEB_OS_TOKEN;// else the login cache (resolved per verb, per instance)constinstanceUrl=env.MIEWEB_OS_URL;// else targetConfig.instanceUrl, else the defaultreturnmakeOsProvider({ token, instanceUrl });}
A user mints an API key once via create_api_key (POST /apikeys → data.keyplaintext, returned exactly once; thereafter only keyPrefix is stored). (The UI exposes the same operation.)
They export MIEWEB_OS_TOKEN=<key> (and, for local dev, MIEWEB_OS_URL=http://localhost:3000 — see §5), or set targets.mieweb.instanceUrl for the URL.
The provider sends Authorization: Bearer <key> on every Manager call.
Any 401/403 from a verb → throw AuthError('opensource-server', ctx.target, 'set MIEWEB_OS_TOKEN, or run mieweb login --target mieweb').
(b) Interactive web login — loopback handoff (login/logout)
Modeled on wrangler login: a browser establishes a Manager session, and a loopback redirect returns a freshly-minted API key to the CLI (this needs a small new Manager route — see §4).
login starts a temporary http://127.0.0.1:<port>/callback listener, reads GET /health → oidcEnabled to decide the entry point, and opens the browser at the new CLI-auth route (§4.3) carrying the loopback port + a one-time state (OIDC-enabled → it first bounces through GET /auth/oidc/login; otherwise the password login page). The user authenticates the normal way, establishing a connect.sid session. ⚠️ The existing login/OIDC redirect param cannot carry the loopback: safeRedirectUrl (in routers/api/v1/auth.js) allowlists only / and configured ExternalDomain names and silently falls back to / otherwise — so a 127.0.0.1 target is rejected. The loopback is therefore owned entirely by the new §4.3 route, which does its own strict loopback validation.
Once the session exists, the §4.3 route mints a new API key and redirects back to the loopback with that key. The provider captures it from the loopback request.
Persist { instanceUrl, token, apiKeyId } to a machine-local store (~/.mieweb/os.json, keyed by instance — mirroring wrangler's ~/.wrangler cache). The verbs read it back when MIEWEB_OS_TOKEN is unset.
logout → delete the cached token and delete_api_key (DELETE /apikeys/{id}) to revoke it server-side; warn if MIEWEB_OS_TOKEN is set in the env (it takes precedence and can't be cleared by logout) — same pattern @mieweb/deploy-wrangler uses for CLOUDFLARE_API_TOKEN.
Multi-instance & the login URL. The instance URL is location, not identity — the cache is keyed by instance so a user can be logged into os.mieweb.org and a self-hosted os.acme.internal simultaneously. For login, the instance URL resolves in this order: --instance <url> (passthrough ctx.argv) → ctx.targetConfig.instanceUrl → an interactive prompt.
4. New Manager-side work & supplemental requirements
Two items are net-new Manager work owned by this issue and each has a build strategy below: the converged image (§4.1) and the loopback login route (§4.3). The persistent volume (§4.2) is split out into #421 and blocks v1 from there. The remaining items (§4.4) are audits.
4.1 Converged app image — new bake target images/cloud (required for v1)
⚠️ghcr.io/mieweb/opensource-server/cloud does not exist today. There is no Dockerfile bundling MinIO + libsqld + Valkey, and the repo's images are systemd-based LXC images (the Manager itself uses PostgreSQL, not libSQL). This is sizable net-new work.
Build strategy:
Base & supervisor. Add images/cloud/Dockerfile with FROM the existing base image (the Proxmox Debian-13 LXC rootfs whose entrypoint is /sbin/init). Because the base runs systemd, each co-located service is a systemd unit — do not introduce s6/supervisord. Ship four units: minio.service, libsqld.service, valkey.service, and app.service (the deployed worker), with app.service ordered After= the three datastores.
Service install. Install MinIO, libsqld (libSQL server), and Valkey binaries in the image; bind each to 127.0.0.1 on its conventional port (per os.mieweb.org integration: architecture for the mieweb target on opensource-server cloud#5: MinIO :9000, libsqld :8080, Valkey :6379). Credentials are injected at deploy time via the container's environmentVars (§2) and read by the units at start; nothing is baked into the image.
Data paths on the persistent mount. Point each datastore's data dir at the persistent volume mount (§4.2 → Support user-defined shared volumes for containers (replace hardcoded quick_and_dirty mp0) #421) (e.g. /mnt/data/minio, /mnt/data/libsql, /mnt/data/valkey) so state survives container replacement. The app itself is delivered inside this image (no per-app image build for v1).
Bake + CI wiring. Add a target "cloud" to images/docker-bake.hcl and a corresponding docker/metadata-action + bake-file entry in .github/workflows/build-images.yml, mirroring the existing targets. It then inherits the repo's tag scheme automatically: type=sha, type=ref,event=branch, and type=raw,value=latestonly on a non-prerelease published release.
Release consequence.:latest is not deployable until the next release — which is fine, since that same release ships the new API handlers this issue needs. Pre-release testing uses the :<branch>/:<sha> tags via targets.mieweb.image (§2).
Visibility. The GHCR package must be public (see 4.4) — the node pulls anonymously.
4.2 Persistent volume for the converged container — blocked by #421
The converged container co-locates MinIO/libSQL/Valkey state inside it, so it needs a read-write persistent volume that survives delete+recreate on the same hostname (the property the image-change redeploy in §2 relies on). Today there is no container-volume feature: the create path only attaches a hardcoded read-only quick_and_dirty bind mount, and Proxmox has no mkdir API, so a per-container RW host directory can't be provisioned.
This is split out into #421 (Support user-defined shared volumes for containers), which now carries the full design: host-path bind mount with the site agent pre-creating the directory, a per-Volume.statuscreate-job↔agent sync barrier, the shared-storage requirement + node-config-save warning, quick_and_dirty migrated into a first-class Volume model, retain-on-delete, and backup documented as out of scope. See #421 for the settled facts and deliverables.
This provider (v1) depends on #421 landing. From the provider's side the integration is just: request one rw volume mounted at /mnt/data on the create/update volumes array (once #421 exposes it), and let §4.1's systemd units point their data dirs there. Until #421 ships, the converged deploy has no durable datastore state.
4.3 Loopback login handoff (required for §3b)
A new auth route that, after a browser session is established, mints a new API key and redirects to a CLI-provided http://127.0.0.1:<port> loopback with that key — the basis for mieweb login --target mieweb.
Build strategy:
Route. Add e.g. GET /auth/cli/callback?port=<loopback-port>&state=<nonce> that requires an authenticated session (connect.sid); the CLI's browser hand-off lands here after the normal login/oidc/callback completes. If the request is unauthenticated, the route bounces the user through the standard login first (carrying its own params through the OIDC session, not through the allowlisted redirect).
Mint & redirect. On hit with a valid session, call the same code path as create_api_key to mint a key scoped to the authenticated session's user (description like mieweb-cli@<hostname>), then 302 to http://127.0.0.1:<port>/callback#key=<plaintext>&state=<nonce> (key in the fragment, so it isn't logged as a query or forwarded upstream).
This route does NOT use safeRedirectUrl. That helper only permits / + configured ExternalDomain names and would reject the loopback. This route does its own strict validation instead: accept a port only, hard-code the host to 127.0.0.1/::1, and echo the CLI-supplied one-time state so a logged-in user can't be tricked into minting a key for someone else's listener. Do not generalize the app-wide redirect allowlist to include loopback.
CLI side. The provider's login (§3b) starts the loopback listener (bound to 127.0.0.1 only), opens the browser at /auth/cli/callback?port=…&state=…, captures the key from the loopback request's URL fragment, verifies the returned state, and stores it (§3b step 3).
4.4 Audits (not large changes)
externalDomainId discovery for non-admins — list_external_domains/create_external_domain are admin-only, but get_new_container_form (GET /sites/{siteId}/containers/new) returns the usable externalDomains list to any container creator. Confirm that's sufficient for a non-admin deploy to pick an externalDomainId; if not, expose a non-admin domain-list read.
Confirm non-admin deploy works end-to-end — creating a container, attaching an HTTP service to an existing external domain, and setting env vars should all be doable by a normal (non-admin) user for their own hostname. Audit the 403s (create_container for-other-user, external-domains admin-gate) to be sure the self-service path is unobstructed.
Anonymous image pull — the node pulls the template image via Proxmox from public GHCR anonymously (utils/docker-registry.js implements only anonymous Bearer-token auth; there are no registry credentials). Ensure ghcr.io/mieweb/opensource-server/cloud is public, or the deploy will fail to pull. (See the related metadata/air-gap caveat in Support air-gapped container creation using locally cached OCI images #454.)
Deferred (not v1): resource sizing on deploy via create_resource_request / get_effective_resources (the converged container is heavy; defaults are memory 4096 MB, swap 0, cpus 4, rootfs 50 GB). v1 uses defaults; app-driven sizing is a follow-up.
5. Testing against a locally-running control plane
Two loops, both reusable going forward:
Inner loop — make dev + DummyApi (fast, no Proxmox).make dev (top-level, delegating to create-a-container/) runs the Manager on SQLite + the DummyApi mock hypervisor at http://localhost:3000, with a seeded localhost site + dummy node. Point the provider at it (MIEWEB_OS_URL=http://localhost:3000, mint a key via create_api_key, set targets.mieweb.siteId to the seeded site), and run mieweb deploy --target mieweb. The create flow (POST /sites/:id/containers → job-runner → bin/create-container.js → DummyApi) exercises the provider's API mapping + job polling end-to-end without Proxmox or a real converged image. This is the primary provider dev/CI loop.
Gated integration — full compose stack (real Proxmox). The top-level compose.yml brings up real Proxmox-in-Docker (privileged) + the node/client/mcp watchers, and pulls images anonymously from public GHCR. For a true end-to-end (real container creation + image pull + the RW data mount from Support user-defined shared volumes for containers (replace hardcoded quick_and_dirty mp0) #421), stage the locally-built images/cloud into the Proxmox template cache using the existing local-first pattern (skopeo copy docker-daemon:… || skopeo copy docker://…, as the compose pull-image service does), then deploy. Keep this behind a flag/CI-gate — it's heavy and privileged.
The conformance suite (runProviderConformance from @mieweb/deploy-contract/testkit) runs structurally offline; its live/handle-stability path runs against the DummyApi Manager with an applyIds hook.
Acceptance criteria
@mieweb/os-cloud-provider package created in this repo (TypeScript, native-TS dev / tsc-built on publish), exporting createProvider(env), importing @mieweb/deploy-contract via the pnpm subdir git dependency pinned to a SHA (§1).
supports() returns true for the 'mieweb' target.
deploy reads siteId from targets.mieweb.siteId and image from targets.mieweb.image (default ghcr.io/mieweb/opensource-server/cloud:latest), implements the hostname-keyed upsert (§2) with one http service, polls the job to completion honoring ctx.signal, and returns a DeployResult with url + a containerResourceHandle whose id is the Proxmox VMID (the Container.containerId read back via get_container after the job succeeds — not the create response's DB-id containerId).
destroy, whoami, login, logout implemented per §2–§3. dev and tail are intentionally omitted (documented).
Env-token auth (§3a) works; interactive login (§3b) works via the loopback handoff once the Manager route (§4.3) exists.
Secrets are read only from createProvider(env) / the machine-local cache — never from ctx.targetConfig/ctx.manifest. AuthError thrown on 401/403.
Passes runProviderConformance from @mieweb/deploy-contract/testkit (structural always; live behind a flag against the make dev Manager + an applyIds hook).
Green inner-loop test against make dev/DummyApi at http://localhost:3000; gated full-stack integration documented (compose + skopeo-staged images/cloud).
A mieweb.jsonc example documenting targets.mieweb = { provider: "@mieweb/os-cloud-provider", siteId: <int>, instanceUrl: "https://os.mieweb.org", image?: "…" } and the MIEWEB_OS_TOKEN/MIEWEB_OS_URL env vars.
The §4 blockers landed: images/cloud bake target (§4.1) building + pushing to public GHCR; loopback login route (§4.3); non-admin deploy path confirmed (§4.4).
Existing image CI + tag scheme: .github/workflows/build-images.yml, images/docker-bake.hcl.
Container CRUD contract (verified field lists, the two containerIds, dnsWarnings, httpEntries): create-a-container/routers/api/v1/containers.js, create-a-container/openapi.v1.yaml.
Context
mieweb/cloudnow defines a provider-agnostic deploy contract (@mieweb/deploy-contract, added in mieweb/cloud#14) that@mieweb/clidrives for the deploy lifecycle (deploy/dev/tail/login/logout/whoami/destroy). Cloudflare is the reference implementation (@mieweb/deploy-wrangler, which wrapswrangler). This issue tracks building the opensource-server provider — the implementation of that same interface that provisions/updates a container on a site via this repo's Manager API.This is the concrete follow-up to the design in mieweb/cloud#5 (the
mieweb/os.mieweb.org target). The provider is what makesmieweb deploy --target miewebactually talk to the Manager.Package:
@mieweb/os-cloud-provider, living in this repo so it can import the Manager's own OpenAPI-derived types and stay in lockstep with the API. It depends on@mieweb/deploy-contract(see §1). The CLI resolves it from the app'snode_modules(viatargets.mieweb.provider), so it does not need to be a dependency of@mieweb/cli.Language & build: authored in TypeScript, run directly by modern Node's native TypeScript at dev time — no dev build step. This requires type-strippable (erasable) syntax only: no
enum,namespace, parameter properties, orconst enum; useimport type/verbatimModuleSyntaxand.tsextensions on relative imports. The published package is compiled withtscto ESM.js+.d.ts(build only at publish time). Verified: Node ≥ 22 runs type-strippable.tsdirectly (unflagged on Node 24).v1 blockers (all in §4): the converged app image (
images/cloud, §4.1) and the loopback login route (§4.3) are net-new Manager work owned by this issue; the persistent container volume is split out into #421 (§4.2) and must land before the converged deploy has durable state. Read §4 first if you're scoping effort.The contract we must implement
@mieweb/deploy-contractexports a single interface. Verbatim shape (frompackages/deploy-contract/src/index.tsat mieweb/cloud@HEAD):Hard contract obligations (the CLI + conformance test-kit enforce these):
DeployContext.targetConfig/manifestare non-secret (the CLI recursively redactstargetConfigbefore we see it). Our API token comes fromcreateProvider(env)(the environment) or the machine-local cache written bylogin— see §3.AuthError(not a bareError) on backend 401/403 so the CLI shows a login hint.ctx.logger, notconsole.*, for our own output.ctx.signal(abort in-flight Manager calls / job polling).ResourceHandle.idis opaque — we choose the string; the CLI just reports it (write-back into the manifest is a future CLI feature, so don't rely on it being persisted yet).deploymust be idempotent — re-running converges to the same container (see §2, hostname-keyed upsert).1. How to require the contract package today (it is NOT on npm yet)
npm install @mieweb/deploy-contractand expect it to resolve. As of today:@mieweb/deploy-contract@0.2.1is not published to npm (npm view @mieweb/deploy-contract→ 404). Its sibling@mieweb/cloud@0.2.1is published, but the contract isn't — it will only hit npm after feat(deploy): provider contract + wrangler reference provider cloud#14 merges and the Changesets/OIDC release workflow runs.mieweb-cloud) isprivate: true, and the package lives in the subdirectorypackages/deploy-contract. Plainnpm install github:mieweb/cloudwould try to install the private root, not the subpackage, and npm proper cannot install a subdirectory of a git repo.files: ["src"]andexportspoint straight atsrc/*.mjs(runtime) +src/*.ts/*.d.ts/*.d.mts(types). The install shipssrc/verbatim — nothing to compile.Install method: pnpm subdir git dependency, pinned to a SHA
pnpm natively supports installing a subdirectory of a git repo via its
path:parameter (no third-party service). Pin a commit SHA for reproducibility. The whole team already uses pnpm (this repo + mieweb/cloud), so this is the path — do not usegitpkgor expect npm's (nonexistent)#path:support.The full-URL form
git+https://github.com/mieweb/cloud.git#<sha>&path:/packages/deploy-contractresolves identically under pnpm; use the shorthand above. Pin the SHA (notmain) so builds are reproducible. Once the contract is published to npm, this specifier collapses to a plain range ("^0.2.1"); flip it deliberately (tracked in Acceptance).Import shape
The contract package targets bare
node(no build) and strictnode16/nodenexttype resolution — both verified in mieweb/cloud#14 — so it consumes cleanly from a native-TS provider.2. Mapping the contract to existing OpenAPI operations
The provider's job: given
ctx(a parsedwrangler.jsonc+ non-secrettargetConfig), converge a single converged container per app on a site (the model chosen in mieweb/cloud#5, Option A) and expose the app publicly. Everything below uses real operationIds from this repo's OpenAPI spec.Identity & idempotency — hostname is the key
Per mieweb/cloud#5: read
namefromwrangler.jsonc(must be a valid DNS label) and use it as the containerhostname, which the Manager enforces unique per site. Sodeployis a hostname-keyed upsert:list_containers—GET /sites/{siteId}/containers?hostname=<name>(the query param is literallyhostname).update_container(PUT /sites/{siteId}/containers/{id}); else →create_container(POST /sites/{siteId}/containers).siteIdis required in config —mieweb.jsonc→targets.mieweb.siteId(an integer). The provider reads it fromctx.targetConfig.siteIdand errors clearly if absent; it does not guess a site by name.Hostname uniqueness is enforced per site at the DB level — a unique index on
(siteId, hostname)(verified inmodels/container.js), andhostnameis DNS-label-validated by the same model (^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$). So the list→create/update upsert is safe, and under concurrent deploys of the same hostname a create can lose the race and return 409conflict(SequelizeUniqueConstraintError). The provider must treat a create 409 as "it now exists" → re-list_containersandupdate_containerinstead (retry-as-update), keeping the upsert idempotent.Verb → operation table
deploylist_containers→create_container|update_container; then pollget_job(GET /jobs/{id}) for the returnedjobId/creationJobIduntilJob.statusis terminalcreate_container201 →{ containerId, jobId, hostname, status };update_container200 →{ containerId, jobId?, dnsWarnings[], pendingRestart, message }.destroydelete_container(DELETE /sites/{siteId}/containers/{id}){ deleted, dnsWarnings[] }; 409hostname_mismatchmust surface as an error.whoamiget_session(GET /session) with the bearer key →{ user, isAdmin }AuthStatus; see §3.login/logoutcreate_api_key/delete_api_key.devlocal/miewebhost-harness path, not this provider.tailstream_job_output), not running-app stdout/stderr; the CLI reportstailunsupported. Revisit when a container-logs stream exists.Building the
create_container/update_containerbody fromctxhostname=wrangler.jsoncname(validate DNS-label; the CLI rejects non-conforming names).template= the converged app image built in this repo (§4). Configurable viamieweb.jsonc→targets.mieweb.image, defaulting toghcr.io/mieweb/opensource-server/cloud:latestwhen unset. Because the CI tag scheme also publishes:<branch>and:<sha>(see §4), a developer pointsimageat a pre-release tag to test before:latestexists. The provider reports the resolved image reference in its output today; once the CLI's manifest write-back lands (a mieweb/cloud follow-up), that reported value is persisted for reproducibility.template/imageis a create-only field —update_containercannot change it (verified: the PUT accepts onlyusername/services/environmentVars/entrypoint/restart). See the image-change rule in Onupdate_containerbelow.environmentVars= array of{ key, value }(Containerread shape returns an object map — handle both). Service credentials for the co-located MinIO/libSQL/Valkey are set here per os.mieweb.org integration: architecture for themiewebtarget on opensource-server cloud#5.services= keyed map ofServiceInput. Expose the app with onehttpservice —{ type: 'http', internalPort: <app port>, externalHostname: '<name>.<domain>', externalDomainId: <id>, authRequired: <bool> }. The app serves plain HTTP internally; the Manager's nginx fronts it and terminates TLS externally (ACME), per the os.mieweb.org integration: architecture for themiewebtarget on opensource-server cloud#5 "only the app is exposed; nginx + TLS in front" model.externalDomainIdis resolved viaget_new_container_form(which returns{ externalDomains, nvidiaAvailable }for exactly this). Any SRV/TCP/UDP services the app declares map totype: 'srv'|'tcp'|'udp'.nvidiaRequested= derive from the app (e.g. presence of an AI binding), since node placement is automatic (there is no node-selection field;create_containerreturns 409no_node/no_nvidia_nodeif unplaceable). Create-only — liketemplate, it is not accepted byupdate_container, so changing it also requires delete+recreate.On
update_container— semantics & the image-change rule (important)update_containeraccepts onlyusername,services,environmentVars,entrypoint, andrestart(verified againstopenapi.v1.yaml/routers/api/v1/containers.js). It does not accepttemplate/image,nvidiaRequested, orcollaborators. Consequences:image(e.g. a new converged-base tag) — or needs to changenvidiaRequested— the provider cannot update in place: it mustdelete_containerthencreate_container. This is safe because the data volume is retained on delete (the retain-on-delete behavior specified in Support user-defined shared volumes for containers (replace hardcoded quick_and_dirty mp0) #421): the recreated container on the same hostname reattaches the same host directory, so datastore state survives. Same image + no nvidia change ⇒ ordinary in-placeupdate_container.environmentVarson update is a full replacement set — "Omitting it clears all user env vars." Always send the complete desired env, not a delta.serviceson update is a keyed map ofServiceUpdate=ServiceInput+{ id?, deleted? }. To reconcile: entries with an existingidupdate in place;{ id, deleted: true }removes a service; entries withoutidcreate one. Diff the container's currentservices(read viaget_container) against desired and emit the right add/update/delete set.restart: truewhen a redeploy must take effect immediately.Producing
DeployResulturl= the app's public URL — read fromContainer.httpEntries[].externalUrl(ahttps://<externalHostname>.<domain>value) on the same post-jobget_containerread-back.resources= oneResourceHandlefor the converged container:{ kind: 'container', binding: '<name>', id: '<vmid>' }.containerIds exist —create_container's responsecontainerIdis the Manager DB row id, whereas theContainerschema'scontainerId(populated after provisioning) is the Proxmox VMID / Docker id. Use the latter for the opaque handle: after the create/update job completes, GET the container (get_container) and read itscontainerId(VMID). The CLI treatsidas opaque; the stable backend VMID lets a futuredeployshort-circuit.Response/async cross-cutting facts
{ "data": ... }; errors are{ "error": { code, message } }(codes:unauthorized,site_not_found,no_node,hostname_mismatch,conflict,already_reviewed, …). Map 401/403 →AuthError; map others → a descriptiveErrorviactx.logger.error+ throw.get_job(GET /jobs/{id}) forJob.status(pending|running|success|failure|cancelled) — that is the terminal verdict.get_job_status(GET /jobs/{id}/status) returns output log rows, not a status verdict; use it (orstream_job_output) only for logs. Treatfailure/cancelledas a failed deploy (surface viactx.logger.error+ throw); respectctx.signalwhile polling.3. Authentication: env-token (non-interactive) + interactive web login
The Manager's auth model (verified against the spec): machines use
BearerAuthAPI keys; interactive humans use a browser session (username/passwordlogin, oroidc_login→oidc_callbackwhich sets theconnect.sidcookie). There is no device-code flow in the API today. Both flows below therefore land on an API key the provider uses as a Bearer token. Verified inmiddlewares/api.js:apiAuthaccepts aBearerAPI key (looked up bykeyPrefix, verified viavalidateKey) on all container CRUD routes, and CSRF is skipped for pure-Bearer requests (no session cookie) — so the provider sends onlyAuthorization: Bearer <key>, no CSRF token.Where credentials & instance URL come from (fixed precedence)
Secrets come from the environment (or the
logincache), surfaced via thecreateProvider(env)factory — never fromctx.targetConfig.env.MIEWEB_OS_TOKEN→ the machine-locallogincache for the active instance. (No config fallback — a token is never read fromtargetConfig/manifest.)env.MIEWEB_OS_URL→ctx.targetConfig.instanceUrl→ defaulthttps://os.mieweb.org. (Non-secret, so config participates.)(a) Env-token flow (CI / non-interactive) — primary
create_api_key(POST /apikeys→data.keyplaintext, returned exactly once; thereafter onlykeyPrefixis stored). (The UI exposes the same operation.)MIEWEB_OS_TOKEN=<key>(and, for local dev,MIEWEB_OS_URL=http://localhost:3000— see §5), or settargets.mieweb.instanceUrlfor the URL.Authorization: Bearer <key>on every Manager call.whoami→get_session(GET /session): 200{ user, isAdmin }→{ authenticated: true, account: user, method: 'env' }; 401 →{ authenticated: false }.AuthError('opensource-server', ctx.target, 'set MIEWEB_OS_TOKEN, or runmieweb login --target mieweb').(b) Interactive web login — loopback handoff (
login/logout)Modeled on
wrangler login: a browser establishes a Manager session, and a loopback redirect returns a freshly-minted API key to the CLI (this needs a small new Manager route — see §4).loginstarts a temporaryhttp://127.0.0.1:<port>/callbacklistener, readsGET /health→oidcEnabledto decide the entry point, and opens the browser at the new CLI-auth route (§4.3) carrying the loopback port + a one-time state (OIDC-enabled → it first bounces throughGET /auth/oidc/login; otherwise the password login page). The user authenticates the normal way, establishing aconnect.sidsession.redirectparam cannot carry the loopback:safeRedirectUrl(inrouters/api/v1/auth.js) allowlists only/and configuredExternalDomainnames and silently falls back to/otherwise — so a127.0.0.1target is rejected. The loopback is therefore owned entirely by the new §4.3 route, which does its own strict loopback validation.{ instanceUrl, token, apiKeyId }to a machine-local store (~/.mieweb/os.json, keyed by instance — mirroring wrangler's~/.wranglercache). The verbs read it back whenMIEWEB_OS_TOKENis unset.logout→ delete the cached token anddelete_api_key(DELETE /apikeys/{id}) to revoke it server-side; warn ifMIEWEB_OS_TOKENis set in the env (it takes precedence and can't be cleared by logout) — same pattern@mieweb/deploy-wrangleruses forCLOUDFLARE_API_TOKEN.Multi-instance & the login URL. The instance URL is location, not identity — the cache is keyed by instance so a user can be logged into
os.mieweb.organd a self-hostedos.acme.internalsimultaneously. Forlogin, the instance URL resolves in this order:--instance <url>(passthroughctx.argv) →ctx.targetConfig.instanceUrl→ an interactive prompt.4. New Manager-side work & supplemental requirements
Two items are net-new Manager work owned by this issue and each has a build strategy below: the converged image (§4.1) and the loopback login route (§4.3). The persistent volume (§4.2) is split out into #421 and blocks v1 from there. The remaining items (§4.4) are audits.
4.1 Converged app image — new bake target
images/cloud(required for v1)ghcr.io/mieweb/opensource-server/clouddoes not exist today. There is no Dockerfile bundling MinIO + libsqld + Valkey, and the repo's images are systemd-based LXC images (the Manager itself uses PostgreSQL, not libSQL). This is sizable net-new work.Build strategy:
images/cloud/DockerfilewithFROMthe existingbaseimage (the Proxmox Debian-13 LXC rootfs whose entrypoint is/sbin/init). Because the base runs systemd, each co-located service is a systemd unit — do not introduce s6/supervisord. Ship four units:minio.service,libsqld.service,valkey.service, andapp.service(the deployed worker), withapp.serviceorderedAfter=the three datastores.127.0.0.1on its conventional port (per os.mieweb.org integration: architecture for themiewebtarget on opensource-server cloud#5: MinIO:9000, libsqld:8080, Valkey:6379). Credentials are injected at deploy time via the container'senvironmentVars(§2) and read by the units at start; nothing is baked into the image./mnt/data/minio,/mnt/data/libsql,/mnt/data/valkey) so state survives container replacement. The app itself is delivered inside this image (no per-app image build for v1).target "cloud"toimages/docker-bake.hcland a correspondingdocker/metadata-action+ bake-file entry in.github/workflows/build-images.yml, mirroring the existing targets. It then inherits the repo's tag scheme automatically:type=sha,type=ref,event=branch, andtype=raw,value=latestonly on a non-prerelease published release.:latestis not deployable until the next release — which is fine, since that same release ships the new API handlers this issue needs. Pre-release testing uses the:<branch>/:<sha>tags viatargets.mieweb.image(§2).4.2 Persistent volume for the converged container — blocked by #421
The converged container co-locates MinIO/libSQL/Valkey state inside it, so it needs a read-write persistent volume that survives delete+recreate on the same hostname (the property the image-change redeploy in §2 relies on). Today there is no container-volume feature: the create path only attaches a hardcoded read-only
quick_and_dirtybind mount, and Proxmox has no mkdir API, so a per-container RW host directory can't be provisioned.This is split out into #421 (Support user-defined shared volumes for containers), which now carries the full design: host-path bind mount with the site agent pre-creating the directory, a per-
Volume.statuscreate-job↔agent sync barrier, the shared-storage requirement + node-config-save warning,quick_and_dirtymigrated into a first-classVolumemodel, retain-on-delete, and backup documented as out of scope. See #421 for the settled facts and deliverables.This provider (v1) depends on #421 landing. From the provider's side the integration is just: request one
rwvolume mounted at/mnt/dataon the create/updatevolumesarray (once #421 exposes it), and let §4.1's systemd units point their data dirs there. Until #421 ships, the converged deploy has no durable datastore state.4.3 Loopback login handoff (required for §3b)
A new auth route that, after a browser session is established, mints a new API key and redirects to a CLI-provided
http://127.0.0.1:<port>loopback with that key — the basis formieweb login --target mieweb.Build strategy:
GET /auth/cli/callback?port=<loopback-port>&state=<nonce>that requires an authenticated session (connect.sid); the CLI's browser hand-off lands here after the normallogin/oidc/callbackcompletes. If the request is unauthenticated, the route bounces the user through the standard login first (carrying its own params through the OIDC session, not through the allowlistedredirect).create_api_keyto mint a key scoped to the authenticated session's user (description likemieweb-cli@<hostname>), then302tohttp://127.0.0.1:<port>/callback#key=<plaintext>&state=<nonce>(key in the fragment, so it isn't logged as a query or forwarded upstream).safeRedirectUrl. That helper only permits/+ configuredExternalDomainnames and would reject the loopback. This route does its own strict validation instead: accept aportonly, hard-code the host to127.0.0.1/::1, and echo the CLI-supplied one-timestateso a logged-in user can't be tricked into minting a key for someone else's listener. Do not generalize the app-wide redirect allowlist to include loopback.login(§3b) starts the loopback listener (bound to127.0.0.1only), opens the browser at/auth/cli/callback?port=…&state=…, captures the key from the loopback request's URL fragment, verifies the returnedstate, and stores it (§3b step 3).4.4 Audits (not large changes)
externalDomainIddiscovery for non-admins —list_external_domains/create_external_domainare admin-only, butget_new_container_form(GET /sites/{siteId}/containers/new) returns the usableexternalDomainslist to any container creator. Confirm that's sufficient for a non-admin deploy to pick anexternalDomainId; if not, expose a non-admin domain-list read.deployworks end-to-end — creating a container, attaching an HTTP service to an existing external domain, and setting env vars should all be doable by a normal (non-admin) user for their own hostname. Audit the 403s (create_containerfor-other-user, external-domains admin-gate) to be sure the self-service path is unobstructed.templateimage via Proxmox from public GHCR anonymously (utils/docker-registry.jsimplements only anonymous Bearer-token auth; there are no registry credentials). Ensureghcr.io/mieweb/opensource-server/cloudis public, or the deploy will fail to pull. (See the related metadata/air-gap caveat in Support air-gapped container creation using locally cached OCI images #454.)Deferred (not v1): resource sizing on deploy via
create_resource_request/get_effective_resources(the converged container is heavy; defaults are memory 4096 MB, swap 0, cpus 4, rootfs 50 GB). v1 uses defaults; app-driven sizing is a follow-up.5. Testing against a locally-running control plane
Two loops, both reusable going forward:
make dev+ DummyApi (fast, no Proxmox).make dev(top-level, delegating tocreate-a-container/) runs the Manager on SQLite + the DummyApi mock hypervisor athttp://localhost:3000, with a seeded localhost site + dummy node. Point the provider at it (MIEWEB_OS_URL=http://localhost:3000, mint a key viacreate_api_key, settargets.mieweb.siteIdto the seeded site), and runmieweb deploy --target mieweb. The create flow (POST /sites/:id/containers→ job-runner →bin/create-container.js→ DummyApi) exercises the provider's API mapping + job polling end-to-end without Proxmox or a real converged image. This is the primary provider dev/CI loop.compose.ymlbrings up real Proxmox-in-Docker (privileged) + the node/client/mcp watchers, and pulls images anonymously from public GHCR. For a true end-to-end (real container creation + image pull + the RW data mount from Support user-defined shared volumes for containers (replace hardcoded quick_and_dirty mp0) #421), stage the locally-builtimages/cloudinto the Proxmox template cache using the existing local-first pattern (skopeo copy docker-daemon:… || skopeo copy docker://…, as the composepull-imageservice does), then deploy. Keep this behind a flag/CI-gate — it's heavy and privileged.The conformance suite (
runProviderConformancefrom@mieweb/deploy-contract/testkit) runs structurally offline; its live/handle-stability path runs against the DummyApi Manager with anapplyIdshook.Acceptance criteria
@mieweb/os-cloud-providerpackage created in this repo (TypeScript, native-TS dev /tsc-built on publish), exportingcreateProvider(env), importing@mieweb/deploy-contractvia the pnpm subdir git dependency pinned to a SHA (§1).supports()returns true for the'mieweb'target.deployreadssiteIdfromtargets.mieweb.siteIdandimagefromtargets.mieweb.image(defaultghcr.io/mieweb/opensource-server/cloud:latest), implements the hostname-keyed upsert (§2) with onehttpservice, polls the job to completion honoringctx.signal, and returns aDeployResultwithurl+ acontainerResourceHandlewhoseidis the Proxmox VMID (theContainer.containerIdread back viaget_containerafter the job succeeds — not the create response's DB-idcontainerId).destroy,whoami,login,logoutimplemented per §2–§3.devandtailare intentionally omitted (documented).createProvider(env)/ the machine-local cache — never fromctx.targetConfig/ctx.manifest.AuthErrorthrown on 401/403.runProviderConformancefrom@mieweb/deploy-contract/testkit(structural always; live behind a flag against themake devManager + anapplyIdshook).make dev/DummyApi athttp://localhost:3000; gated full-stack integration documented (compose + skopeo-stagedimages/cloud).mieweb.jsoncexample documentingtargets.mieweb = { provider: "@mieweb/os-cloud-provider", siteId: <int>, instanceUrl: "https://os.mieweb.org", image?: "…" }and theMIEWEB_OS_TOKEN/MIEWEB_OS_URLenv vars.images/cloudbake target (§4.1) building + pushing to public GHCR; loopback login route (§4.3); non-admin deploy path confirmed (§4.4).rw/mnt/datavolume via thevolumesarray — the converged container has durable datastore state (§4.2).@mieweb/deploy-contractspecifier to a plain npm semver range once it's published (tracked, deliberate).References
mieweb/cloud→packages/deploy-contract/src/index.ts(and@mieweb/deploy-wrangleras the reference implementation to mirror).miewebtarget on opensource-server cloud#5..github/workflows/build-images.yml,images/docker-bake.hcl.containerIds,dnsWarnings,httpEntries):create-a-container/routers/api/v1/containers.js,create-a-container/openapi.v1.yaml.middlewares/api.js(apiAuthBearer + CSRF-skip),routers/api/v1/auth.js(safeRedirectUrlallowlist),Job.statusenum +get_jobvsget_job_status(openapi.v1.yaml), hostname unique(siteId,hostname)+ DNS-label validation (models/container.js).make dev(create-a-container/) and top-levelcompose.yml. Related image-pull caveat: Support air-gapped container creation using locally cached OCI images #454.cc @horner