diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index fdbdbd38..1b111cd6 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -105,6 +105,20 @@ jobs: type=ref,event=tag type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + - name: Docker Meta (Cloud) + id: meta-cloud + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/cloud + bake-target: cloud + flavor: latest=false + tags: | + type=sha + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + - name: Docker Meta (Docs) id: meta-docs uses: docker/metadata-action@v5 @@ -169,6 +183,7 @@ jobs: ${{ steps.meta-nodejs.outputs.bake-file }} ${{ steps.meta-docker.outputs.bake-file }} ${{ steps.meta-docker-nodejs.outputs.bake-file }} + ${{ steps.meta-cloud.outputs.bake-file }} ${{ steps.meta-docs.outputs.bake-file }} ${{ steps.meta-agent.outputs.bake-file }} ${{ steps.meta-manager.outputs.bake-file }} @@ -182,6 +197,8 @@ jobs: docker.cache-to=type=gha,mode=max,scope=docker-${{ github.ref_name }} docker-nodejs.cache-from=type=gha,scope=docker-nodejs-${{ github.ref_name }} docker-nodejs.cache-to=type=gha,mode=max,scope=docker-nodejs-${{ github.ref_name }} + cloud.cache-from=type=gha,scope=cloud-${{ github.ref_name }} + cloud.cache-to=type=gha,mode=max,scope=cloud-${{ github.ref_name }} docs.cache-from=type=gha,scope=docs-${{ github.ref_name }} docs.cache-to=type=gha,mode=max,scope=docs-${{ github.ref_name }} agent.cache-from=type=gha,scope=agent-${{ github.ref_name }} diff --git a/.github/workflows/os-cloud-provider-preview.yml b/.github/workflows/os-cloud-provider-preview.yml new file mode 100644 index 00000000..0cc8743b --- /dev/null +++ b/.github/workflows/os-cloud-provider-preview.yml @@ -0,0 +1,90 @@ +name: os-cloud-provider preview package + +# On every push to a same-repo pull request that touches the provider, publish +# @mieweb/os-cloud-provider to the GitHub Packages npm registry as +# `-pr.` under dist-tag `pr`, for cross-repo testing +# (e.g. @mieweb/cli in mieweb/cloud). +# +# Consumers install it by its tarball URL (printed in the job summary) plus a +# GitHub Packages token, NOT a scope-wide `@mieweb:registry=` override: that +# would route every @mieweb/* lookup, including @mieweb/deploy-contract (only +# on npmjs), to GitHub Packages and fail. +# +# .npmrc: //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} +# package.json: "@mieweb/os-cloud-provider": "" + +on: + pull_request: + paths: + - 'packages/os-cloud-provider/**' + - '.github/workflows/os-cloud-provider-preview.yml' + +concurrency: + group: os-cloud-provider-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + publish: + # GITHUB_TOKEN is read-only on fork PRs. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/os-cloud-provider + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + package_json_file: packages/os-cloud-provider/package.json + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: packages/os-cloud-provider/pnpm-lock.yaml + registry-url: https://npm.pkg.github.com + scope: '@mieweb' + # setup-node points @mieweb at GitHub Packages for the publish below; + # dependencies (incl. @mieweb/deploy-contract) come from npmjs. + - run: pnpm install --frozen-lockfile --config.@mieweb:registry=https://registry.npmjs.org/ + - name: Stamp preview version + id: stamp + env: + SUFFIX: pr${{ github.event.pull_request.number }}.${{ github.run_number }} + run: | + v=$(node -p 'require("./package.json").version.replace(/-.*$/, "")')-$SUFFIX + npm pkg set version="$v" + echo "version=$v" >> "$GITHUB_OUTPUT" + # Preview builds default to this PR's cloud image (build-images.yml pushes + # cloud:pr- on every PR push); `latest` only exists after a release. + - name: Default to this PR's cloud image + env: + TAG: pr-${{ github.event.pull_request.number }} + run: | + sed -i "s#\(DEFAULT_IMAGE = 'ghcr.io/mieweb/opensource-server/cloud\):latest'#\1:$TAG'#" src/config.ts + grep -q "cloud:$TAG'" src/config.ts + # prepack builds dist/. + - run: pnpm publish --no-git-checks --tag pr${{ github.event.pull_request.number }} + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Summarize how to install this preview + env: + VERSION: ${{ steps.stamp.outputs.version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tarball=$(npm view "@mieweb/os-cloud-provider@$VERSION" dist.tarball --registry https://npm.pkg.github.com) + { + echo "Published \`@mieweb/os-cloud-provider@$VERSION\` to npm.pkg.github.com." + echo + echo "Install it by tarball URL (keeps @mieweb/deploy-contract on npmjs), with a GitHub Packages token in .npmrc:" + echo + echo '```' + echo "//npm.pkg.github.com/:_authToken=\${GITHUB_TOKEN}" + echo "\"@mieweb/os-cloud-provider\": \"$tarball\"" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/os-cloud-provider.yml b/.github/workflows/os-cloud-provider.yml new file mode 100644 index 00000000..b65a8965 --- /dev/null +++ b/.github/workflows/os-cloud-provider.yml @@ -0,0 +1,49 @@ +name: os-cloud-provider + +# Typecheck + test @mieweb/os-cloud-provider (packages/os-cloud-provider). +# The regular suite runs against an in-process fake Manager; the live suite +# (pnpm test:live) needs a running Manager and is not run here. + +on: + push: + branches: [main] + paths: + - 'packages/os-cloud-provider/**' + - 'create-a-container/openapi.v1.yaml' + - '.github/workflows/os-cloud-provider.yml' + pull_request: + paths: + - 'packages/os-cloud-provider/**' + - 'create-a-container/openapi.v1.yaml' + - '.github/workflows/os-cloud-provider.yml' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: ['22', '24'] + defaults: + run: + working-directory: packages/os-cloud-provider + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + package_json_file: packages/os-cloud-provider/package.json + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + cache-dependency-path: packages/os-cloud-provider/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - name: Generated Manager types are up to date + run: pnpm gen:types && git diff --exit-code -- src/generated + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b43cbba..3d32fecd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ name: Release Packages # images ship this source so `apt upgrade` tracks future releases. This # workflow never creates or modifies the release itself — create the release # (and choose full vs prerelease) in GitHub first, then this attaches assets. +# +# The same release also publishes @mieweb/os-cloud-provider to npmjs (job +# `publish-npm`), versioned from the release tag (v2026.10.2 -> 2026.10.2). +# Full releases get the `latest` dist-tag, prereleases `next`. on: release: @@ -68,3 +72,62 @@ jobs: dist/dist/Packages.gz \ --repo "${{ github.repository }}" \ --clobber + + publish-npm: + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: read + # npm trusted publishing (OIDC) + provenance attestation. + id-token: write + defaults: + run: + working-directory: packages/os-cloud-provider + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + # Version/dist-tag from the tag, the deploy-contract check, and the + # already-published check live in a script you can run locally: + # node packages/os-cloud-provider/scripts/release-meta.mjs [--prerelease] + - name: Resolve release metadata + id: meta + env: + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: node scripts/release-meta.mjs "$TAG" $([ "$PRERELEASE" = true ] && echo --prerelease) + + - uses: pnpm/action-setup@v4 + with: + package_json_file: packages/os-cloud-provider/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: packages/os-cloud-provider/pnpm-lock.yaml + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm run test + + - name: Set package version + if: steps.meta.outputs.skip != 'true' + run: npm pkg set version="${{ steps.meta.outputs.version }}" + + # pnpm pack runs prepack (builds dist/); npm publish does the upload so + # trusted publishing (OIDC) and provenance work. NPM_TOKEN is only a + # fallback for when trusted publishing isn't configured on npmjs. + - name: Publish to npm + if: steps.meta.outputs.skip != 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + DIST_TAG: ${{ steps.meta.outputs.dist-tag }} + run: | + npm install -g npm@latest + pnpm pack --pack-destination "$RUNNER_TEMP" + npm publish "$RUNNER_TEMP"/mieweb-os-cloud-provider-*.tgz --access public --provenance --tag "$DIST_TAG" + echo "Published \`@mieweb/os-cloud-provider@${{ steps.meta.outputs.version }}\` to npmjs (\`$DIST_TAG\`)." >> "$GITHUB_STEP_SUMMARY" diff --git a/agent/src/volumes.ts b/agent/src/volumes.ts index 7480bfee..29885497 100644 --- a/agent/src/volumes.ts +++ b/agent/src/volumes.ts @@ -33,11 +33,12 @@ import fs from 'fs'; import { log } from './log'; import type { SiteConfig, SiteVolume, VolumeResult } from './types'; -// Mode for created directories. RW volumes get owner/group rwx (the id-mapped -// container root owns the dir, so it can write); RO volumes are r-x. World bits -// are left closed so other tenants can't read another container's data. -const RW_MODE = 0o0770; -const RO_MODE = 0o0550; +// Mode for volume roots, like /var/lib: the id-mapped container root owns the +// dir (rwx for RW, r-x for RO) and non-root service accounts inside the +// container can traverse it to their own subdirectories. Isolation between +// tenants comes from each container mounting only its own volume. +const RW_MODE = 0o0755; +const RO_MODE = 0o0555; /** * Collect the volumes to provision from the config snapshot (site-level). diff --git a/create-a-container/bin/reconfigure-container.js b/create-a-container/bin/reconfigure-container.js index b02bf820..ffa1286f 100644 --- a/create-a-container/bin/reconfigure-container.js +++ b/create-a-container/bin/reconfigure-container.js @@ -147,7 +147,9 @@ async function main() { if (Object.keys(lxcConfig).length > 0) { console.log('Applying LXC configuration...'); - console.log('Config:', JSON.stringify(lxcConfig, null, 2)); + // Log only which keys change: `env` carries every user env var + // (including secrets), and job output is shown to users and in CI logs. + console.log(`Config keys: ${Object.keys(lxcConfig).join(', ')}`); await client.updateLxcConfig(node.name, container.containerId, lxcConfig); console.log('Configuration applied'); } else { diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx index 3b5e06f4..b2ffd624 100644 --- a/create-a-container/client/src/pages/auth/LoginPage.tsx +++ b/create-a-container/client/src/pages/auth/LoginPage.tsx @@ -45,6 +45,9 @@ function asExternalUrl(target: string): string | null { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + // Same-origin server routes (e.g. the CLI login handoff at + // /api/v1/auth/cli/callback) aren't SPA pages — they need a real navigation. + if (url.origin === window.location.origin && url.pathname.startsWith('/api/')) return url.href; // Same-origin targets stay in-app (let react-router handle them as paths). if (url.origin === window.location.origin) return null; return url.href; diff --git a/create-a-container/client/src/pages/containers/ContainersListPage.tsx b/create-a-container/client/src/pages/containers/ContainersListPage.tsx index f65984ad..7a377af6 100644 --- a/create-a-container/client/src/pages/containers/ContainersListPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainersListPage.tsx @@ -55,13 +55,27 @@ export function ContainersListPage() { }); const del = useMutation({ - mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/containers/${id}`), + // Safe by default: the Manager keeps the record (502 node_delete_failed) + // if it can't confirm the VM is gone. Forcing is an explicit second step. + mutationFn: ({ id, force }: { id: number; force?: boolean }) => + api.delete(`/api/v1/sites/${siteId}/containers/${id}${force ? '?force=true' : ''}`), onSuccess: () => { toast.success('Container deleted'); qc.invalidateQueries({ queryKey: keys.containers(siteId!) }); }, - onError: (err: ApiError) => toast.error(err.message), + onError: (err: ApiError, vars) => { + if (err.code === 'node_delete_failed' && !vars.force) { + const forceIt = confirm( + `${err.message}\n\nRemove the container record anyway? The VM may keep running on its node ` + + '(only do this if the node is gone for good).', + ); + if (forceIt) del.mutate({ id: vars.id, force: true }); + return; + } + toast.error(err.message); + }, }); + const onDelete = (id: number) => del.mutate({ id }); const containers = data ?? []; const hasContainers = containers.length > 0; @@ -125,7 +139,7 @@ export function ContainersListPage() { containers={containers} sessionUser={sessionUser} siteId={siteId} - onDelete={del.mutate} + onDelete={onDelete} deleting={del.isPending} canShare={canShareContainer} onShare={(target) => setShareTargetId(target.id)} diff --git a/create-a-container/models/dns-service.js b/create-a-container/models/dns-service.js index 254bfa43..0151ca00 100644 --- a/create-a-container/models/dns-service.js +++ b/create-a-container/models/dns-service.js @@ -16,7 +16,8 @@ module.exports = (sequelize, DataTypes) => { references: { model: 'Services', key: 'id' - } + }, + onDelete: 'CASCADE' }, recordType: { type: DataTypes.ENUM('SRV'), diff --git a/create-a-container/models/http-service.js b/create-a-container/models/http-service.js index 7d12e1c4..1455af74 100644 --- a/create-a-container/models/http-service.js +++ b/create-a-container/models/http-service.js @@ -17,7 +17,8 @@ module.exports = (sequelize, DataTypes) => { references: { model: 'Services', key: 'id' - } + }, + onDelete: 'CASCADE' }, externalHostname: { type: DataTypes.STRING(255), diff --git a/create-a-container/models/transport-service.js b/create-a-container/models/transport-service.js index 10397805..dfd7a652 100644 --- a/create-a-container/models/transport-service.js +++ b/create-a-container/models/transport-service.js @@ -47,7 +47,8 @@ module.exports = (sequelize, DataTypes) => { references: { model: 'Services', key: 'id' - } + }, + onDelete: 'CASCADE' }, protocol: { type: DataTypes.ENUM('tcp', 'udp'), diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index ad95793e..8341a187 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -632,6 +632,53 @@ paths: responses: '302': { description: 'Redirect to the post-login destination on success, or /login?oidc_error= on failure' } '404': { description: 'OIDC not configured (code: oidc_disabled)' } + /auth/cli/callback: + get: + operationId: cli_login_authorize + tags: [Auth] + summary: CLI loopback login — browser confirmation page + description: >- + Browser-facing (HTML), used by `mieweb login`. Without a session, it + redirects through the normal sign-in (OIDC or /login) and comes back + here. With a session, it renders a confirmation form that POSTs back to + this path. A GET never mints a key. + security: [] + parameters: + - { in: query, name: port, required: true, schema: { type: integer, minimum: 1024, maximum: 65535 }, description: Loopback port the CLI listens on (host is always 127.0.0.1) } + - { in: query, name: state, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16,128}$' }, description: CLI-generated one-time nonce, echoed back } + - { in: query, name: client, schema: { type: string, pattern: '^[A-Za-z0-9._@-]{1,64}$' }, description: Label recorded in the minted key's description } + responses: + '200': { description: HTML confirmation page } + '302': { description: Redirect to sign-in when there is no session } + '400': { description: HTML error page for invalid parameters } + post: + operationId: cli_login_mint + tags: [Auth] + summary: CLI loopback login — mint an API key and hand it to the loopback listener + description: >- + Requires a browser session and a CSRF token (`_csrf`). Bearer-only + requests are rejected. Mints an API key for the session user and + 303-redirects to `http://127.0.0.1:/callback#key=…&id=…&user=…&state=…`. + The key is in the URL fragment, so it never appears in a request line. + # Browser session only: Bearer-only requests are rejected. + security: + - SessionCookie: [] + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [_csrf, port, state] + properties: + _csrf: { type: string } + port: { type: integer } + state: { type: string } + client: { type: string } + responses: + '303': { description: Redirect to the loopback listener with the key in the fragment } + '400': { description: HTML error page for invalid parameters } + '401': { description: No browser session } + '403': { description: Missing/invalid CSRF token } /auth/logout: post: operationId: logout @@ -893,7 +940,7 @@ paths: description: Bootstrap payload content: application/json: - schema: { type: object, properties: { data: { type: object, properties: { siteId: { type: integer }, externalDomains: { type: array, items: { type: object } }, nvidiaAvailable: { type: boolean } } } } } + schema: { type: object, properties: { data: { type: object, required: [siteId, externalDomains, nvidiaAvailable], properties: { siteId: { type: integer }, externalDomains: { type: array, description: 'Usable domains; the site''s own default domains first', items: { type: object, required: [id, name], properties: { id: { type: integer }, name: { type: string }, siteId: { type: integer, nullable: true } } } }, nvidiaAvailable: { type: boolean } } } } } '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/metadata: get: @@ -982,6 +1029,12 @@ paths: delete: operationId: delete_container tags: [Containers] + description: >- + Deletes the VM on its node, then the record. The record is kept and 502 + returned if the node-side delete fails and the VM still exists (or the + node can't be checked), unless `force=true`. + parameters: + - { in: query, name: force, schema: { type: boolean }, description: 'Remove the record even if the node-side delete fails' } responses: '200': description: Deleted, with DNS cleanup warnings @@ -991,6 +1044,7 @@ paths: '403': { description: 'forbidden — only the owner/admin may delete (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { $ref: '#/components/responses/NotFound' } '409': { description: 'DB/Proxmox hostname mismatch — delete aborted (code: hostname_mismatch)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '502': { description: 'Node-side delete failed and the VM still exists or could not be checked (code: node_delete_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/{id}/collaborators: parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 75a7cece..c0bda9fc 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -11,6 +11,7 @@ "cookie-parser": "^1.4.7", "csrf-sync": "^4.2.1", "dotenv": "^17.2.3", + "escape-html": "^1.0.3", "express": "^5.2.1", "express-session": "^1.19.0", "express-session-sequelize": "^2.3.0", diff --git a/create-a-container/package.json b/create-a-container/package.json index 9bfc2d81..d8be21e8 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -25,6 +25,7 @@ "cookie-parser": "^1.4.7", "csrf-sync": "^4.2.1", "dotenv": "^17.2.3", + "escape-html": "^1.0.3", "express": "^5.2.1", "express-session": "^1.19.0", "express-session-sequelize": "^2.3.0", diff --git a/create-a-container/routers/api/v1/__tests__/cli-auth.test.js b/create-a-container/routers/api/v1/__tests__/cli-auth.test.js new file mode 100644 index 00000000..5852142f --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/cli-auth.test.js @@ -0,0 +1,152 @@ +/** + * Integration tests for the CLI loopback login handoff (issue #475 §4.3): + * GET/POST /api/v1/auth/cli/callback. + * + * Requests carry a non-localhost X-Forwarded-For so the CSRF guard's + * localhost bypass doesn't apply; that way the session + CSRF path is the + * real one a browser takes. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db'); +const { ApiKey } = require('../../../../models'); +const { parseHandoff } = require('../cli-auth'); + +const REMOTE = ['X-Forwarded-For', '203.0.113.7']; +const STATE = 'abcdefghijklmnop0123456789'; +const BASE = '/api/v1/auth/cli/callback'; + +async function loggedInAgent(app, uid) { + const agent = request.agent(app); + const csrf = await agent.get('/api/v1/csrf-token').set(...REMOTE); + const token = csrf.body.data.csrfToken; + const res = await agent + .post('/api/v1/auth/login') + .set(...REMOTE) + .set('X-CSRF-Token', token) + .send({ username: uid, password: 'correct horse battery staple' }); + expect(res.status).toBe(200); + return agent; +} + +function csrfFrom(html) { + const m = html.match(/name="_csrf" value="([^"]+)"/); + return m && m[1]; +} + +describe('parseHandoff', () => { + test('accepts a valid port/state/client', () => { + expect(parseHandoff({ port: '53682', state: STATE, client: 'mieweb-cli@laptop' })).toEqual({ + port: 53682, + state: STATE, + client: 'mieweb-cli@laptop', + }); + }); + + test.each([ + [{ port: '80', state: STATE }], + [{ port: '70000', state: STATE }], + [{ port: '5368a', state: STATE }], + [{ port: '053682', state: STATE }], + [{ port: '53682', state: 'short' }], + [{ port: '53682', state: `${STATE}`; + +interface Handoff { + key: string; + id: string; + user: string; +} + +function safeEqual(a: string, b: string): boolean { + const x = Buffer.from(a); + const y = Buffer.from(b); + return x.length === y.length && timingSafeEqual(x, y); +} + +function readBody(req: IncomingMessage, limit = 8192): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + body += chunk; + if (body.length > limit) { + reject(new Error('body too large')); + req.destroy(); + } + }); + req.on('end', () => resolve(body)); + req.on('error', reject); + }); +} + +/** Start the loopback listener; resolves the handoff once a valid `/token` POST arrives. */ +export async function startLoopback( + state: string, + signal: AbortSignal, + timeoutMs: number, +): Promise<{ port: number; result: Promise; close: () => void }> { + let settle!: { resolve: (h: Handoff) => void; reject: (e: unknown) => void }; + const result = new Promise((resolve, reject) => { + settle = { resolve, reject }; + }); + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const send = (status: number, type: string, body: string): void => { + res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); + res.end(body); + }; + if (req.method === 'GET' && url.pathname === '/callback') { + send(200, 'text/html; charset=utf-8', CALLBACK_PAGE); + return; + } + if (req.method === 'POST' && url.pathname === '/token') { + // Only our own callback page (same origin) may post the handoff. + const origin = req.headers.origin; + const self = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + if (origin !== undefined && origin !== self) { + send(403, 'text/plain', 'Forbidden origin'); + return; + } + readBody(req).then( + (body) => { + const p = new URLSearchParams(body); + const key = p.get('key') ?? ''; + const id = p.get('id') ?? ''; + const user = p.get('user') ?? ''; + if (!safeEqual(p.get('state') ?? '', state)) { + send(400, 'text/plain', 'State mismatch — this sign-in was not started by this terminal. Re-run `mieweb login`.'); + return; + } + if (!key || !id) { + send(400, 'text/plain', 'The Manager did not return an API key.'); + return; + } + send(200, 'text/plain', 'ok'); + settle.resolve({ key, id, user }); + }, + () => send(400, 'text/plain', 'Bad request'), + ); + return; + } + send(404, 'text/plain', 'Not found'); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const port = (server.address() as { port: number }).port; + + const timer = setTimeout(() => settle.reject(new Error('Timed out waiting for the browser sign-in')), timeoutMs); + const onAbort = (): void => settle.reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + // An abort during listen() already fired; AbortSignal doesn't replay it. + if (signal.aborted) onAbort(); + const close = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + server.closeAllConnections(); + server.close(); + }; + return { port, result, close }; +} + +function clientLabel(): string { + const host = osHostname().toLowerCase().replace(/[^a-z0-9._-]/g, '-').slice(0, 40) || 'host'; + return `mieweb-cli@${host}`; +} + +export async function login(ctx: DeployContext, deps: ProviderDeps, hooks: LoginHooks = {}): Promise { + const { logger } = ctx; + const instanceUrl = await loginInstanceUrl(ctx, deps); + if (deps.env.MIEWEB_OS_TOKEN?.trim()) { + logger.warn('MIEWEB_OS_TOKEN is set in the environment and takes precedence over the login cache.'); + } + + // Fail fast (before opening a browser) if the instance isn't a Manager. + await clientFor(ctx, deps, instanceUrl, null).call((api) => api.GET('/health'), { auth: false }); + + const state = randomBytes(24).toString('base64url'); + const loop = await startLoopback(state, ctx.signal, hooks.timeoutMs ?? 5 * 60 * 1000); + try { + const params = new URLSearchParams({ port: String(loop.port), state, client: clientLabel() }); + const authUrl = `${instanceUrl}/api/v1/auth/cli/callback?${params.toString()}`; + logger.info(`Opening your browser to sign in to ${instanceUrl}`); + logger.info(`If it doesn't open, visit: ${authUrl}`); + (hooks.openBrowser ?? defaultOpenBrowser)(authUrl); + + const handoff = await loop.result; + const minted = { token: handoff.key, apiKeyId: handoff.id }; + + // The key now exists on the Manager. Track it before anything else can + // fail: queued for revocation until it's stored as the login below. + try { + await addPendingRevocation(deps.env, instanceUrl, minted); + } catch (err) { + // Can't even record it: revoke it right away rather than leak it. + await revoke(instanceUrl, minted, ctx, deps); + throw err; + } + + let user: string; + try { + user = await sessionUser(clientFor(ctx, deps, instanceUrl, handoff.key)); + } catch (err) { + await revokeAll(instanceUrl, ctx, deps, logger); // includes the new key + throw err; + } + + // Store the new key and queue the one it replaces in one locked update. + await replaceCredential(deps.env, instanceUrl, { + token: handoff.key, + apiKeyId: handoff.id, + user, + savedAt: new Date().toISOString(), + }); + // Revoke the replaced key (plus any earlier failures), so repeated logins + // don't pile up keys. It's queued, so a crash here can't lose track of it. + await revokeAll(instanceUrl, ctx, deps, logger); + logger.info(`Logged in to ${instanceUrl} as ${user}`); + } finally { + loop.close(); + } +} + +/** Revoke one key. True when it is gone (revoked now, already deleted, or already invalid). */ +async function revoke(instanceUrl: string, key: PendingRevocation, ctx: DeployContext, deps: ProviderDeps): Promise { + try { + await clientFor(ctx, deps, instanceUrl, key.token).call((api) => + api.DELETE('/apikeys/{id}', { params: { path: { id: key.apiKeyId } } }), + ); + return true; + } catch (err) { + const status = (err as { status?: number }).status; + return (err as Error).name === 'AuthError' || status === 404; + } +} + +/** + * Revoke every queued key. Keys that still can't be revoked (Manager + * unreachable, 5xx) stay queued, with their token, for the next + * login/logout to retry, so no live key is ever forgotten. Only confirmed + * revocations are removed, so a concurrent login's queued key is untouched. + */ +async function revokeAll(instanceUrl: string, ctx: DeployContext, deps: ProviderDeps, logger: DeployLogger): Promise { + const done: string[] = []; + for (const key of await readPendingRevocations(deps.env, instanceUrl)) { + if (await revoke(instanceUrl, key, ctx, deps)) done.push(key.apiKeyId); + else logger.warn(`Could not revoke API key ${key.apiKeyId} on ${instanceUrl}; it will be retried on the next login or logout`); + } + await removePendingRevocations(deps.env, instanceUrl, done); +} + +export async function logout(ctx: DeployContext, deps: ProviderDeps): Promise { + const { logger } = ctx; + const explicit = instanceFromArgv(ctx.argv) || deps.env.MIEWEB_OS_URL?.trim(); + const instanceUrl = explicit ? normalizeInstanceUrl(explicit) : resolveInstanceUrl(deps.env, ctx.targetConfig); + if (deps.env.MIEWEB_OS_TOKEN?.trim()) { + logger.warn('MIEWEB_OS_TOKEN is set in the environment; logout cannot clear it. Unset it to fully log out.'); + } + // Log out locally either way; the key stays queued (with its token) until + // the Manager confirms it's revoked. Earlier failures are retried too. + const wasLoggedIn = await removeCredential(deps.env, instanceUrl); + await revokeAll(instanceUrl, ctx, deps, logger); + logger.info(wasLoggedIn ? `Logged out of ${instanceUrl}` : `Not logged in to ${instanceUrl}`); +} diff --git a/packages/os-cloud-provider/src/client.ts b/packages/os-cloud-provider/src/client.ts new file mode 100644 index 00000000..84aa6424 --- /dev/null +++ b/packages/os-cloud-provider/src/client.ts @@ -0,0 +1,107 @@ +/** + * Manager API client: `openapi-fetch` typed by the Manager's own OpenAPI spec + * (`src/generated/manager-api.ts`), plus what the provider needs on top: + * Bearer auth, `{ data }` envelope unwrapping, 401/403 → AuthError, + * AbortSignal support, and retrying idempotent GETs on dropped connections. + * + * Only `Authorization: Bearer` is sent, no cookies, so the Manager's CSRF + * guard skips these requests (middlewares/api.js). + */ + +import { AuthError } from '@mieweb/deploy-contract'; +import type { DeployTarget } from '@mieweb/deploy-contract'; +import createClient, { type Client } from 'openapi-fetch'; +import pRetry from 'p-retry'; +import { LOGIN_HINT, PROVIDER_NAME } from './config.ts'; +import type { paths } from './generated/manager-api.ts'; + +/** A non-auth error response from the Manager. */ +export class ManagerApiError extends Error { + readonly status: number; + readonly code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.name = 'ManagerApiError'; + this.status = status; + this.code = code; + } +} + +export interface ClientOptions { + instanceUrl: string; + token: string | null; + target: DeployTarget; + signal?: AbortSignal; + fetch?: typeof fetch; +} + +/** The `data` member of an endpoint's `{ data }` success envelope. */ +type Envelope = T extends { data?: infer D } ? D : never; + +export type ManagerApi = Client; + +export class ManagerClient { + readonly instanceUrl: string; + readonly target: DeployTarget; + readonly api: ManagerApi; + private readonly token: string | null; + private readonly signal: AbortSignal | undefined; + + constructor(opts: ClientOptions) { + this.instanceUrl = opts.instanceUrl; + this.target = opts.target; + this.token = opts.token; + this.signal = opts.signal; + const base = opts.fetch ?? globalThis.fetch; + const signal = opts.signal; + this.api = createClient({ + baseUrl: `${opts.instanceUrl}/api/v1`, + headers: opts.token ? { Authorization: `Bearer ${opts.token}` } : {}, + // GETs are idempotent: retry them on network-level failures (a dropped + // keep-alive socket mid job-poll shouldn't fail a deploy). Writes are + // never retried. + fetch: (req: Request) => { + const once = (): Promise => base(req.clone(), { signal, redirect: 'manual' }); + return req.method === 'GET' ? pRetry(once, { retries: 3, minTimeout: 500, signal }) : once(); + }, + }); + } + + authError(): AuthError { + return new AuthError(PROVIDER_NAME, this.target, LOGIN_HINT); + } + + /** + * Run one typed request and return the `data` payload of its envelope. + * + * const site = await client.call((api) => api.GET('/sites/{id}', { params: { path: { id } } })); + */ + async call( + request: (api: ManagerApi) => Promise, + opts: { auth?: boolean } = {}, + ): Promise>> { + if (opts.auth !== false && !this.token) throw this.authError(); + let result: R; + try { + result = await request(this.api); + } catch (err) { + if (this.signal?.aborted) throw this.signal.reason ?? err; + if ((err as Error).name === 'AbortError') throw err; + if (err instanceof SyntaxError) { + throw new ManagerApiError(0, 'bad_response', `Unexpected non-JSON response from ${this.instanceUrl}`); + } + throw new Error(`Cannot reach the Manager at ${this.instanceUrl}: ${(err as Error).message}`, { cause: err }); + } + const { response } = result; + const where = `${new URL(response.url || this.instanceUrl).pathname}`; + if (response.status === 401 || response.status === 403) throw this.authError(); + if (!response.ok) { + const body = result.error as { error?: { code?: string; message?: string } } | string | undefined; + const e = typeof body === 'object' ? body?.error : undefined; + const code = e?.code ?? `http_${response.status}`; + const message = e?.message ?? (typeof body === 'string' && body ? body.slice(0, 200) : response.statusText); + throw new ManagerApiError(response.status, code, `${where} failed (${response.status} ${code}): ${message}`); + } + return (result.data as { data?: unknown } | undefined)?.data as Envelope>; + } +} diff --git a/packages/os-cloud-provider/src/config.ts b/packages/os-cloud-provider/src/config.ts new file mode 100644 index 00000000..a603ac39 --- /dev/null +++ b/packages/os-cloud-provider/src/config.ts @@ -0,0 +1,218 @@ +/** + * Configuration resolution: instance URL, API token, and the non-secret + * `targets.mieweb` block of mieweb.jsonc. + * + * Precedence (issue #475 §3): + * token: env.MIEWEB_OS_TOKEN → machine-local login cache (never config) + * instance URL: env.MIEWEB_OS_URL → targetConfig.instanceUrl → default + */ + +import type { DeployContext, ProviderEnv } from '@mieweb/deploy-contract'; +import { readCredential } from './credentials.ts'; + +export const PROVIDER_NAME = 'opensource-server'; +export const DEFAULT_INSTANCE_URL = 'https://os.mieweb.org'; +export const DEFAULT_IMAGE = 'ghcr.io/mieweb/opensource-server/cloud:latest'; +export const DEFAULT_PORT = 8787; +/** The one rw persistent volume the converged container's datastores live on (#421). */ +export const DATA_VOLUME = { name: 'data', mountPath: '/mnt/data', mode: 'rw' } as const; + +/** Same rule the Manager enforces on `Container.hostname` (models/container.js). */ +export const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +export const LOGIN_HINT = 'set MIEWEB_OS_TOKEN, or run `mieweb login --target mieweb`'; + +/** Thrown for configuration problems the user must fix (not auth, not backend). */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +/** + * Canonical instance URL: scheme + host (+ port) + path without a trailing + * slash or a trailing `/api/v1`. It is also the key for the login cache, so + * `https://OS.mieweb.org/` and `https://os.mieweb.org` share one entry. + */ +export function normalizeInstanceUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + throw new ConfigError(`Invalid instance URL: ${JSON.stringify(raw)}`); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new ConfigError(`Instance URL must be http(s): ${JSON.stringify(raw)}`); + } + if (url.username || url.password) { + throw new ConfigError('Instance URL must not contain credentials'); + } + let path = url.pathname.replace(/\/+$/, ''); + if (path.endsWith('/api/v1')) path = path.slice(0, -'/api/v1'.length); + return `${url.protocol}//${url.host}${path}`; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; +} + +/** Instance URL for all verbs except `login` (see {@link loginInstanceUrl}). */ +export function resolveInstanceUrl(env: ProviderEnv, targetConfig: Readonly>): string { + return normalizeInstanceUrl(str(env.MIEWEB_OS_URL) ?? str(targetConfig.instanceUrl) ?? DEFAULT_INSTANCE_URL); +} + +/** Value of `--instance ` / `--instance=` in passthrough argv, if any. */ +export function instanceFromArgv(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]!; + if (a === '--instance') return str(argv[i + 1]); + if (a.startsWith('--instance=')) return str(a.slice('--instance='.length)); + } + return undefined; +} + +export interface ResolvedToken { + token: string; + method: 'env' | 'login'; +} + +/** Token for `instanceUrl`, or null when neither env nor the login cache has one. */ +export async function resolveToken(env: ProviderEnv, instanceUrl: string): Promise { + const fromEnv = str(env.MIEWEB_OS_TOKEN); + if (fromEnv) return { token: fromEnv, method: 'env' }; + const cached = await readCredential(env, instanceUrl); + return cached ? { token: cached.token, method: 'login' } : null; +} + +/** Extra non-HTTP service declared in `targets.mieweb.services`. */ +export interface ExtraService { + type: 'tcp' | 'udp' | 'srv'; + internalPort: number; + dnsName?: string; +} + +/** The parts of `targets.mieweb` deploy/destroy use. All non-secret. */ +export interface TargetSettings { + instanceUrl: string; + /** Manager site; undefined → chosen at deploy time (see resolveSiteId in deploy.ts). */ + siteId?: number; + image: string; + port: number; + /** External hostname label; defaults to the app name. */ + externalHostname: string; + /** External domain, by name or id; defaults to the site's first domain. */ + domain?: string | number; + authRequired: boolean; + nvidia?: boolean; + services: ExtraService[]; + /** Sync the worktree into the container after converging (default true). */ + sync: boolean; + /** SSH login for the sync (default: the Manager account). */ + sshUser?: string; + /** SSH host override (default: the container's published sshHost). */ + sshHost?: string; + /** Start command inside the container (default `npm start`). */ + start?: string; +} + +function posInt(v: unknown, what: string): number { + const n = typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v; + if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0) { + throw new ConfigError(`${what} must be a positive integer, got ${JSON.stringify(v)}`); + } + return n; +} + +function port(v: unknown, what: string): number { + const n = posInt(v, what); + if (n > 65535) throw new ConfigError(`${what} must be a TCP port (1-65535), got ${n}`); + return n; +} + +/** App name from wrangler.jsonc `name`; must be a DNS label (it becomes the hostname). */ +export function appName(manifest: Readonly>): string { + const name = manifest.name; + if (typeof name !== 'string' || name === '') { + throw new ConfigError('wrangler.jsonc must set `name` (it is used as the container hostname)'); + } + if (!DNS_LABEL.test(name)) { + throw new ConfigError( + `wrangler.jsonc \`name\` ${JSON.stringify(name)} is not a valid DNS label ` + + '(1-63 chars of a-z, 0-9 and "-", starting and ending with a letter or digit)', + ); + } + return name; +} + +/** Validate `targets.mieweb` for deploy/destroy. */ +export function resolveTargetSettings(ctx: DeployContext, env: ProviderEnv): TargetSettings { + const tc = ctx.targetConfig; + const name = appName(ctx.manifest); + const target = `targets.${ctx.target}`; + + // MIEWEB_OS_SITE_ID → targets.mieweb.siteId → (deploy time) the only site, or a prompt. + const rawSite = str(env.MIEWEB_OS_SITE_ID) ?? (tc.siteId === null || tc.siteId === '' ? undefined : tc.siteId); + const siteId = rawSite === undefined ? undefined : posInt(rawSite, str(env.MIEWEB_OS_SITE_ID) ? 'MIEWEB_OS_SITE_ID' : `${target}.siteId`); + + const externalHostname = str(tc.externalHostname) ?? name; + if (!DNS_LABEL.test(externalHostname)) { + throw new ConfigError(`${target}.externalHostname ${JSON.stringify(externalHostname)} is not a valid DNS label`); + } + + let domain: string | number | undefined; + if (tc.domain !== undefined) { + if (typeof tc.domain === 'number') domain = posInt(tc.domain, `${target}.domain`); + else if (str(tc.domain)) domain = str(tc.domain); + else throw new ConfigError(`${target}.domain must be a domain name or id`); + } + + if (tc.authRequired !== undefined && typeof tc.authRequired !== 'boolean') { + throw new ConfigError(`${target}.authRequired must be a boolean`); + } + if (tc.sync !== undefined && typeof tc.sync !== 'boolean') { + throw new ConfigError(`${target}.sync must be a boolean`); + } + if (tc.source !== undefined || tc.ref !== undefined) { + throw new ConfigError( + `${target}.source/.ref are no longer used: deploy syncs your local worktree into the container over SSH`, + ); + } + if (tc.nvidia !== undefined && typeof tc.nvidia !== 'boolean') { + throw new ConfigError(`${target}.nvidia must be a boolean`); + } + + const services: ExtraService[] = []; + if (tc.services !== undefined) { + if (!Array.isArray(tc.services)) throw new ConfigError(`${target}.services must be an array`); + for (const [i, raw] of tc.services.entries()) { + const s = (raw ?? {}) as Record; + if (s.type !== 'tcp' && s.type !== 'udp' && s.type !== 'srv') { + throw new ConfigError(`${target}.services[${i}].type must be tcp, udp or srv (the HTTP service is implicit)`); + } + const svc: ExtraService = { type: s.type, internalPort: port(s.internalPort, `${target}.services[${i}].internalPort`) }; + if (s.type === 'srv') { + const dnsName = str(s.dnsName); + if (!dnsName) throw new ConfigError(`${target}.services[${i}].dnsName is required for srv services`); + svc.dnsName = dnsName; + } + services.push(svc); + } + } + + return { + instanceUrl: resolveInstanceUrl(env, tc), + siteId, + image: str(tc.image) ?? DEFAULT_IMAGE, + port: tc.port === undefined ? DEFAULT_PORT : port(tc.port, `${target}.port`), + externalHostname, + domain, + authRequired: tc.authRequired === true, + nvidia: tc.nvidia as boolean | undefined, + services, + sync: tc.sync !== false, + sshUser: str(env.MIEWEB_OS_SSH_USER) ?? str(tc.sshUser), + sshHost: str(tc.sshHost), + start: str(tc.start), + }; +} diff --git a/packages/os-cloud-provider/src/credentials.ts b/packages/os-cloud-provider/src/credentials.ts new file mode 100644 index 00000000..6522d330 --- /dev/null +++ b/packages/os-cloud-provider/src/credentials.ts @@ -0,0 +1,151 @@ +/** + * Machine-local login cache: `~/.mieweb/os.json`, keyed by instance URL so a + * user can be logged into os.mieweb.org and a self-hosted instance at once + * (mirrors wrangler's `~/.wrangler` cache). Written 0600 in a 0700 directory. + * + * `MIEWEB_OS_CREDENTIALS` (from the provider env) overrides the file path. + */ + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { ProviderEnv } from '@mieweb/deploy-contract'; +import { updateLockedFile } from './locked-file.ts'; + +export interface StoredCredential { + token: string; + /** API key id, so `logout` can revoke it server-side. */ + apiKeyId: string; + user?: string; + savedAt: string; +} + +/** A key that is no longer used but couldn't be revoked yet. */ +export interface PendingRevocation { + token: string; + apiKeyId: string; +} + +interface CredentialFile { + version: 1; + instances: Record; + /** Per instance: keys whose server-side revocation failed, retried by login/logout. */ + pendingRevocations: Record; +} + +export function credentialsPath(env: ProviderEnv): string { + const override = env.MIEWEB_OS_CREDENTIALS?.trim(); + return override ? override : join(env.HOME?.trim() || homedir(), '.mieweb', 'os.json'); +} + +function parse(path: string, text: string | null): CredentialFile { + if (text === null) return { version: 1, instances: {}, pendingRevocations: {} }; + try { + const parsed = JSON.parse(text) as Partial; + return { + version: 1, + instances: { ...(parsed.instances ?? {}) }, + pendingRevocations: { ...(parsed.pendingRevocations ?? {}) }, + }; + } catch { + throw new Error(`Credential cache ${path} is not valid JSON; delete it and run \`mieweb login\` again`); + } +} + +async function load(path: string): Promise { + try { + return parse(path, await readFile(path, 'utf8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return parse(path, null); + throw err; + } +} + +/** + * Change the cache under a cross-process lock, re-reading it first, so + * concurrent logins/logouts (e.g. to two instances) never drop each other's + * entries. `change` returns false to leave the file untouched. + */ +async function mutate(env: ProviderEnv, change: (data: CredentialFile) => boolean): Promise { + const path = credentialsPath(env); + await updateLockedFile(path, (text) => { + const data = parse(path, text); + return change(data) ? `${JSON.stringify(data, null, 2)}\n` : null; + }); +} + +export async function readCredential(env: ProviderEnv, instanceUrl: string): Promise { + const data = await load(credentialsPath(env)); + return data.instances[instanceUrl] ?? null; +} + +/** + * Store `cred` as the instance's login, queueing the key it replaces for + * revocation in the same locked update, so no concurrent login can make a + * replaced key untracked. + */ +function queue(data: CredentialFile, instanceUrl: string, key: PendingRevocation): void { + const list = data.pendingRevocations[instanceUrl] ?? []; + if (!list.some((p) => p.apiKeyId === key.apiKeyId)) { + data.pendingRevocations[instanceUrl] = [...list, { token: key.token, apiKeyId: key.apiKeyId }]; + } +} + +/** + * Queue a key for revocation. `login` queues a freshly minted key first, so a + * failure before it is stored as the login can never leave it untracked. + */ +export async function addPendingRevocation(env: ProviderEnv, instanceUrl: string, key: PendingRevocation): Promise { + await mutate(env, (data) => { + queue(data, instanceUrl, key); + return true; + }); +} + +/** + * Store `cred` as the instance's login in one locked update: the key it + * replaces is queued for revocation, and `cred`'s own key is taken off the + * queue (it was queued as a safety net until it was stored). + */ +export async function replaceCredential(env: ProviderEnv, instanceUrl: string, cred: StoredCredential): Promise { + await mutate(env, (data) => { + const previous = data.instances[instanceUrl]; + if (previous && previous.apiKeyId !== cred.apiKeyId) queue(data, instanceUrl, previous); + const rest = (data.pendingRevocations[instanceUrl] ?? []).filter((p) => p.apiKeyId !== cred.apiKeyId); + if (rest.length > 0) data.pendingRevocations[instanceUrl] = rest; + else delete data.pendingRevocations[instanceUrl]; + data.instances[instanceUrl] = cred; + return true; + }); +} + +/** Remove the instance's login, queueing its key for revocation in the same locked update. */ +export async function removeCredential(env: ProviderEnv, instanceUrl: string): Promise { + let had = false; + await mutate(env, (data) => { + const cred = data.instances[instanceUrl]; + if (!cred) return false; + had = true; + queue(data, instanceUrl, cred); + delete data.instances[instanceUrl]; + return true; + }); + return had; +} + +export async function readPendingRevocations(env: ProviderEnv, instanceUrl: string): Promise { + return (await load(credentialsPath(env))).pendingRevocations[instanceUrl] ?? []; +} + +/** Drop keys from the queue once the Manager confirmed they're revoked. */ +export async function removePendingRevocations(env: ProviderEnv, instanceUrl: string, apiKeyIds: readonly string[]): Promise { + if (apiKeyIds.length === 0) return; + await mutate(env, (data) => { + const list = data.pendingRevocations[instanceUrl] ?? []; + const rest = list.filter((p) => !apiKeyIds.includes(p.apiKeyId)); + if (rest.length === list.length) return false; + if (rest.length > 0) data.pendingRevocations[instanceUrl] = rest; + else delete data.pendingRevocations[instanceUrl]; + return true; + }); +} diff --git a/packages/os-cloud-provider/src/deploy.ts b/packages/os-cloud-provider/src/deploy.ts new file mode 100644 index 00000000..8c99100c --- /dev/null +++ b/packages/os-cloud-provider/src/deploy.ts @@ -0,0 +1,829 @@ +/** + * `deploy` / `destroy`: converge one container per app on a Manager site. + * + * Identity is the hostname (= wrangler.jsonc `name`), unique per site, so + * deploy is an upsert: + * + * list_containers?hostname= + * ├─ none → create_container (retry as update on 409 conflict) + * ├─ same image/GPU → update_container (services diff, full env, restart) + * └─ image/GPU drift → delete_container + create_container + * (`template`/`nvidiaRequested` are create-only; the + * /mnt/data volume is retained on delete (#421), so + * datastore state survives the recreate) + * + * then poll the job and read the container back for the URL + VMID. + */ + +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { DeployContext, DeployResult, ProviderEnv } from '@mieweb/deploy-contract'; +import pRetry from 'p-retry'; +import { quote } from 'shell-quote'; +import type { Container, EnvVar, NewContainerForm, ServiceUpdate, UpdateBody } from './api-types.ts'; +import { ManagerApiError, ManagerClient } from './client.ts'; +import { + appName, + ConfigError, + DATA_VOLUME, + resolveTargetSettings, + resolveToken, + type ExtraService, + type TargetSettings, +} from './config.ts'; +import { waitForJob } from './jobs.ts'; +import { ttyPrompter, type Prompter } from './prompt.ts'; +import { forgetHostKey, knownHostsPath, SshConnection, SshError, type RemoteShell, type SshTarget } from './ssh.ts'; +import { lineSplitter, syncWorktree } from './sync.ts'; + +/** Env keys the provider owns inside the converged container. */ +export const MANAGED_ENV = { + port: 'PORT', + target: 'MIEWEB_TARGET', + start: 'MIEWEB_APP_START', + minioUser: 'MINIO_ROOT_USER', + minioPassword: 'MINIO_ROOT_PASSWORD', + s3Endpoint: 'MIEWEB_S3_ENDPOINT', + s3AccessKey: 'MIEWEB_S3_ACCESS_KEY_ID', + s3SecretKey: 'MIEWEB_S3_SECRET_ACCESS_KEY', + libsqlUrl: 'MIEWEB_LIBSQL_URL', + valkeyUrl: 'MIEWEB_VALKEY_URL', + sshAllowUsers: 'MIEWEB_SSH_ALLOW_USERS', +} as const; + +/** + * Account names sshd matches literally (no patterns or separators). Must + * match the cloud image's `mieweb-ssh-allow` filter. + */ +const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}$/; + +/** + * Who may SSH into the converged container: its owner and collaborators, the + * account deploying (an admin may deploy someone else's app) and the sync + * login. The cloud image restricts sshd to exactly these accounts, because + * the container holds app secrets and datastore files and every LDAP user + * otherwise has SSH + passwordless sudo on every container. + */ +export function sshAllowUsers(names: readonly (string | null | undefined)[]): string[] { + const present = names.filter((n): n is string => !!n); + // Never drop a name silently: the allow-list must be exactly who should + // have access, or the container could end up open (or locking out the owner). + const bad = present.filter((n) => !SSH_USER_RE.test(n)); + if (bad.length > 0) { + throw new ConfigError( + `Can't restrict SSH to account name(s) ${bad.map((n) => JSON.stringify(n)).join(', ')}: only letters, digits, '.', '_' and '-' are supported`, + ); + } + return [...new Set(present)].sort(); +} + +/** Names the Manager accepts for container env vars (models/container.js). */ +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Provider env vars with this prefix are injected (prefix stripped) as app secrets. */ +export const SECRET_ENV_PREFIX = 'MIEWEB_OS_SECRET_'; + +/** + * Same normalization the Manager applies to `template` on create + * (normalizeDockerRef in routers/api/v1/containers.js), so a stored template + * can be compared with the configured image. + */ +export function normalizeImageRef(ref: string): string { + if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('git@')) return ref; + let tag = 'latest'; + let imagePart = ref; + const lastColon = ref.lastIndexOf(':'); + if (lastColon !== -1) { + const potentialTag = ref.substring(lastColon + 1); + if (!potentialTag.includes('/')) { + tag = potentialTag; + imagePart = ref.substring(0, lastColon); + } + } + const parts = imagePart.split('/'); + let host = 'docker.io'; + let org = 'library'; + let image: string; + if (parts.length === 1) { + image = parts[0]!; + } else if (parts.length === 2) { + if (parts[0]!.includes('.') || parts[0]!.includes(':')) { + host = parts[0]!; + image = parts[1]!; + } else { + org = parts[0]!; + image = parts[1]!; + } + } else { + host = parts[0]!; + image = parts[parts.length - 1]!; + org = parts.slice(1, -1).join('/'); + } + return `${host}/${org}/${image}:${tag}`; +} + +/** Pick the external domain to expose the app under. */ +export function pickDomain(form: NewContainerForm, wanted: string | number | undefined): { id: number; name: string } { + const domains = form.externalDomains ?? []; + if (wanted !== undefined) { + const hit = domains.find((d) => (typeof wanted === 'number' ? d.id === wanted : d.name === wanted)); + if (!hit) { + const names = domains.map((d) => `${d.name} (${d.id})`).join(', ') || 'none'; + throw new ConfigError(`External domain ${JSON.stringify(wanted)} is not available on this site (available: ${names})`); + } + return { id: hit.id, name: hit.name }; + } + // The Manager sorts the site's own default domains first. + const first = domains[0]; + if (!first) throw new ConfigError('The site has no external domains; ask an admin to add one, or set targets.mieweb.domain'); + return { id: first.id, name: first.name }; +} + +function envString(v: unknown): string { + return typeof v === 'string' ? v : JSON.stringify(v); +} + +export interface EnvInputs { + manifest: Readonly>; + env: ProviderEnv; + settings: Pick; + /** Current env map of the existing container (read shape is an object). */ + existing?: Record; + /** Accounts allowed to SSH in (see {@link sshAllowUsers}). */ + sshAllowUsers?: readonly string[]; + warn: (m: string) => void; +} + +/** + * The complete desired env set. `update_container` treats `environmentVars` + * as a full replacement, so this always returns everything. + * + * Order of precedence (later wins): wrangler `vars` → MIEWEB_OS_SECRET_* from + * the provider env → provider-managed keys (warned on collision). + */ +export function buildEnv(inputs: EnvInputs): EnvVar[] { + const out = new Map(); + const vars = inputs.manifest.vars; + if (vars && typeof vars === 'object' && !Array.isArray(vars)) { + for (const [k, v] of Object.entries(vars)) out.set(k, envString(v)); + } + for (const [k, v] of Object.entries(inputs.env)) { + if (k.startsWith(SECRET_ENV_PREFIX) && k.length > SECRET_ENV_PREFIX.length && v !== undefined) { + out.set(k.slice(SECRET_ENV_PREFIX.length), v); + } + } + + // Reuse the generated MinIO secret: the data on /mnt/data was written with + // it, so it must survive redeploys and image-change recreates. + const minioPassword = + inputs.existing?.[MANAGED_ENV.minioPassword] || randomBytes(24).toString('base64url'); + const minioUser = inputs.existing?.[MANAGED_ENV.minioUser] || 'mieweb'; + + const managed: [string, string | undefined][] = [ + [MANAGED_ENV.port, String(inputs.settings.port)], + [MANAGED_ENV.target, 'mieweb'], + [MANAGED_ENV.start, inputs.settings.start], + [MANAGED_ENV.minioUser, minioUser], + [MANAGED_ENV.minioPassword, minioPassword], + [MANAGED_ENV.s3Endpoint, 'http://127.0.0.1:9000'], + [MANAGED_ENV.s3AccessKey, minioUser], + [MANAGED_ENV.s3SecretKey, minioPassword], + [MANAGED_ENV.libsqlUrl, 'http://127.0.0.1:8080'], + [MANAGED_ENV.valkeyUrl, 'redis://127.0.0.1:6379'], + [MANAGED_ENV.sshAllowUsers, inputs.sshAllowUsers?.length ? inputs.sshAllowUsers.join(' ') : undefined], + ]; + for (const [k, v] of managed) { + if (v === undefined) continue; + if (out.has(k) && out.get(k) !== v) inputs.warn(`Env var ${k} is managed by the provider; ignoring the app's value`); + out.set(k, v); + } + // The Manager silently drops names it can't use, and the container's + // /etc/environment is one `KEY=value` per line, so a multi-line value would + // arrive truncated. Fail instead of deploying something different. + const badNames = [...out.keys()].filter((k) => !ENV_NAME_RE.test(k)); + if (badNames.length > 0) { + throw new ConfigError( + `Unsupported environment variable name(s): ${badNames.map((k) => JSON.stringify(k)).join(', ')} ` + + '(use letters, digits and _, not starting with a digit)', + ); + } + const multiline = [...out].filter(([, v]) => /[\r\n\0]/.test(v)).map(([k]) => k); + if (multiline.length > 0) { + throw new ConfigError( + `Environment variable(s) ${multiline.join(', ')} contain line breaks, which the container's environment can't carry; ` + + 'encode them (e.g. base64) and decode in the app', + ); + } + return [...out.entries()].map(([key, value]) => ({ key, value })); +} + +export interface DesiredHttp { + internalPort: number; + externalHostname: string; + externalDomainId: number; + authRequired: boolean; +} + +/** + * Diff the container's services against the desired set and produce an + * `update_container` services map. + * + * The provider owns the container's exposure: one HTTP service, plus the + * non-HTTP services in `extras` (which always include the provider's SSH + * service). Each desired service is matched to at most one existing service; + * every unmatched existing service is deleted, so removing an entry from + * `targets.mieweb.services` closes that port on the next deploy. + */ +export function planServices( + current: Container['services'], + http: DesiredHttp, + extras: readonly ExtraService[], +): Record { + const plan: Record = {}; + let keptHttp = false; + for (const svc of current ?? []) { + if (svc.type !== 'http' || svc.id === undefined) continue; + const h = svc.httpService; + const matches = + !keptHttp && + svc.internalPort === http.internalPort && + h?.externalHostname === http.externalHostname && + h?.externalDomainId === http.externalDomainId && + (h?.backendProtocol ?? 'http') === 'http'; + if (matches) { + keptHttp = true; + // Existing entries may only toggle authRequired. + plan[`keep-${svc.id}`] = { id: svc.id, type: 'http', internalPort: http.internalPort, authRequired: http.authRequired }; + } else { + plan[`del-${svc.id}`] = { id: svc.id, deleted: true, type: 'http', internalPort: svc.internalPort ?? 0 }; + } + } + if (!keptHttp) { + plan.http = { + type: 'http', + internalPort: http.internalPort, + externalHostname: http.externalHostname, + externalDomainId: http.externalDomainId, + authRequired: http.authRequired, + }; + } + + const others = (current ?? []).filter((svc) => svc.type !== 'http' && svc.id !== undefined); + const matched = new Set(); + extras.forEach((want, i) => { + const hit = others.find( + (svc) => + !matched.has(svc.id!) && + svc.internalPort === want.internalPort && + (want.type === 'srv' + ? svc.type === 'dns' && svc.dnsService?.dnsName === want.dnsName + : svc.type === 'transport' && svc.transportService?.protocol === want.type), + ); + if (hit) matched.add(hit.id!); + else plan[`extra-${i}`] = { type: want.type, internalPort: want.internalPort, ...(want.dnsName ? { dnsName: want.dnsName } : {}) }; + }); + for (const svc of others) { + if (!matched.has(svc.id!)) { + const type = svc.type === 'dns' ? 'srv' : (svc.transportService?.protocol ?? 'tcp'); + plan[`del-${svc.id}`] = { id: svc.id, deleted: true, type, internalPort: svc.internalPort ?? 0 }; + } + } + return plan; +} + +/** The SSH service the code sync uses; always requested. */ +export const SSH_SERVICE: ExtraService = { type: 'tcp', internalPort: 22 }; + +function withSsh(extras: readonly ExtraService[]): ExtraService[] { + return extras.some((e) => e.type === 'tcp' && e.internalPort === 22) ? [...extras] : [SSH_SERVICE, ...extras]; +} + +/** True when applying `plan` to `current` would change nothing. */ +export function servicesUnchanged(current: Container['services'], plan: Record): boolean { + return Object.entries(plan).every(([key, entry]) => { + if (!key.startsWith('keep-')) return false; + const svc = (current ?? []).find((c) => c.id === entry.id); + return (svc?.httpService?.authRequired ?? false) === (entry.authRequired ?? false); + }); +} + +export function envUnchanged(current: unknown, desired: readonly EnvVar[]): boolean { + const cur = asEnvMap(current); + return ( + Object.keys(cur).length === desired.length && desired.every((e) => cur[e.key ?? ''] === (e.value ?? '')) + ); +} + +function asEnvMap(v: unknown): Record { + if (Array.isArray(v)) return Object.fromEntries(v.map((e: EnvVar) => [e.key ?? '', e.value ?? ''])); + return v && typeof v === 'object' ? (v as Record) : {}; +} + +/** Whether AI is bound in wrangler.jsonc (suggests a GPU node). */ +function wantsAi(manifest: Readonly>): boolean { + return manifest.ai !== undefined && manifest.ai !== null; +} + +export interface ProviderDeps { + env: ProviderEnv; + fetch?: typeof fetch; + /** Job poll interval (tests shorten it). */ + pollIntervalMs?: number; + /** Open the SSH session used for the code sync (tests inject a fake). */ + connectSsh?: ( + target: SshTarget, + opts: { knownHostsFile: string; signal: AbortSignal; logger: DeployContext['logger']; prompt: Prompter }, + ) => Promise; + /** Terminal prompt for passphrases/passwords. */ + prompt?: Prompter; + /** Total time to keep trying to reach SSH (default 60 s; tests shorten it). */ + sshTimeoutMs?: number; + /** Delay between SSH connection attempts (default 2 s). */ + sshRetryDelayMs?: number; +} + +async function clientFor(ctx: DeployContext, deps: ProviderDeps, instanceUrl: string): Promise { + const tok = await resolveToken(deps.env, instanceUrl); + return new ManagerClient({ instanceUrl, token: tok?.token ?? null, target: ctx.target, signal: ctx.signal, fetch: deps.fetch }); +} + +async function findByHostname(client: ManagerClient, siteId: number, hostname: string): Promise { + const list = await client.call((api) => + api.GET('/sites/{siteId}/containers', { params: { path: { siteId }, query: { hostname } } }), + ); + return list?.find((c) => c.hostname === hostname) ?? null; +} + +function getContainer(client: ManagerClient, siteId: number, id: number): Promise { + return client.call((api) => api.GET('/sites/{siteId}/containers/{id}', { params: { path: { siteId, id } } })); +} + +async function deleteContainer(client: ManagerClient, siteId: number, id: number, logger: DeployContext['logger']): Promise { + const res = await client.call((api) => api.DELETE('/sites/{siteId}/containers/{id}', { params: { path: { siteId, id } } })); + for (const w of res?.dnsWarnings ?? []) logger.warn(w); +} + +/** + * The site to deploy into when none is configured: the only site the user can + * see, otherwise ask on the terminal. Non-interactive runs get an error that + * lists the choices. + */ +export async function resolveSiteId( + configured: number | undefined, + client: ManagerClient, + deps: ProviderDeps, + ctx: DeployContext, +): Promise { + if (configured !== undefined) return configured; + const { logger, target } = ctx; + const sites = ((await client.call((api) => api.GET('/sites'))) ?? []).flatMap((x) => + x.id === undefined ? [] : [{ id: x.id, name: x.name ?? `site ${x.id}` }], + ); + type SiteSummary = (typeof sites)[number]; + // Save the choice to mieweb.jsonc when the host supports it (CLI >= this + // contract); otherwise tell the user what to set. + const remember = async (site: SiteSummary, why: string): Promise => { + logger.info(`Using site ${site.id} (${site.name})${why}`); + const saved = await ctx.persistTargetConfig?.({ siteId: site.id }).catch((err: unknown) => { + logger.warn(`Could not save siteId to mieweb.jsonc: ${err instanceof Error ? err.message : String(err)}`); + return false; + }); + if (!saved) logger.info(`Set targets.${target}.siteId to ${site.id} in mieweb.jsonc (or MIEWEB_OS_SITE_ID) to skip this`); + return site.id; + }; + if (sites.length === 0) throw new ConfigError('No Manager sites are visible to your account'); + const [only] = sites; + if (sites.length === 1 && only) return remember(only, ', the only one available'); + const list = sites.map((x) => ` ${x.id}) ${x.name}`).join('\n'); + const prompt = deps.prompt ?? ttyPrompter; + for (;;) { + const answer = await prompt(`Manager sites:\n${list}\nSite to deploy into: `, false); + if (answer === null) { + throw new ConfigError(`targets.${target}.siteId is required (the Manager site to deploy into). Available:\n${list}`); + } + const pick = sites.find((x) => String(x.id) === answer.trim() || x.name === answer.trim()); + if (pick) return remember(pick, ''); + logger.warn(`"${answer.trim()}" is not one of the listed sites`); + } +} + +export async function deploy(ctx: DeployContext, deps: ProviderDeps): Promise { + const { logger, signal } = ctx; + const name = appName(ctx.manifest); + const s = resolveTargetSettings(ctx, deps.env); + // app.service only starts once /opt/app/src/package.json exists; fail before + // creating anything rather than after a confusing "app stopped" timeout. + if (s.sync && !existsSync(join(ctx.root, 'package.json'))) { + throw new ConfigError(`${ctx.root} has no package.json; the container runs the app with npm`); + } + const client = await clientFor(ctx, deps, s.instanceUrl); + const siteId = await resolveSiteId(s.siteId, client, deps, ctx); + const image = normalizeImageRef(s.image); + logger.info(`Deploying "${name}" to site ${siteId} on ${s.instanceUrl}`); + logger.info(`Image: ${image}`); + + const form = await client.call((api) => api.GET('/sites/{siteId}/containers/new', { params: { path: { siteId } } })); + if (!form) throw new Error(`Site ${siteId} returned no container form`); + const domain = pickDomain(form, s.domain); + let nvidia = s.nvidia ?? false; + if (s.nvidia === undefined && wantsAi(ctx.manifest)) { + nvidia = form.nvidiaAvailable; + logger.info( + nvidia + ? 'AI binding found; requesting an NVIDIA node' + : 'AI binding found but the site has no NVIDIA node; deploying without a GPU', + ); + } + + const extras = withSsh(s.services); + const http: DesiredHttp = { + internalPort: s.port, + externalHostname: s.externalHostname, + externalDomainId: domain.id, + authRequired: s.authRequired, + }; + const account = (await client.call((api) => api.GET('/session')))?.user; + const envFor = (existing?: Container | null): EnvVar[] => + buildEnv({ + manifest: ctx.manifest, + env: deps.env, + settings: s, + existing: existing ? asEnvMap(existing.environmentVars) : undefined, + // A new container is owned by the deploying account. + sshAllowUsers: sshAllowUsers([ + existing?.owner ?? account, + ...(existing?.collaborators ?? []), + account, + s.sshUser ?? account, + ]), + warn: (m) => logger.warn(m), + }); + + // Values to mask in relayed job output (older Managers log the full LXC + // config, whose `env` holds every variable). Filled as env sets are built. + const secrets = new Set(); + const secretKeys = new Set([MANAGED_ENV.minioPassword, MANAGED_ENV.s3SecretKey]); + for (const k of Object.keys(deps.env)) { + if (k.startsWith(SECRET_ENV_PREFIX) && k.length > SECRET_ENV_PREFIX.length) secretKeys.add(k.slice(SECRET_ENV_PREFIX.length)); + } + const envWithSecrets = (existing?: Container | null): EnvVar[] => { + const env = envFor(existing); + for (const e of env) if (e.key && secretKeys.has(e.key) && e.value) secrets.add(e.value); + return env; + }; + const wait = (jobId: number): Promise => + waitForJob(client, jobId, { signal, logger, intervalMs: deps.pollIntervalMs, redact: secrets }); + + /** + * Let an in-flight create (a concurrent or interrupted deploy) finish, then + * re-read the container. Null if it no longer exists. + */ + const settle = async (c: Container): Promise => { + if (c.containerId || c.status !== 'creating' || !c.creationJobId) return c; + logger.info(`Container ${c.id} is still being created; waiting for job ${c.creationJobId}`); + // Its outcome is judged below from the re-read; only an abort stops us. + await wait(c.creationJobId).catch((err: unknown) => { + if (signal.aborted) throw err; + }); + return findByHostname(client, siteId, name); + }; + /** Why `c` must be deleted and recreated rather than updated, if it must. */ + const driftOf = (c: Container): string[] => { + const drift: string[] = []; + // Never provisioned (failed/missing create); an update can't fix that. + if (!c.containerId) drift.push(`not provisioned (status ${c.status ?? 'unknown'})`); + if (c.template && normalizeImageRef(c.template) !== image) drift.push(`image ${c.template} → ${image}`); + if (!!c.nvidiaRequested !== nvidia) drift.push(`nvidia ${!!c.nvidiaRequested} → ${nvidia}`); + return drift; + }; + + let existing = await findByHostname(client, siteId, name); + // The env of the container being replaced: reused so the MinIO credentials + // that own the data on /mnt/data survive recreates. + let carryEnv: Container | null = existing; + let createdId: number | undefined; + let adopted = false; + // A lost create race hands us someone else's container, which goes through + // the same settle/drift checks; bound the loop in case of repeated races. + for (let round = 0; createdId === undefined; round += 1) { + if (existing) { + existing = await settle(existing); + if (existing) carryEnv = existing; + } + const drift = existing ? driftOf(existing) : []; + if (existing && drift.length > 0) { + logger.info(`Recreating container ${existing.id} (${drift.join(', ')}); ${DATA_VOLUME.mountPath} is retained`); + await deleteContainer(client, siteId, existing.id!, logger); + existing = null; + } + if (existing) break; + if (round >= 2) throw new Error(`Could not create container "${name}": it kept being created concurrently`); + const created = await createOrAdopt(client, siteId, s, name, image, nvidia, envWithSecrets(carryEnv), http, logger); + if ('adopted' in created) { + // Someone else's create just won the race: the container behind this + // host:port may be new too, so its host-key pin must be cleared. + adopted = true; + existing = created.adopted; + continue; + } + createdId = created.created.containerId!; + logger.info(`Created container ${createdId}; waiting for job ${created.created.jobId}`); + await wait(created.created.jobId!); + } + // Set when this deploy (re)created the container: its SSH host key is new. + const fresh = createdId !== undefined || adopted; + const id = createdId ?? existing!.id!; + + if (existing) { + const services = planServices(existing.services, http, extras); + const environmentVars = envWithSecrets(carryEnv); + const body: UpdateBody = { + services, + environmentVars, + entrypoint: existing.entrypoint ?? null, + restart: true, + }; + let changed = !servicesUnchanged(existing.services, services) || !envUnchanged(existing.environmentVars, environmentVars); + // `volumes` is absent on Managers that predate volumes (#421); warned about below. + const dataVolume = existing.volumes?.find((v) => v.mountPath === DATA_VOLUME.mountPath); + if (existing.volumes && !dataVolume) { + body.volumes = [DATA_VOLUME]; + changed = true; + logger.info(`Attaching the ${DATA_VOLUME.mountPath} data volume`); + } else if (dataVolume && dataVolume.mode !== 'rw') { + throw new ConfigError( + `${DATA_VOLUME.mountPath} is attached read-only to container ${id}, but MinIO, libSQL and Valkey need to write ` + + 'to it. Detach it (or delete the container) and deploy again.', + ); + } else if (dataVolume && dataVolume.status !== 'ready' && dataVolume.id !== undefined) { + // A row isn't a mount: an earlier attach failed or never finished, so + // the container is using the image's (ephemeral) /mnt/data. Detach and + // re-attach, which makes the Manager provision and mount it again. + body.volumes = [{ id: dataVolume.id, detach: true }, DATA_VOLUME]; + changed = true; + logger.warn( + `The ${DATA_VOLUME.mountPath} volume is ${dataVolume.status ?? 'not ready'}` + + `${dataVolume.statusMessage ? ` (${dataVolume.statusMessage})` : ''}; re-attaching it`, + ); + } + // Code-only redeploys skip the Manager entirely and just sync. + if (!changed) { + logger.info(`Container ${id} configuration is up to date`); + } else { + const upd = await client.call((api) => + api.PUT('/sites/{siteId}/containers/{id}', { params: { path: { siteId, id } }, body }), + ); + for (const w of upd?.dnsWarnings ?? []) logger.warn(w); + if (upd?.jobId) { + logger.info(`Updated container ${id}; waiting for job ${upd.jobId}`); + await wait(upd.jobId); + } else { + logger.info(`Updated container ${id}${upd?.message ? `: ${upd.message}` : ''}`); + } + } + } + + const final = await getContainer(client, siteId, id); + if (!final?.containerId) { + throw new Error(`Container ${id} has no hypervisor id after the job finished (status: ${final?.status ?? 'unknown'})`); + } + if (final.volumes === undefined) { + logger.warn( + `This Manager does not support volumes; ${DATA_VOLUME.mountPath} is not persistent and datastore state will not survive a container recreate`, + ); + } else { + // The jobs above block until the volume is ready, so anything else here + // means the datastores would be writing to non-persistent storage. + const dv = final.volumes.find((v) => v.mountPath === DATA_VOLUME.mountPath); + if (dv?.status !== 'ready') { + throw new Error( + `The ${DATA_VOLUME.mountPath} data volume on container ${id} is ${dv ? (dv.status ?? 'not ready') : 'missing'}` + + `${dv?.statusMessage ? `: ${dv.statusMessage}` : ''}. Its data would not persist; fix the volume (see the Manager) and deploy again.`, + ); + } + } + const url = + final.httpEntries?.find((e) => e.port === s.port && e.externalUrl)?.externalUrl ?? + final.httpEntries?.find((e) => e.externalUrl)?.externalUrl ?? + undefined; + + if (s.sync) { + if (!final.sshPort || !(s.sshHost ?? final.sshHost)) { + throw new Error(`Container ${id} has no published SSH port/host; cannot sync code (set targets.${ctx.target}.sync to false to skip)`); + } + const shell = await openShell(ctx, deps, client, s, final, { fresh }); + try { + await syncWorktree(ctx.root, shell, logger, signal); + } finally { + shell.close(); + } + } else { + logger.info('Code sync disabled (sync: false)'); + } + + if (url) logger.info(`Live at ${url}`); + return { + ...(url ? { url } : {}), + resources: [{ binding: name, kind: 'container', id: String(final.containerId) }], + }; +} + +/** + * Open an SSH session to the container. A freshly created/rebuilt container + * can take a while before sshd accepts connections and (via SSSD) serves the + * user's LDAP keys, so transient failures are retried within one budget + * (default 60 s). + */ +export async function openShell( + ctx: DeployContext, + deps: ProviderDeps, + client: ManagerClient, + s: TargetSettings, + container: Container, + opts: { fresh?: boolean } = {}, +): Promise { + const { logger, signal } = ctx; + const port = container.sshPort; + const host = s.sshHost ?? container.sshHost ?? undefined; + if (!port || !host) throw new Error(`Container ${container.id} has no published SSH port/host`); + const user = s.sshUser ?? (await client.call((api) => api.GET('/session')))?.user ?? ''; + const target: SshTarget = { host, port, user }; + const knownHostsFile = knownHostsPath(deps.env); + if (opts.fresh) await forgetHostKey(knownHostsFile, host, port); + + const budgetMs = deps.sshTimeoutMs ?? 60_000; + const deadline = Date.now() + budgetMs; + // Ask for a password/passphrase at most once across attempts. Once the + // user has typed one, an auth failure is theirs to fix, not a startup race. + const answers = new Map(); + const basePrompt = deps.prompt ?? ttyPrompter; + const prompt: Prompter = async (q, hidden) => { + if (!answers.has(q)) answers.set(q, await basePrompt(q, hidden)); + return answers.get(q)!; + }; + + logger.info(`Connecting to ${user}@${host}:${port}`); + let attempts = 0; + try { + return await pRetry( + () => { + attempts += 1; + const timeoutMs = Math.min(20_000, Math.max(deadline - Date.now(), 5000)); + return deps.connectSsh + ? deps.connectSsh(target, { knownHostsFile, signal, logger, prompt }) + : SshConnection.connect({ target, env: deps.env, knownHostsFile, prompt, signal, logger, timeoutMs }); + }, + { + retries: Number.POSITIVE_INFINITY, + factor: 1, + minTimeout: deps.sshRetryDelayMs ?? 2000, + maxRetryTime: budgetMs, + signal, + shouldRetry: ({ error }) => { + const kind = error instanceof SshError ? error.kind : 'network'; + const retry = kind === 'network' || (kind === 'auth' && opts.fresh === true && answers.size === 0); + if (retry) logger.info(`SSH not ready yet (${error.message.split('. ')[0]}); retrying…`); + return retry; + }, + }, + ); + } catch (err) { + const transient = !(err instanceof SshError) || err.kind === 'network' || (err.kind === 'auth' && opts.fresh); + if (attempts > 1 && transient && !signal.aborted) { + throw new Error( + `SSH on ${host}:${port} was not usable within ${Math.round(budgetMs / 1000)}s (${attempts} attempts): ${(err as Error).message}`, + { cause: err }, + ); + } + throw err; + } +} + +export interface TailOptions { + lines: number; + follow: boolean; + since?: string; +} + +/** Parse `mieweb tail` passthrough args: `-n/--lines N`, `--no-follow`, `--since