From 67d2c09b4047f335d61fd5027af38a1ffc6b6e1f Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 8 Oct 2026 10:51:53 +0300 Subject: [PATCH 1/2] fix(docker): drop dead edition build arg and harden .dockerignore The Dockerfile has no VITE_ADMIN_RELEASE arg and no code reads it: the edition comes from the Hub's /echo at runtime, so managed, enterprise and community are the same build. Exclude env variants, keys, tests, test artefacts, .next and other non-build files from the public image context. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 62 +++++++++++++++++++++++++++++- .github/workflows/docker-image.yml | 8 ++-- .github/workflows/staging.yml | 15 +++++--- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/.dockerignore b/.dockerignore index b6c9487..57f87fa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,67 @@ +# Build context for deployments/Dockerfile (`COPY . .` in the build stage). +# The image is PUBLIC (community-admin), so keep secrets, local state, test +# artefacts and anything the `next build` does not need out of the context. +# Needed by the build: package*.json, next.config.mjs, tsconfig*.json, +# next-env.d.ts, postcss/tailwind config, app/, src/, public/, config/. + +# Dependencies / build output (rebuilt inside the image) node_modules dist +.next +out +build +*.tsbuildinfo + +# VCS / CI / editor / OS .git +.gitignore +.github +.vscode +.idea +*.swp +.DS_Store +Thumbs.db + +# Env files and local state — every variant (the build reads none of them; +# runtime config is passed with `docker run -e` / k8s env) .env +.env.* *.local -.DS_Store +.local +.claude +.cursor + +# Keys and certificates +*.pem +*.key +*.p8 +*.p12 +*.pfx +*.crt +*.cer +id_rsa* +*.keystore + +# Tests and test artefacts (the image runs no tests) +tests +test-results +playwright-report +blob-report +coverage +.nyc_output +**/*.test.ts +**/*.test.tsx +vitest.config.ts + +# Vite-era tooling not used by `next build` +vite.config.ts +index.html +tsconfig.node.json + +# Docs, logs and Docker files themselves docs +*.md +*.log +npm-debug.log* +deployments +.dockerignore diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a511546..f40ed20 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -18,9 +18,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # Build the managed-service flavor. Multi-arch push happens on tags only; - # PRs just build to validate the Dockerfile. - - name: Build image (managed) + # One build serves every edition (managed, enterprise, community): the + # edition is read at runtime from the Hub's /echo, not at build time. + - name: Build image uses: docker/build-push-action@v6 with: context: . @@ -28,8 +28,6 @@ jobs: push: false load: true tags: norbix/admin:ci - build-args: | - VITE_ADMIN_RELEASE=ManagedService - name: Trivy image scan uses: aquasecurity/trivy-action@0.28.0 diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 81a35ed..8269e11 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -2,8 +2,10 @@ name: Staging · Build & Deploy # Continuous deployment of the ADMIN portal to the staging box (Hostinger, k3s). # -# Admin ships only for managed + enterprise (there is no community-admin -# image), so the triggers are: +# This workflow stages managed + enterprise only. The self-hosted +# community-admin image is the SAME build (the edition is decided at runtime +# from the Hub's /echo `release`, never at build time) and is not staged here, +# so the triggers are: # # push a tag staging-ms-v2.1.0 → build `admin` (ManagedService) → deploy to norbix-ms # push a tag staging-ent-v2.1.0 → build `enterprise-admin` (Enterprise) → deploy to norbix-enterprise @@ -30,7 +32,7 @@ on: workflow_dispatch: inputs: release: - description: 'Release to build & deploy (admin has no self-hosted image)' + description: 'Release to build & deploy (community-admin is not staged here)' required: true default: 'managed' type: choice @@ -65,7 +67,7 @@ jobs: staging-managed-*) RELEASE=managed ; VERSION="${TAG#staging-managed-}" ;; staging-ms-*) RELEASE=managed ; VERSION="${TAG#staging-ms-}" ;; staging-self-hosted-*|staging-sh-*) - echo "::notice::Admin has no self-hosted image — nothing to build for '$TAG'." + echo "::notice::Admin self-hosted (community-admin) is not staged by this workflow — nothing to build for '$TAG'." echo "release=skip" >> "$GITHUB_OUTPUT"; exit 0 ;; *) echo "::error::Tag '$TAG' must look like staging--v"; exit 1 ;; esac @@ -125,8 +127,9 @@ jobs: platforms: linux/amd64 push: true tags: ${{ steps.ref.outputs.image_ref }} - build-args: | - VITE_ADMIN_RELEASE=${{ needs.plan.outputs.flavor }} + # No edition build arg: the Dockerfile has none and the portal reads + # the edition at runtime (/echo `release`), so every flavour is the + # same build. `flavor` only scopes the cache. cache-from: type=gha,scope=staging-${{ needs.plan.outputs.flavor }} cache-to: type=gha,mode=max,scope=staging-${{ needs.plan.outputs.flavor }} From 5769e1e0a323da4169bb7533fc3a272ff3d7e21c Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 8 Oct 2026 10:51:53 +0300 Subject: [PATCH 2/2] feat(docker): runtime PROJECT_ID for the self-hosted community image NEXT_PUBLIC_ADMIN_PROJECT_ID is inlined at build time, so a public image could not serve a customer's project on a custom domain. The root layout now reads PROJECT_ID from the server env per request and emits it as the norbix-project meta tag the client resolver already reads. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +++ app/layout.tsx | 17 ++++-- app/lib/runtimeProject.test.ts | 28 ++++++++++ app/lib/runtimeProject.ts | 24 +++++++++ deployments/Dockerfile | 22 +++++--- docs/tasks/community-admin-image.md | 83 +++++++++++++++++++++++++++++ src/config/project.ts | 4 +- 7 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 app/lib/runtimeProject.test.ts create mode 100644 app/lib/runtimeProject.ts create mode 100644 docs/tasks/community-admin-image.md diff --git a/.env.example b/.env.example index 4c3a605..5f7949d 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,12 @@ API_BASE_URL=https://api.norbix.ai HUB_VERSION=v3 API_VERSION=v3 +# Self-hosted: the project this portal serves (pr_{base62} or pr-{32 hex}). +# Read at RUNTIME by the server and emitted as , +# so a published image needs no rebuild per customer. Unset = managed (the +# project comes from the pr- host label). +PROJECT_ID= + # Optional Norbix environment (self-hosted only). Unset = PROD. # Set to "staging" or "test" to target that project environment; the backend # sends it as the norbix-env header on every request. Managed deployments diff --git a/app/layout.tsx b/app/layout.tsx index b811b12..6cbbb75 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,12 +3,23 @@ // screens keep the same theming. During the phased migration the actual UI is // still served by the Vite app; this layout is the shell the ported screens // will mount into. +import type { Metadata } from 'next'; import type { ReactNode } from 'react'; import '../src/styles.css'; +import { runtimeProjectId } from './lib/runtimeProject'; -export const metadata = { - title: 'Admin Portal', -}; +// Per request (the catch-all page is force-dynamic), so PROJECT_ID is read +// from the RUNTIME server env, not baked in at build: one public image works +// for any self-hosted customer. Emitted as , which +// the client resolver reads (src/config/project.ts). Unset → no tag, and the +// project comes from the host label as before (managed). +export function generateMetadata(): Metadata { + const projectId = runtimeProjectId(process.env.PROJECT_ID); + return { + title: 'Admin Portal', + ...(projectId ? { other: { 'norbix-project': projectId } } : {}), + }; +} export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/app/lib/runtimeProject.test.ts b/app/lib/runtimeProject.test.ts new file mode 100644 index 0000000..5d015eb --- /dev/null +++ b/app/lib/runtimeProject.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { runtimeProjectId } from './runtimeProject'; +import { viewIdFromHex } from '@/config/project'; + +describe('runtimeProjectId', () => { + it('returns null when unset or blank', () => { + expect(runtimeProjectId(undefined)).toBeNull(); + expect(runtimeProjectId('')).toBeNull(); + expect(runtimeProjectId(' ')).toBeNull(); + }); + + it('keeps a pr_{base62} view id (trimmed)', () => { + expect(runtimeProjectId(' pr_5R4dlqJeXx943tOzSDEwbS ')).toBe( + 'pr_5R4dlqJeXx943tOzSDEwbS', + ); + }); + + it('converts a pr-{hex} host label to the view id', () => { + const hex = '4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2'; + expect(runtimeProjectId(`pr-${hex}`)).toBe(viewIdFromHex(hex)); + }); + + it('rejects anything else', () => { + expect(runtimeProjectId('my-project')).toBeNull(); + expect(runtimeProjectId('pr_">