diff --git a/.github/workflows/workshop.yml b/.github/workflows/workshop.yml index ec6d8d9dc..c924e8d80 100644 --- a/.github/workflows/workshop.yml +++ b/.github/workflows/workshop.yml @@ -23,8 +23,9 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @homarr/workshop typecheck - run: pnpm --filter @homarr/docs typecheck + - run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet - run: pnpm test:workshop - - run: docker build --target production -f apps/workshop/Dockerfile . + - run: pnpm test:workshop-image publish: if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main') diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 9caa898a5..e3afc40b0 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -112,8 +112,8 @@ The advanced deployments environment variables should only be used if you know w | Environment Variable | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. | -| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. | -| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. | +| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. | +| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. | All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 472111f22..e9d4acd2c 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -112,6 +112,42 @@ The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it. +### Staging and demo deployment + +Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2 +preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is: + +```dotenv +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* +``` + +Start or update it from the repository root: + +```sh +docker compose --env-file apps/workshop/.env.staging \ + -f apps/workshop/docker-compose.staging.yml pull +docker compose --env-file apps/workshop/.env.staging \ + -f apps/workshop/docker-compose.staging.yml up --detach +curl --fail http://127.0.0.1:8090/api/health +``` + +The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including +`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading. +This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the +chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep +`/workshop` only on `WORKSHOP_WEB_URL`. + +The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable +for previews; use the `sha-` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows +self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead. +GitHub OAuth requires both credentials and the callback +`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health +endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview. + Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. diff --git a/apps/workshop/.env.staging.example b/apps/workshop/.env.staging.example new file mode 100644 index 000000000..c783f53bc --- /dev/null +++ b/apps/workshop/.env.staging.example @@ -0,0 +1,12 @@ +WORKSHOP_IMAGE=ghcr.io/homarr-labs/workshop:v2 +WORKSHOP_EXPOSE_PORT=8090 + +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* + +# Optional GitHub OAuth application for the preview hostname. +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/apps/workshop/README.md b/apps/workshop/README.md index 09807d893..4818567c5 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -162,6 +162,45 @@ WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev PB_ALLOWED_ORIGINS=* ``` +### Deploy a v2 staging or demo instance + +The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to +the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to +run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then: + +```sh +cp .env.staging.example .env.staging +docker compose --env-file .env.staging -f docker-compose.staging.yml pull +docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach +docker compose --env-file .env.staging -f docker-compose.staging.yml ps +curl --fail http://127.0.0.1:8090/api/health +``` + +The checked-in example is ready for `https://v2.preview.homarr.dev`: + +```dotenv +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* +``` + +Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and +`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and +PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead +and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`). + +The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a +new v2 image while retaining the named PocketBase volume. Use the immutable `sha-` image tag when promoting a +tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins +call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins. + +After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is +enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and +restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr +preview before sharing the demo. + Build and smoke-test the combined image locally: ```sh @@ -172,6 +211,10 @@ curl --fail http://127.0.0.1:3003/workshop docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs ``` +`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the +browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin +browser access all work. + Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional, irreversible local reset: diff --git a/apps/workshop/docker-compose.staging.yml b/apps/workshop/docker-compose.staging.yml new file mode 100644 index 000000000..a4604e4c2 --- /dev/null +++ b/apps/workshop/docker-compose.staging.yml @@ -0,0 +1,25 @@ +name: homarr-workshop-staging + +services: + workshop: + image: ${WORKSHOP_IMAGE:-ghcr.io/homarr-labs/workshop:v2} + restart: unless-stopped + environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:?Set HOMARR_WEBSITE_URL in the Workshop environment file} + PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} + PORT: 8090 + WORKSHOP_API_URL: ${WORKSHOP_API_URL:?Set WORKSHOP_API_URL in the Workshop environment file} + WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:?Set WORKSHOP_PUBLIC_ORIGIN in the Workshop environment file} + WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + ports: + - "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090" + volumes: + - pb_data:/pb_data + security_opt: + - no-new-privileges:true + stop_grace_period: 30s + +volumes: + pb_data: diff --git a/apps/workshop/docker-compose.yml b/apps/workshop/docker-compose.yml index 350538104..22c1be2e6 100644 --- a/apps/workshop/docker-compose.yml +++ b/apps/workshop/docker-compose.yml @@ -8,8 +8,10 @@ services: target: development restart: unless-stopped environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} PORT: ${PB_EXPOSE_PORT:-8090} + WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:8090} WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} diff --git a/apps/workshop/test-image.sh b/apps/workshop/test-image.sh new file mode 100644 index 000000000..c56e3b15e --- /dev/null +++ b/apps/workshop/test-image.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +WORKSHOP_IMAGE_TEST_PORT=${WORKSHOP_IMAGE_TEST_PORT:-18091} +WORKSHOP_IMAGE_TEST_NAME="homarr-workshop-image-test-$$" +WORKSHOP_IMAGE_TEST_TAG="homarr-workshop:test-$$" +PREVIEW_ORIGIN=https://v2.preview.homarr.dev + +cleanup() { + docker rm --force "$WORKSHOP_IMAGE_TEST_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" . +docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \ + --publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \ + --env HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \ + --env WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + --env WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + --env WORKSHOP_PUBLIC_ORIGIN="$PREVIEW_ORIGIN" \ + --env PB_ALLOWED_ORIGINS='*' \ + "$WORKSHOP_IMAGE_TEST_TAG" >/dev/null + +for attempt in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/health" >/dev/null; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs "$WORKSHOP_IMAGE_TEST_NAME" + exit 1 + fi + sleep 1 +done + +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \ + EXPECTED_HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + node apps/workshop/tests/runtime-config.integration.mjs + +curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null +curl --fail --silent \ + "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null +curl --fail --silent --dump-header - --output /dev/null \ + --header 'Origin: https://homarr.example' \ + "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" \ + | tr -d '\r' \ + | grep -qi '^Access-Control-Allow-Origin: \*$' + +echo "Workshop production image passed" diff --git a/apps/workshop/test.sh b/apps/workshop/test.sh index b010a45ca..67a28d080 100644 --- a/apps/workshop/test.sh +++ b/apps/workshop/test.sh @@ -14,7 +14,10 @@ cleanup() { } trap cleanup EXIT -PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ +HOMARR_WEBSITE_URL=https://preview.example.invalid \ + WORKSHOP_API_URL=https://api.preview.example.invalid \ + WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ + PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ -f apps/workshop/docker-compose.yml up --build -d for attempt in $(seq 1 60); do @@ -29,6 +32,12 @@ for attempt in $(seq 1 60); do sleep 1 done +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" \ + EXPECTED_HOMARR_WEBSITE_URL=https://preview.example.invalid \ + EXPECTED_WORKSHOP_API_URL=https://api.preview.example.invalid \ + EXPECTED_WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ + node apps/workshop/tests/runtime-config.integration.mjs + docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \ pocketbase superuser create \ workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data diff --git a/apps/workshop/tests/runtime-config.integration.mjs b/apps/workshop/tests/runtime-config.integration.mjs new file mode 100644 index 000000000..469d5d12c --- /dev/null +++ b/apps/workshop/tests/runtime-config.integration.mjs @@ -0,0 +1,24 @@ +import vm from "node:vm"; + +const baseUrl = process.env.WORKSHOP_TEST_URL; +if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required"); + +const response = await fetch(`${baseUrl}/workshop-runtime-config.js`); +if (!response.ok) throw new Error(`Runtime configuration returned ${response.status}`); + +const context = { TextDecoder, Uint8Array, atob }; +context.globalThis = context; +vm.runInNewContext(await response.text(), context); + +const actual = JSON.parse(JSON.stringify(context.homarrRuntimeConfig)); +const expected = { + homarrWebsiteUrl: process.env.EXPECTED_HOMARR_WEBSITE_URL, + workshopApiUrl: process.env.EXPECTED_WORKSHOP_API_URL, + workshopWebUrl: process.env.EXPECTED_WORKSHOP_WEB_URL, +}; + +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected Workshop runtime configuration: ${JSON.stringify(actual)}`); +} + +console.log("Workshop runtime configuration passed"); diff --git a/package.json b/package.json index 6857153a0..e5683d34c 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --coverage.enabled ", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", "test:workshop": "sh apps/workshop/test.sh", + "test:workshop-image": "sh apps/workshop/test-image.sh", "test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled", "typecheck": "turbo typecheck", "with-env": "dotenv -e .env --"