feat: add source-mounted sf server self-deploy
This commit is contained in:
parent
55a498603f
commit
c26de39afa
17 changed files with 884 additions and 0 deletions
|
|
@ -7,6 +7,9 @@ coverage/
|
|||
# ── Dependencies ──
|
||||
node_modules/
|
||||
packages/*/node_modules/
|
||||
web/node_modules/
|
||||
web/.next/
|
||||
.next/
|
||||
|
||||
# ── Environment & secrets ──
|
||||
.env
|
||||
|
|
@ -37,6 +40,7 @@ Thumbs.db
|
|||
*~
|
||||
tmp/
|
||||
.cache/
|
||||
.gitignore
|
||||
|
||||
# ── Native build artifacts ──
|
||||
native/
|
||||
|
|
|
|||
136
.forgejo/workflows/self-deploy.yml
Normal file
136
.forgejo/workflows/self-deploy.yml
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
name: sf self-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "26.1.0"
|
||||
SF_SERVER_PORT: "4000"
|
||||
SF_REGISTRY: ${{ secrets.SF_REGISTRY }}
|
||||
SF_REGISTRY_USER: ${{ secrets.SF_REGISTRY_USER }}
|
||||
SF_REGISTRY_PASSWORD: ${{ secrets.SF_REGISTRY_PASSWORD }}
|
||||
SF_IMAGE_REPOSITORY: ${{ secrets.SF_IMAGE_REPOSITORY }}
|
||||
SF_PUSH_IMAGE: ${{ vars.SF_PUSH_IMAGE }}
|
||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
|
||||
SF_TEST_URL: ${{ vars.SF_TEST_URL }}
|
||||
SF_PROD_URL: ${{ vars.SF_PROD_URL }}
|
||||
SF_TEST_NAMESPACE: ${{ vars.SF_TEST_NAMESPACE }}
|
||||
SF_PROD_NAMESPACE: ${{ vars.SF_PROD_NAMESPACE }}
|
||||
SF_TEST_DEPLOYMENT: ${{ vars.SF_TEST_DEPLOYMENT }}
|
||||
SF_PROD_DEPLOYMENT: ${{ vars.SF_PROD_DEPLOYMENT }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build, test, and publish server image
|
||||
runs-on: docker
|
||||
outputs:
|
||||
image: ${{ steps.image.outputs.image }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm --prefix web ci
|
||||
|
||||
- name: Build core
|
||||
run: npm run build:core
|
||||
|
||||
- name: Build web host
|
||||
run: npm run build:web-host
|
||||
|
||||
- name: Typecheck extensions
|
||||
run: npm run typecheck:extensions
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Resolve image name
|
||||
id: image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo="${SF_IMAGE_REPOSITORY:-${SF_REGISTRY:-registry.centralcloud.com}/singularity/sf-server}"
|
||||
tag="${GITHUB_SHA:-${CI_COMMIT_SHA:-$(git rev-parse HEAD)}}"
|
||||
echo "image=${repo}:${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "SF_RELEASE_IMAGE=${repo}:${tag}" >> "$GITHUB_ENV"
|
||||
echo "SF_IMAGE_REPOSITORY=${repo}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate release manifest
|
||||
run: npm run release:manifest -- --out dist/sf-release-manifest.json
|
||||
|
||||
- name: Login to registry
|
||||
if: env.SF_REGISTRY_USER != '' && env.SF_REGISTRY_PASSWORD != ''
|
||||
run: |
|
||||
printf '%s' "$SF_REGISTRY_PASSWORD" | docker login "${SF_REGISTRY:-registry.centralcloud.com}" --username "$SF_REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build server image
|
||||
run: |
|
||||
docker build \
|
||||
-f docker/Dockerfile.sf-server \
|
||||
--build-arg "SF_GIT_SHA=${GITHUB_SHA:-$(git rev-parse HEAD)}" \
|
||||
--build-arg "SF_GIT_REF=${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}" \
|
||||
--build-arg "SF_RELEASE_IMAGE=${{ steps.image.outputs.image }}" \
|
||||
--build-arg "SF_IMAGE_REPOSITORY=${SF_IMAGE_REPOSITORY:-${SF_REGISTRY:-registry.centralcloud.com}/singularity/sf-server}" \
|
||||
-t "${{ steps.image.outputs.image }}" \
|
||||
.
|
||||
|
||||
- name: Push server image
|
||||
if: env.SF_PUSH_IMAGE != '0'
|
||||
run: docker push "${{ steps.image.outputs.image }}"
|
||||
|
||||
deploy-test:
|
||||
name: deploy test and probe
|
||||
needs: build
|
||||
runs-on: docker
|
||||
if: env.KUBECONFIG_B64 != '' && env.SF_TEST_URL != ''
|
||||
steps:
|
||||
- name: Configure kubeconfig
|
||||
run: |
|
||||
mkdir -p "$HOME/.kube"
|
||||
printf '%s' "$KUBECONFIG_B64" | base64 -d > "$HOME/.kube/config"
|
||||
|
||||
- name: Roll test deployment
|
||||
run: |
|
||||
kubectl -n "${SF_TEST_NAMESPACE:-sf}" set image "deployment/${SF_TEST_DEPLOYMENT:-sf-server-test}" "sf-server=${{ needs.build.outputs.image }}"
|
||||
kubectl -n "${SF_TEST_NAMESPACE:-sf}" rollout status "deployment/${SF_TEST_DEPLOYMENT:-sf-server-test}" --timeout=180s
|
||||
|
||||
- name: Probe test server
|
||||
run: |
|
||||
curl --fail --silent --show-error "$SF_TEST_URL/api/healthz"
|
||||
curl --fail --silent --show-error "$SF_TEST_URL/api/ready"
|
||||
curl --fail --silent --show-error "$SF_TEST_URL/api/version"
|
||||
|
||||
deploy-prod:
|
||||
name: promote prod
|
||||
needs:
|
||||
- build
|
||||
- deploy-test
|
||||
runs-on: docker
|
||||
if: needs.deploy-test.result == 'success' && env.KUBECONFIG_B64 != '' && env.SF_PROD_URL != ''
|
||||
steps:
|
||||
- name: Configure kubeconfig
|
||||
run: |
|
||||
mkdir -p "$HOME/.kube"
|
||||
printf '%s' "$KUBECONFIG_B64" | base64 -d > "$HOME/.kube/config"
|
||||
|
||||
- name: Roll prod deployment
|
||||
run: |
|
||||
kubectl -n "${SF_PROD_NAMESPACE:-sf}" set image "deployment/${SF_PROD_DEPLOYMENT:-sf-server}" "sf-server=${{ needs.build.outputs.image }}"
|
||||
kubectl -n "${SF_PROD_NAMESPACE:-sf}" rollout status "deployment/${SF_PROD_DEPLOYMENT:-sf-server}" --timeout=180s
|
||||
|
||||
- name: Probe prod server
|
||||
run: |
|
||||
curl --fail --silent --show-error "$SF_PROD_URL/api/healthz"
|
||||
curl --fail --silent --show-error "$SF_PROD_URL/api/ready"
|
||||
curl --fail --silent --show-error "$SF_PROD_URL/api/version"
|
||||
|
|
@ -119,6 +119,13 @@ second writer. Server-forwarded feedback writes are queued and drained by the
|
|||
server before autonomous dispatch, so CLI control does not block behind a busy
|
||||
unit.
|
||||
|
||||
Self-deploy is defined in [`docs/specs/sf-self-deploy.md`](docs/specs/sf-self-deploy.md).
|
||||
Forgejo builds a source-pinned server image, writes
|
||||
`dist/sf-release-manifest.json`, rolls test first, probes `/api/healthz`,
|
||||
`/api/ready`, and `/api/version`, then promotes the same image to prod.
|
||||
On vega, use `npm run docker:vega:up` for the containerized source-mounted
|
||||
server on port 4000.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- **Language**: TypeScript with `"strict": true` enabled in all packages
|
||||
|
|
|
|||
|
|
@ -129,3 +129,10 @@ npm run build:core
|
|||
|
||||
Then restart the server. Stale `dist/` or stale `~/.sf/agent/extensions/sf/`
|
||||
copies can make fixed source look broken.
|
||||
|
||||
Self-deploy is documented in `docs/specs/sf-self-deploy.md`. The short version:
|
||||
Forgejo builds the source-pinned server image, generates
|
||||
`dist/sf-release-manifest.json`, rolls test, probes `/api/healthz`,
|
||||
`/api/ready`, and `/api/version`, then promotes the same image to prod.
|
||||
On vega, use `npm run docker:vega:up` to run the source-mounted container on
|
||||
port 4000.
|
||||
|
|
|
|||
68
docker/Dockerfile.sf-server
Normal file
68
docker/Dockerfile.sf-server
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Source-built SF server image for Forgejo self-deploy.
|
||||
#
|
||||
# Purpose: package the exact repository revision Forgejo verified, including
|
||||
# the staged Next.js standalone host and release manifest, instead of installing
|
||||
# a mutable npm tag at runtime.
|
||||
#
|
||||
# Consumer: .forgejo/workflows/self-deploy.yml and GitOps deployments that run
|
||||
# `sf server /workspace --host 0.0.0.0 --port 4000`.
|
||||
|
||||
FROM node:26.1-slim AS build
|
||||
|
||||
WORKDIR /src
|
||||
ENV CI=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
libsecret-1-dev \
|
||||
make \
|
||||
g++ \
|
||||
python3 \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages ./packages
|
||||
COPY web/package.json web/package-lock.json ./web/
|
||||
RUN npm ci && npm --prefix web ci
|
||||
|
||||
COPY . .
|
||||
ARG SF_GIT_SHA
|
||||
ARG SF_GIT_REF
|
||||
ARG SF_RELEASE_IMAGE
|
||||
ARG SF_RELEASE_IMAGE_DIGEST
|
||||
ARG SF_IMAGE_REPOSITORY
|
||||
ENV SF_GIT_SHA=${SF_GIT_SHA}
|
||||
ENV SF_GIT_REF=${SF_GIT_REF}
|
||||
ENV SF_RELEASE_IMAGE=${SF_RELEASE_IMAGE}
|
||||
ENV SF_RELEASE_IMAGE_DIGEST=${SF_RELEASE_IMAGE_DIGEST}
|
||||
ENV SF_IMAGE_REPOSITORY=${SF_IMAGE_REPOSITORY}
|
||||
RUN npm run build:core
|
||||
RUN npm run build:web-host
|
||||
RUN npm run release:manifest -- --out dist/sf-release-manifest.json
|
||||
|
||||
FROM node:26.1-slim AS sf-server
|
||||
|
||||
WORKDIR /opt/sf
|
||||
ENV NODE_ENV=production
|
||||
ENV SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json
|
||||
ENV SF_WEB_PREFER_SOURCE=0
|
||||
ENV SF_WEB_HOST=0.0.0.0
|
||||
ENV SF_WEB_PORT=4000
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /src /opt/sf
|
||||
|
||||
WORKDIR /workspace
|
||||
EXPOSE 4000
|
||||
ENTRYPOINT ["tini", "--", "node", "/opt/sf/dist/loader.js"]
|
||||
CMD ["server", "/workspace", "--host", "0.0.0.0", "--port", "4000"]
|
||||
26
docker/Dockerfile.source-server
Normal file
26
docker/Dockerfile.source-server
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Local source-mounted SF server image.
|
||||
#
|
||||
# Purpose: run the vega development/production server inside a container while
|
||||
# keeping /home/mhugo/code/singularity-forge as the source of truth.
|
||||
#
|
||||
# Consumer: docker/docker-compose.vega.yaml.
|
||||
|
||||
FROM node:26.1-slim
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV HOME=/home/node
|
||||
ENV SF_WEB_PREFER_SOURCE=0
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/sf
|
||||
EXPOSE 4000
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["npm", "run", "sf:server", "--", "--host", "0.0.0.0", "--port", "4000"]
|
||||
40
docker/docker-compose.vega.yaml
Normal file
40
docker/docker-compose.vega.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
services:
|
||||
sf-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.source-server
|
||||
container_name: sf-server-vega
|
||||
working_dir: /opt/sf
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
ports:
|
||||
- "${SF_VEGA_BIND:-127.0.0.1}:4000:4000"
|
||||
volumes:
|
||||
- ../:/opt/sf
|
||||
- ${SF_WORKSPACE_DIR:-..}:/workspace
|
||||
- ${HOME}/.sf:/home/node/.sf
|
||||
- ${HOME}/.gitconfig:/home/node/.gitconfig:ro
|
||||
environment:
|
||||
HOME: /home/node
|
||||
NODE_ENV: development
|
||||
SF_SOURCE_ROOT: /opt/sf
|
||||
SF_RUNTIME_SOURCE_ROOT: /opt/sf
|
||||
SF_RELEASE_MANIFEST: /opt/sf/dist/sf-release-manifest.json
|
||||
SF_WEB_PROJECT_CWD: /workspace
|
||||
SF_WEB_HOST: 0.0.0.0
|
||||
SF_WEB_PORT: "4000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
PORT: "4000"
|
||||
SF_WEB_ALLOWED_ORIGINS: ${SF_WEB_ALLOWED_ORIGINS:-http://127.0.0.1:4000,http://localhost:4000}
|
||||
SF_DEV_SERVER_WATCH: "1"
|
||||
command:
|
||||
- node
|
||||
- /opt/sf/dist/web/standalone/server.js
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- node -e "fetch('http://127.0.0.1:4000/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
|
|
@ -39,4 +39,5 @@ memory, ledgers, replay, or drift analysis.
|
|||
## See also
|
||||
|
||||
- [SF operating model export](./sf-operating-model.md)
|
||||
- [SF self-deploy contract](./sf-self-deploy.md)
|
||||
- [AGENTS.md#sf-planning-state](../../AGENTS.md#sf-planning-state)
|
||||
|
|
|
|||
109
docs/specs/sf-self-deploy.md
Normal file
109
docs/specs/sf-self-deploy.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# SF Self-Deploy Contract
|
||||
|
||||
SF deploys as a long-running server owned by the deployment platform, not by an
|
||||
interactive TUI session. Forgejo is the build authority: it verifies a source
|
||||
revision, publishes an immutable OCI image, then rolls a test server before prod.
|
||||
|
||||
## Purpose
|
||||
|
||||
The server must be reloadable without humans killing old processes by hand, and
|
||||
the CLI/web surfaces must be able to prove which build they are controlling. The
|
||||
artifact boundary is therefore:
|
||||
|
||||
1. source revision in git
|
||||
2. Forgejo build/test result
|
||||
3. OCI image tag or digest
|
||||
4. `dist/sf-release-manifest.json`
|
||||
5. `/api/healthz`, `/api/ready`, and `/api/version` probes
|
||||
|
||||
## Build Authority
|
||||
|
||||
Forgejo runs `.forgejo/workflows/self-deploy.yml` on `main` and manual dispatch.
|
||||
The required gates are:
|
||||
|
||||
- `npm ci`
|
||||
- `npm --prefix web ci`
|
||||
- `npm run build:core`
|
||||
- `npm run build:web-host`
|
||||
- `npm run typecheck:extensions`
|
||||
- `npm run test:unit`
|
||||
- build `docker/Dockerfile.sf-server`
|
||||
- generate `dist/sf-release-manifest.json`
|
||||
|
||||
The image builder can be Docker, BuildKit, Kaniko, or `nix2container`. SF does
|
||||
not depend on the builder implementation. The deployment contract starts at the
|
||||
OCI image plus release manifest.
|
||||
|
||||
## Server Runtime
|
||||
|
||||
The server image starts:
|
||||
|
||||
```bash
|
||||
node /opt/sf/dist/loader.js server /workspace --host 0.0.0.0 --port 4000
|
||||
```
|
||||
|
||||
The web host receives `SF_RELEASE_MANIFEST`, `SF_WEB_PROJECT_CWD`,
|
||||
`SF_WEB_HOST`, and `SF_WEB_PORT` in its environment. Probes are unauthenticated
|
||||
so Kubernetes, Traefik, and Forgejo can verify rollouts without a browser token.
|
||||
|
||||
On vega, the local production server may run from the live checkout while still
|
||||
being containerised:
|
||||
|
||||
```bash
|
||||
npm run docker:vega:up
|
||||
```
|
||||
|
||||
That profile mounts this SF checkout at `/opt/sf`, mounts the controlled repo at
|
||||
`/workspace`, persists `~/.sf`, and binds port 4000 to
|
||||
`${SF_VEGA_BIND:-127.0.0.1}`. `SF_WORKSPACE_DIR` selects which repo the server
|
||||
controls; it defaults to this checkout for dogfooding, but the same server code
|
||||
can be reused with any repo:
|
||||
|
||||
```bash
|
||||
SF_WORKSPACE_DIR=/home/mhugo/code/other-repo npm run docker:vega:up
|
||||
```
|
||||
|
||||
Set `SF_VEGA_BIND` to the vega Tailscale address when the server should be
|
||||
reachable over Tailscale; do not bind public `0.0.0.0` unless a proxy/firewall
|
||||
owns access control.
|
||||
|
||||
On hosts without the Docker Compose plugin, `npm run docker:vega:up` uses
|
||||
`scripts/run-vega-source-server.mjs` to build `docker/Dockerfile.source-server`
|
||||
and run the equivalent `docker run` command directly. This is one SF server
|
||||
implementation and image, with one running server process per workspace repo.
|
||||
Each workspace gets a stable container name derived from the workspace path. A
|
||||
restart replaces only that workspace's container; other repo servers keep
|
||||
running. If `SF_VEGA_PORT` is not set, the runner picks the first free port in
|
||||
`4000-4099`, so several repos can run at the same time.
|
||||
|
||||
## Promotion
|
||||
|
||||
Test must roll before prod:
|
||||
|
||||
1. set test deployment image to the new digest
|
||||
2. wait for rollout
|
||||
3. call `/api/healthz`
|
||||
4. call `/api/ready`
|
||||
5. call `/api/version`
|
||||
6. promote the same image digest to prod
|
||||
7. repeat the same probes
|
||||
|
||||
Prod must not install `latest` from npm during rollout. Runtime auto-update
|
||||
means the deployment controller rolls a verified image; it does not mean the
|
||||
running process mutates its own package tree.
|
||||
|
||||
## Reload Model
|
||||
|
||||
For a source-mounted vega container, the foreground process is the staged Next
|
||||
standalone server at `dist/web/standalone/server.js`. Rebuild or restart the
|
||||
container after changing server/web code. In Kubernetes or k3s, rollout
|
||||
replacement is the reload mechanism. Long term, CLI commands should call the
|
||||
server RPC surface by default when a healthy server owns the project, while
|
||||
local `sf server` remains the bootstrap and recovery path.
|
||||
|
||||
## Open Work
|
||||
|
||||
- Wire `/api/version` into the web footer/admin panel.
|
||||
- Add an RPC smoke probe once the stable server RPC endpoint is finalized.
|
||||
- Move the Forgejo workflow's deployment target names into `/srv/infra` GitOps
|
||||
values when the cluster manifests exist.
|
||||
|
|
@ -53,6 +53,11 @@
|
|||
"build": "npm run build:core && node scripts/build-web-if-stale.cjs",
|
||||
"stage:web-host": "node scripts/stage-web-standalone.cjs",
|
||||
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
|
||||
"release:manifest": "node scripts/generate-release-manifest.mjs",
|
||||
"docker:build-sf-server": "docker build -f docker/Dockerfile.sf-server -t ghcr.io/singularity-ng/sf-server .",
|
||||
"docker:vega:up": "node scripts/run-vega-source-server.mjs up",
|
||||
"docker:vega:logs": "node scripts/run-vega-source-server.mjs logs",
|
||||
"docker:vega:down": "node scripts/run-vega-source-server.mjs down",
|
||||
"docs:features": "node scripts/generate-features-inventory.mjs",
|
||||
"copy-resources": "node scripts/copy-resources.cjs",
|
||||
"copy-themes": "node scripts/copy-themes.cjs",
|
||||
|
|
|
|||
112
scripts/generate-release-manifest.mjs
Normal file
112
scripts/generate-release-manifest.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-release-manifest.mjs — emit the immutable build contract consumed by
|
||||
* SF server health checks and deployment automation.
|
||||
*
|
||||
* Purpose: give Forgejo, Kubernetes, and the running web surface one small JSON
|
||||
* artifact that proves which source revision, image, and verification gates are
|
||||
* being promoted.
|
||||
*
|
||||
* Consumer: Forgejo self-deploy workflow, Docker image build, and web
|
||||
* /api/version readiness surfaces.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const outIndex = args.indexOf("--out");
|
||||
const outPath =
|
||||
outIndex >= 0 && args[outIndex + 1]
|
||||
? resolve(process.cwd(), args[outIndex + 1])
|
||||
: resolve(root, "dist", "sf-release-manifest.json");
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(root, "package.json"), "utf8"),
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
git: {
|
||||
sha:
|
||||
env("SF_GIT_SHA") ??
|
||||
env("GITHUB_SHA") ??
|
||||
env("FORGEJO_SHA") ??
|
||||
git(["rev-parse", "HEAD"]),
|
||||
ref:
|
||||
env("SF_GIT_REF") ??
|
||||
env("GITHUB_REF_NAME") ??
|
||||
env("FORGEJO_REF_NAME") ??
|
||||
env("CI_COMMIT_REF_NAME") ??
|
||||
git(["rev-parse", "--abbrev-ref", "HEAD"]),
|
||||
},
|
||||
image: {
|
||||
repository:
|
||||
env("SF_IMAGE_REPOSITORY") ??
|
||||
env("SF_RELEASE_IMAGE")?.replace(/@sha256:.+$/, "") ??
|
||||
null,
|
||||
ref: env("SF_RELEASE_IMAGE") ?? null,
|
||||
digest: env("SF_RELEASE_IMAGE_DIGEST") ?? null,
|
||||
registry: env("SF_RELEASE_REGISTRY") ?? null,
|
||||
},
|
||||
build: {
|
||||
source: env("CI_SERVER_NAME") ?? env("GITHUB_SERVER_URL") ?? "local",
|
||||
workflow: env("GITHUB_WORKFLOW") ?? env("CI_WORKFLOW") ?? null,
|
||||
runId: env("GITHUB_RUN_ID") ?? env("CI_PIPELINE_ID") ?? null,
|
||||
runNumber: env("GITHUB_RUN_NUMBER") ?? env("CI_PIPELINE_NUMBER") ?? null,
|
||||
builtAt: new Date().toISOString(),
|
||||
node: process.version,
|
||||
npm: command(["npm", "--version"]),
|
||||
},
|
||||
server: {
|
||||
defaultHost: "0.0.0.0",
|
||||
defaultPort: 4000,
|
||||
probes: {
|
||||
healthz: "/api/healthz",
|
||||
ready: "/api/ready",
|
||||
version: "/api/version",
|
||||
},
|
||||
},
|
||||
gates: [
|
||||
"npm ci",
|
||||
"npm --prefix web ci",
|
||||
"npm run build:core",
|
||||
"npm run build:web-host",
|
||||
"npm run typecheck:extensions",
|
||||
"npm run test:unit",
|
||||
"server:/api/healthz",
|
||||
"server:/api/ready",
|
||||
"server:/api/version",
|
||||
],
|
||||
};
|
||||
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(`${outPath}.tmp`, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
renameSync(`${outPath}.tmp`, outPath);
|
||||
process.stdout.write(`${outPath}\n`);
|
||||
|
||||
function env(name) {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return command(["git", ...args]);
|
||||
}
|
||||
|
||||
function command(parts) {
|
||||
try {
|
||||
return execFileSync(parts[0], parts.slice(1), {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
175
scripts/run-vega-source-server.mjs
Normal file
175
scripts/run-vega-source-server.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-vega-source-server.mjs — start the source-mounted SF server container on
|
||||
* vega without requiring Docker Compose.
|
||||
*
|
||||
* Purpose: keep the local production server containerized while using the live
|
||||
* checkout at /home/mhugo/code/singularity-forge as the source of truth.
|
||||
*
|
||||
* Consumer: `npm run docker:vega:up` on vega.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:net";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const image = process.env.SF_VEGA_IMAGE || "sf-source-server:vega";
|
||||
const bind = process.env.SF_VEGA_BIND || "127.0.0.1";
|
||||
const workspace = resolve(process.env.SF_WORKSPACE_DIR || root);
|
||||
const name = process.env.SF_VEGA_CONTAINER || defaultContainerName(workspace);
|
||||
const uid = process.env.PUID || String(process.getuid?.() ?? 1000);
|
||||
const gid = process.env.PGID || String(process.getgid?.() ?? 1000);
|
||||
const command = process.argv[2] ?? "up";
|
||||
|
||||
if (command === "--help" || command === "-h" || command === "help") {
|
||||
process.stdout.write(`Usage:
|
||||
npm run docker:vega:up
|
||||
SF_WORKSPACE_DIR=/path/to/repo npm run docker:vega:up
|
||||
node scripts/run-vega-source-server.mjs print
|
||||
node scripts/run-vega-source-server.mjs logs
|
||||
node scripts/run-vega-source-server.mjs down
|
||||
|
||||
Environment:
|
||||
SF_WORKSPACE_DIR repo mounted at /workspace, defaults to this checkout
|
||||
SF_VEGA_BIND host bind address, defaults to 127.0.0.1
|
||||
SF_VEGA_PORT fixed host port; unset auto-picks 4000-4099
|
||||
SF_VEGA_CONTAINER explicit container name
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "print") {
|
||||
const port = await resolvePort();
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{ name, image, bind, port, workspace, sfSource: root },
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "logs") {
|
||||
run("docker", ["logs", "-f", "--tail=200", name]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "down") {
|
||||
run("docker", ["rm", "-f", name]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command !== "up") {
|
||||
process.stderr.write(`Unknown command: ${command}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const port = await resolvePort();
|
||||
const allowedOrigins =
|
||||
process.env.SF_WEB_ALLOWED_ORIGINS ||
|
||||
`http://127.0.0.1:${port},http://localhost:${port}`;
|
||||
|
||||
run("docker", [
|
||||
"build",
|
||||
"-f",
|
||||
"docker/Dockerfile.source-server",
|
||||
"-t",
|
||||
image,
|
||||
".",
|
||||
]);
|
||||
|
||||
spawnSync("docker", ["rm", "-f", name], { stdio: "ignore" });
|
||||
|
||||
run("docker", [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--user",
|
||||
`${uid}:${gid}`,
|
||||
"-p",
|
||||
`${bind}:${port}:4000`,
|
||||
"-e",
|
||||
"HOME=/home/node",
|
||||
"-e",
|
||||
"NODE_ENV=development",
|
||||
"-e",
|
||||
"SF_SOURCE_ROOT=/opt/sf",
|
||||
"-e",
|
||||
"SF_RUNTIME_SOURCE_ROOT=/opt/sf",
|
||||
"-e",
|
||||
"SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json",
|
||||
"-e",
|
||||
"SF_WEB_PROJECT_CWD=/workspace",
|
||||
"-e",
|
||||
"HOSTNAME=0.0.0.0",
|
||||
"-e",
|
||||
"PORT=4000",
|
||||
"-e",
|
||||
"SF_WEB_HOST=0.0.0.0",
|
||||
"-e",
|
||||
"SF_WEB_PORT=4000",
|
||||
"-e",
|
||||
`SF_WEB_ALLOWED_ORIGINS=${allowedOrigins}`,
|
||||
"-e",
|
||||
"SF_DEV_SERVER_WATCH=1",
|
||||
"-v",
|
||||
`${root}:/opt/sf`,
|
||||
"-v",
|
||||
`${workspace}:/workspace`,
|
||||
"-v",
|
||||
`${homedir()}/.sf:/home/node/.sf`,
|
||||
"-v",
|
||||
`${homedir()}/.gitconfig:/home/node/.gitconfig:ro`,
|
||||
image,
|
||||
"node",
|
||||
"/opt/sf/dist/web/standalone/server.js",
|
||||
]);
|
||||
|
||||
process.stdout.write(`${name} listening on ${bind}:${port}\n`);
|
||||
process.stdout.write(`SF source: ${root}\n`);
|
||||
process.stdout.write(`Workspace: ${workspace}\n`);
|
||||
|
||||
async function resolvePort() {
|
||||
if (process.env.SF_VEGA_PORT) return process.env.SF_VEGA_PORT;
|
||||
for (let candidate = 4000; candidate <= 4099; candidate++) {
|
||||
if (await isPortAvailable(candidate)) return String(candidate);
|
||||
}
|
||||
process.stderr.write("No free SF vega port found in 4000-4099\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolveAvailable) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => resolveAvailable(false));
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolveAvailable(true));
|
||||
});
|
||||
server.listen(port, bind);
|
||||
});
|
||||
}
|
||||
|
||||
function defaultContainerName(path) {
|
||||
const slug = basename(path)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const hash = createHash("sha1").update(path).digest("hex").slice(0, 8);
|
||||
return `sf-server-${slug || "workspace"}-${hash}`;
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
37
src/tests/release-manifest.test.ts
Normal file
37
src/tests/release-manifest.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("release manifest", () => {
|
||||
test("generate_release_manifest_when_run_from_repo_records_server_probe_contract", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-release-manifest-"));
|
||||
const out = join(dir, "manifest.json");
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
["scripts/generate-release-manifest.mjs", "--out", out],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
SF_RELEASE_IMAGE: "registry.example/sf-server:test",
|
||||
SF_RELEASE_IMAGE_DIGEST: "sha256:abc",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(readFileSync(out, "utf8"));
|
||||
expect(manifest.schemaVersion).toBe(1);
|
||||
expect(manifest.name).toBe("singularity-forge");
|
||||
expect(manifest.server.defaultPort).toBe(4000);
|
||||
expect(manifest.server.probes).toEqual({
|
||||
healthz: "/api/healthz",
|
||||
ready: "/api/ready",
|
||||
version: "/api/version",
|
||||
});
|
||||
expect(manifest.image.ref).toBe("registry.example/sf-server:test");
|
||||
expect(manifest.image.digest).toBe("sha256:abc");
|
||||
expect(manifest.gates).toContain("npm run build:web-host");
|
||||
});
|
||||
});
|
||||
110
src/web/release-info.ts
Normal file
110
src/web/release-info.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* release-info.ts — read SF server release identity from env and manifest files.
|
||||
*
|
||||
* Purpose: let health probes and deployment controllers verify the exact server
|
||||
* artifact they are talking to without depending on the interactive CLI.
|
||||
*
|
||||
* Consumer: web /api/healthz, /api/ready, and /api/version routes.
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type ReleaseInfo = {
|
||||
ok: boolean;
|
||||
service: "sf-server";
|
||||
version: string;
|
||||
gitSha: string | null;
|
||||
gitRef: string | null;
|
||||
image: {
|
||||
ref: string | null;
|
||||
digest: string | null;
|
||||
repository: string | null;
|
||||
};
|
||||
manifestPath: string | null;
|
||||
manifestLoaded: boolean;
|
||||
projectCwd: string | null;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
uptimeSeconds: number;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
type Manifest = {
|
||||
version?: unknown;
|
||||
git?: { sha?: unknown; ref?: unknown };
|
||||
image?: {
|
||||
ref?: unknown;
|
||||
digest?: unknown;
|
||||
repository?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the release identity advertised by the current SF server process.
|
||||
*
|
||||
* Purpose: give probes a stable, JSON-serialisable shape whether the server was
|
||||
* launched from source, npm, or a Forgejo-built image.
|
||||
*
|
||||
* Consumer: Next.js API routes under web/app/api.
|
||||
*/
|
||||
export function getReleaseInfo(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
now = new Date(),
|
||||
): ReleaseInfo {
|
||||
const manifestPath = resolveManifestPath(env);
|
||||
const manifest = readManifest(manifestPath);
|
||||
const version =
|
||||
stringValue(env.SF_VERSION) ?? stringValue(manifest?.version) ?? "0.0.0";
|
||||
const port = Number.parseInt(env.SF_WEB_PORT ?? env.PORT ?? "", 10);
|
||||
return {
|
||||
ok: true,
|
||||
service: "sf-server",
|
||||
version,
|
||||
gitSha:
|
||||
stringValue(env.SF_GIT_SHA) ??
|
||||
stringValue(env.GITHUB_SHA) ??
|
||||
stringValue(manifest?.git?.sha),
|
||||
gitRef:
|
||||
stringValue(env.SF_GIT_REF) ??
|
||||
stringValue(env.GITHUB_REF_NAME) ??
|
||||
stringValue(manifest?.git?.ref),
|
||||
image: {
|
||||
ref:
|
||||
stringValue(env.SF_RELEASE_IMAGE) ?? stringValue(manifest?.image?.ref),
|
||||
digest:
|
||||
stringValue(env.SF_RELEASE_IMAGE_DIGEST) ??
|
||||
stringValue(manifest?.image?.digest),
|
||||
repository:
|
||||
stringValue(env.SF_IMAGE_REPOSITORY) ??
|
||||
stringValue(manifest?.image?.repository),
|
||||
},
|
||||
manifestPath,
|
||||
manifestLoaded: manifest !== null,
|
||||
projectCwd: stringValue(env.SF_WEB_PROJECT_CWD),
|
||||
host: stringValue(env.SF_WEB_HOST ?? env.HOSTNAME),
|
||||
port: Number.isFinite(port) ? port : null,
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
timestamp: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManifestPath(env: NodeJS.ProcessEnv): string | null {
|
||||
const explicit = stringValue(env.SF_RELEASE_MANIFEST);
|
||||
if (explicit) return explicit;
|
||||
const packageRoot = stringValue(env.SF_WEB_PACKAGE_ROOT) ?? process.cwd();
|
||||
const candidate = join(packageRoot, "dist", "sf-release-manifest.json");
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
function readManifest(path: string | null): Manifest | null {
|
||||
if (!path) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
10
web/app/api/healthz/route.ts
Normal file
10
web/app/api/healthz/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { getReleaseInfo } from "../../../../src/web/release-info.ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
return Response.json(getReleaseInfo(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
27
web/app/api/ready/route.ts
Normal file
27
web/app/api/ready/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { existsSync } from "node:fs";
|
||||
|
||||
import { getReleaseInfo } from "../../../../src/web/release-info.ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const release = getReleaseInfo();
|
||||
const projectReady =
|
||||
release.projectCwd === null || existsSync(release.projectCwd);
|
||||
const ready = release.ok && projectReady;
|
||||
return Response.json(
|
||||
{
|
||||
...release,
|
||||
ready,
|
||||
checks: {
|
||||
projectCwd: projectReady ? "pass" : "fail",
|
||||
manifest: release.manifestLoaded ? "pass" : "absent",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ready ? 200 : 503,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
},
|
||||
);
|
||||
}
|
||||
10
web/app/api/version/route.ts
Normal file
10
web/app/api/version/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { getReleaseInfo } from "../../../../src/web/release-info.ts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
return Response.json(getReleaseInfo(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue