Skip to content

Implement @mieweb/os-cloud-provider: a DeployProvider for os.mieweb.org #475

Description

@runleveldev

Context

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):

interface DeployProvider {
  readonly name: string;                          // 'opensource-server'
  supports(target: DeployTarget): boolean;        // true for 'mieweb'
  deploy(ctx: DeployContext): Promise<DeployResult>;   // REQUIRED
  dev?(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):
type DeployProviderFactory = (env: ProviderEnv) => DeployProvider;   // ProviderEnv = Readonly<Record<string,string|undefined>>

interface DeployContext {
  readonly root: string;                              // repo root
  readonly target: DeployTarget;                      // active target name
  readonly manifest: Readonly<Record<string, unknown>>;      // parsed wrangler.jsonc (opaque)
  readonly manifestPath?: string;                     // absolute path to it, if on disk
  readonly mieweb: Readonly<Record<string, unknown>>;        // parsed mieweb.jsonc (non-secret)
  readonly targetConfig: Readonly<Record<string, unknown>>;  // mieweb.jsonc → targets[target] (NON-SECRET, recursively redacted by the CLI)
  readonly argv: readonly string[];                   // passthrough args after the verb
  readonly logger: DeployLogger;                      // info/warn/error — provider MUST use this, not console.*
  readonly signal: AbortSignal;                       // cancellation
}

interface DeployResult { readonly url?: string; readonly resources: readonly ResourceHandle[]; }
interface ResourceHandle { readonly binding: string; readonly kind: ResourceKind; readonly id: string; }  // id is OPAQUE to the CLI
// ResourceKind ∈ 'database'|'bucket'|'kv'|'queue'|'vector'|'stateful'|'ai'|'container'

interface AuthStatus { readonly authenticated: boolean; readonly account?: string; readonly method?: string; }
class  AuthError extends Error { constructor(provider: string, target: DeployTarget, hint?: string); }
interface DeployHandle { readonly url?: string; readonly closed?: 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.
  • Honor ctx.signal (abort in-flight Manager calls / job polling).
  • 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.1 is 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 subdirectory packages/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.

// package.json of @mieweb/os-cloud-provider
{
  "dependencies": {
    // pnpm form: <owner>/<repo>#<committish>&path:/<subdir>
    "@mieweb/deploy-contract": "github:mieweb/cloud#<commit-sha>&path:/packages/deploy-contract"
  }
}

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';
// types
import type { DeployProvider, DeployContext, DeployResult } from '@mieweb/deploy-contract';
// conformance test-kit
import { 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:

  1. list_containers — GET /sites/{siteId}/containers?hostname=<name> (the query param is literally hostname).
  2. 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 idempotent via hostname upsert. create_container 201 → { containerId, jobId, hostname, status }; update_container 200 → { containerId, jobId?, dnsWarnings[], pendingRestart, message }.
destroy delete_container (DELETE /sites/{siteId}/containers/{id}) 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.jsonc name (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.
  • environmentVars = array of { key, value } (⚠️ write shape is an array, but the Container read 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 the mieweb target on opensource-server cloud#5.
  • 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 only username, 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.)
  • Instance URL resolution order: env.MIEWEB_OS_URL → ctx.targetConfig.instanceUrl → default https://os.mieweb.org. (Non-secret, so config participates.)
export function createProvider(env: ProviderEnv): DeployProvider {
  const token = env.MIEWEB_OS_TOKEN;                 // else the login cache (resolved per verb, per instance)
  const instanceUrl = env.MIEWEB_OS_URL;             // else targetConfig.instanceUrl, else the default
  return makeOsProvider({ token, instanceUrl });
}

(a) Env-token flow (CI / non-interactive) — primary

  1. A user mints an API key once via create_api_key (POST /apikeys → data.key plaintext, returned exactly once; thereafter only keyPrefix is stored). (The UI exposes the same operation.)
  2. 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.
  3. The provider sends Authorization: Bearer <key> on every Manager call.
  4. whoami → get_session (GET /session): 200 { user, isAdmin } → { authenticated: true, account: user, method: 'env' }; 401 → { authenticated: false }.
  5. 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).

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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=latest only on a non-prerelease published release.
  5. 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).
  6. 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.status create-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:

  1. 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).
  2. 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).
  3. 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.
  4. 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 container ResourceHandle 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).
  • Support user-defined shared volumes for containers (replace hardcoded quick_and_dirty mp0) #421 (persistent volumes) landed and the provider requests one rw /mnt/data volume via the volumes array — the converged container has durable datastore state (§4.2).
  • Flip the @mieweb/deploy-contract specifier to a plain npm semver range once it's published (tracked, deliberate).

References

cc @horner

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions