From 0b0433708250453d52e118f770064b83c1fa14a8 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:10:18 +0000 Subject: [PATCH 01/12] feat(site): build t4code.com website and cluster deployment pipeline --- .github/workflows/deploy-site.yml | 234 +++++------------- .woodpecker.yml | 101 ++++++++ apps/site/docs/index.html | 4 +- apps/site/index.html | 14 +- apps/site/public/robots.txt | 2 +- apps/site/public/site.webmanifest | 2 +- apps/site/public/sitemap.xml | 4 +- apps/site/src/docs/content.ts | 4 +- apps/site/src/landing/Landing.tsx | 9 +- apps/site/src/release.ts | 2 +- apps/site/src/site.css | 2 +- apps/site/test/release.test.ts | 2 +- cluster/images/site/Dockerfile | 60 +++++ deploy/charts/t4-site/Chart.yaml | 7 + deploy/charts/t4-site/templates/_helpers.tpl | 34 +++ .../charts/t4-site/templates/deployment.yaml | 91 +++++++ deploy/charts/t4-site/templates/ingress.yaml | 33 +++ deploy/charts/t4-site/templates/service.yaml | 15 ++ deploy/charts/t4-site/values.yaml | 39 +++ package.json | 1 + scripts/build-site-image.sh | 46 ++++ scripts/deploy-site.mjs | 123 ++++----- scripts/deploy-site.test.mjs | 117 +++++---- scripts/promote-site-image.sh | 41 +++ 24 files changed, 687 insertions(+), 300 deletions(-) create mode 100644 cluster/images/site/Dockerfile create mode 100644 deploy/charts/t4-site/Chart.yaml create mode 100644 deploy/charts/t4-site/templates/_helpers.tpl create mode 100644 deploy/charts/t4-site/templates/deployment.yaml create mode 100644 deploy/charts/t4-site/templates/ingress.yaml create mode 100644 deploy/charts/t4-site/templates/service.yaml create mode 100644 deploy/charts/t4-site/values.yaml create mode 100755 scripts/build-site-image.sh create mode 100755 scripts/promote-site-image.sh diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 61184d2b..a270d27f 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -6,21 +6,15 @@ on: branches: [main, master] paths: - "apps/site/**" - - "apps/flutter/**" - "packages/**" - - "scripts/check-release-publication.mjs" - - "scripts/build-demo.mjs" + - "cluster/images/site/**" + - "deploy/charts/t4-site/**" - "scripts/deploy-site.mjs" - - "scripts/deploy-demo.mjs" - - "scripts/dispatch-site-deployment.mjs" - "scripts/generate-release-manifest.mjs" - - "scripts/inspect-linux-update.mjs" - - "scripts/read-bounded-response.mjs" - - "scripts/reconcile-release-assets.mjs" - - "scripts/wait-for-release-assets.mjs" - "package.json" - "pnpm-lock.yaml" - ".github/workflows/deploy-site.yml" + - ".woodpecker.yml" workflow_dispatch: inputs: release_tag: @@ -34,87 +28,19 @@ on: permissions: contents: read - id-token: write concurrency: - group: t4code-net-production + group: t4code-com-production cancel-in-progress: false jobs: - demo: - if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-24.04 - timeout-minutes: 30 - environment: - name: production - url: https://t4code.net/demo - steps: - - name: Check out current demo source - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 11.10.0 - - - name: Install Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24.13.1 - - - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - flutter-version: 3.44.6 - channel: stable - cache: true - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Authenticate to AWS with GitHub OIDC - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 - with: - role-to-assume: ${{ vars.AWS_ROLE_ARN }} - aws-region: us-east-1 - - - name: Check whether the live demo permits the Flutter renderer - id: demo_csp - shell: bash - run: | - set -euo pipefail - headers=$(curl --fail --silent --show-error --head https://t4code.net/demo) - if printf '%s\n' "$headers" \ - | tr -d '\r' \ - | grep -i '^content-security-policy:' \ - | grep -Fq "'wasm-unsafe-eval'"; then - echo "ready=true" >> "$GITHUB_OUTPUT" - else - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "::warning::Flutter demo deployment is deferred until the demo CloudFront CSP permits wasm-unsafe-eval." - fi - - - name: Defer Flutter demo until its response policy is active - if: ${{ steps.demo_csp.outputs.ready != 'true' }} - run: echo "Apply infra/site/cloudformation.yml, then rerun this workflow from a main push." - - - name: Build and deploy current Flutter demo - if: ${{ steps.demo_csp.outputs.ready == 'true' }} - env: - T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} - T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} - run: pnpm deploy:demo - deploy: if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-24.04 - timeout-minutes: 50 + timeout-minutes: 30 environment: name: production - url: https://t4code.net + url: https://t4code.com steps: - name: Check out trusted workflow source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -123,122 +49,90 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Read trusted workflow source version - id: source - shell: bash - env: - TRUSTED_SHA: ${{ github.sha }} - run: | - set -euo pipefail - version=$(node -p "require('./package.json').version") - printf 'version=%s\ntrusted_sha=%s\n' "$version" "$TRUSTED_SHA" >> "$GITHUB_OUTPUT" - - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 11.10.0 - - - name: Install Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24.13.1 - - - name: Confirm the published release from the release workflow - id: published_release - if: ${{ github.event_name == 'workflow_dispatch' }} - env: - RELEASE_VERSION: ${{ steps.source.outputs.version }} - run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000 - - - name: Classify the stable release referenced by an ordinary main push - id: release_state - if: ${{ github.event_name == 'push' }} - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.source.outputs.version }} - run: node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT" - - - name: Confirm assets for an existing stable release - id: existing_release - if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'published' }} - env: - RELEASE_VERSION: ${{ steps.source.outputs.version }} - run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 15000 --interval-ms 3000 - - - name: Defer a release-version site update until publication - if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'not-published' }} - run: echo "The referenced release is not public yet; the release workflow will deploy this site after publication." - - name: Resolve immutable deployment source - id: immutable_source - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + id: source shell: bash env: EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ github.token }} - TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }} REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }} - TRUSTED_VERSION: ${{ steps.source.outputs.version }} + TRUSTED_SHA: ${{ github.sha }} run: | set -euo pipefail - expected_tag="v${TRUSTED_VERSION}" - release_tag="$expected_tag" + source_sha="$TRUSTED_SHA" if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" ]]; then - echo "release_tag must be the current release ${expected_tag}" >&2 + version=$(node -p "require('./package.json').version") + expected_tag="v${version}" + if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" || "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then + echo "release deployment must run from the current immutable tag ${expected_tag}" >&2 exit 1 fi - if [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then - echo "workflow_dispatch must run from the immutable release tag ${expected_tag}" >&2 + release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${expected_tag}" --jq '[.draft, .prerelease] | @tsv') + if [[ "$release_flags" != $'false\tfalse' ]]; then + echo "release_tag must name a published, non-prerelease GitHub release" >&2 exit 1 fi - release_tag="$REQUESTED_RELEASE_TAG" - fi - release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" --jq '[.draft, .prerelease] | @tsv') - if [[ "$release_flags" != $'false\tfalse' ]]; then - echo "release_tag must name a published, non-prerelease GitHub release" >&2 - exit 1 - fi - git fetch --force origin "refs/tags/${release_tag}:refs/tags/${release_tag}" - source_sha=$(git rev-parse "${release_tag}^{commit}") - tag_version=$(git show "${source_sha}:package.json" | jq -er '.version') - if [[ "$tag_version" != "$TRUSTED_VERSION" ]]; then - echo "release tag package version ${tag_version} does not match trusted source ${TRUSTED_VERSION}" >&2 - exit 1 - fi - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + git fetch --force origin "refs/tags/${expected_tag}:refs/tags/${expected_tag}" + source_sha=$(git rev-parse "${expected_tag}^{commit}") if [[ "$source_sha" != "$TRUSTED_SHA" ]]; then - echo "release tag moved after this immutable deployment was dispatched" >&2 + echo "release tag moved after this deployment was dispatched" >&2 exit 1 fi - elif ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then - echo "release tag source is not reachable from the trusted workflow source" >&2 - exit 1 fi - printf 'release_tag=%s\nsource_sha=%s\n' "$release_tag" "$source_sha" >> "$GITHUB_OUTPUT" + printf 'source_sha=%s\n' "$source_sha" >> "$GITHUB_OUTPUT" - name: Check out immutable deployment source - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ steps.immutable_source.outputs.source_sha }} + ref: ${{ steps.source.outputs.source_sha }} persist-credentials: false + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + - name: Install dependencies - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} run: pnpm install --frozen-lockfile - - name: Authenticate to AWS with GitHub OIDC - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 - with: - role-to-assume: ${{ vars.AWS_ROLE_ARN }} - aws-region: us-east-1 + - name: Validate the site build + shell: bash + run: | + set -euo pipefail + version=$(node -p "require('./package.json').version") + node scripts/generate-release-manifest.mjs \ + --version "$version" \ + --output apps/site/public/releases/latest.json + pnpm build:site + + - name: Install pinned Helm + shell: bash + run: | + set -euo pipefail + curl -fsSLo /tmp/helm.tar.gz https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz + echo "a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 /tmp/helm.tar.gz" | sha256sum -c - + tar -xzf /tmp/helm.tar.gz -C /tmp + sudo install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm - - name: Build and deploy immutable release site - if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + - name: Configure production cluster access + shell: bash env: - GH_TOKEN: ${{ github.token }} - T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} - T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }} + T4_KUBECONFIG_BASE64: ${{ secrets.T4_KUBECONFIG_BASE64 }} + run: | + set -euo pipefail + test -n "$T4_KUBECONFIG_BASE64" + install -d -m 0700 "$RUNNER_TEMP/kube" + printf '%s' "$T4_KUBECONFIG_BASE64" | base64 --decode > "$RUNNER_TEMP/kube/config" + chmod 0600 "$RUNNER_TEMP/kube/config" + + - name: Deploy exact commit image and verify production + env: + KUBECONFIG: ${{ runner.temp }}/kube/config + T4_SITE_IMAGE_TAG: ${{ steps.source.outputs.source_sha }} run: pnpm deploy:site diff --git a/.woodpecker.yml b/.woodpecker.yml index 98024327..c9b895e0 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -335,6 +335,107 @@ steps: memory: 1Gi ephemeral-storage: 1Gi + + prepare-site-image: + depends_on: [harbor-auth] + image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 + commands: + - version=$(node -p "require('./package.json').version") && node scripts/generate-release-manifest.mjs --version "$version" --output apps/site/public/releases/latest.json + when: + - event: push + branch: main + - event: manual + branch: main + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 128Mi + ephemeral-storage: 128Mi + limits: + cpu: 500m + memory: 512Mi + ephemeral-storage: 512Mi + + build-site: + depends_on: [build-controller, prepare-site-image] + image: mirror.gcr.io/moby/buildkit:v0.30.0-rootless@sha256:d76eb1caecac5733ef7553c1e90a1b21f1bb218cd1142d3553de0747b4a14ba9 + environment: + BUILDKIT_ADDR: tcp://woodpecker-buildkitd.linkedin-ci.svc.cluster.local:1234 + HARBOR_REGISTRY: harbor.tailb18de3.ts.net + HARBOR_PROJECT: linkedin-bot + commands: + - scripts/build-site-image.sh + when: + - event: push + branch: main + - event: manual + branch: main + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 256Mi + ephemeral-storage: 256Mi + limits: + cpu: "1" + memory: 1Gi + ephemeral-storage: 1Gi + + promote-site: + depends_on: [build-site] + image: ghcr.io/oras-project/oras:v1.3.0@sha256:6ce045ce069a89934d6666b8b49f9c4c0145201bd6de6dbe2aee267814c55468 + environment: + HARBOR_REGISTRY: harbor.tailb18de3.ts.net + HARBOR_PROJECT: linkedin-bot + commands: + - scripts/promote-site-image.sh + when: + - event: push + branch: main + - event: manual + branch: main + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 128Mi + ephemeral-storage: 128Mi + limits: + cpu: 500m + memory: 512Mi + ephemeral-storage: 512Mi + + deploy-site: + depends_on: [promote-site] + image: harbor.tailb18de3.ts.net/linkedin-bot/woodpecker-buildkit-deploy-tools@sha256:7936be1edac8ce3c225b148cff0e89b1446fc88185d72379398617f7279ad8cc + environment: + T4_SITE_IMAGE_REPOSITORY: harbor.tailb18de3.ts.net/linkedin-bot/t4-site + commands: + - node scripts/deploy-site.mjs + when: + - event: push + branch: main + - event: manual + branch: main + backend_options: + kubernetes: + serviceAccountName: woodpecker-dev-verifier + resources: + requests: + cpu: "0" + memory: 128Mi + ephemeral-storage: 128Mi + limits: + cpu: 500m + memory: 512Mi + ephemeral-storage: 512Mi sbom-controller: depends_on: [build-session-runtime] image: anchore/syft:v1.33.0-debug@sha256:421a1367ee81b35b094defc7c1c42774e9f0c2f818e278848175015987c4a034 diff --git a/apps/site/docs/index.html b/apps/site/docs/index.html index b2ecdb6e..1ac8d219 100644 --- a/apps/site/docs/index.html +++ b/apps/site/docs/index.html @@ -9,7 +9,7 @@ name="description" content="Documentation for T4 Code: install, first run, sessions, remote pairing, agents, terminals, settings, keyboard shortcuts, troubleshooting, security, and building from source." /> - + @@ -21,7 +21,7 @@ property="og:description" content="Install T4 Code, pair it with an Oh My Pi host, and get the most out of sessions, agents, and terminals." /> - + diff --git a/apps/site/index.html b/apps/site/index.html index 01f3f92d..cb93c11f 100644 --- a/apps/site/index.html +++ b/apps/site/index.html @@ -4,25 +4,25 @@ - T4 Code — the open-source desktop app for Oh My Pi + T4 Code — the open-source client for Oh My Pi - + - + - - + + diff --git a/apps/site/public/robots.txt b/apps/site/public/robots.txt index fa69a55a..e4be210e 100644 --- a/apps/site/public/robots.txt +++ b/apps/site/public/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://t4code.net/sitemap.xml +Sitemap: https://t4code.com/sitemap.xml diff --git a/apps/site/public/site.webmanifest b/apps/site/public/site.webmanifest index abd093da..d2b1fdfa 100644 --- a/apps/site/public/site.webmanifest +++ b/apps/site/public/site.webmanifest @@ -1,7 +1,7 @@ { "name": "T4 Code", "short_name": "T4 Code", - "description": "The open-source desktop app for Oh My Pi.", + "description": "The open-source client for Oh My Pi.", "start_url": "/", "display": "browser", "background_color": "#161616", diff --git a/apps/site/public/sitemap.xml b/apps/site/public/sitemap.xml index 0c8264c3..9de128d0 100644 --- a/apps/site/public/sitemap.xml +++ b/apps/site/public/sitemap.xml @@ -1,9 +1,9 @@ - https://t4code.net/ + https://t4code.com/ - https://t4code.net/docs/ + https://t4code.com/docs/ diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index bd95867c..9fa3ced1 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -579,7 +579,7 @@ const security: DocTopic = { title: "Security", lede: "Plain answers about what T4 Code stores, sends, and never does.", blocks: [ - { kind: "h2", id: "security-unsigned", text: "Signed macOS releases" }, + { kind: "h2", id: "security-signing", text: "Signed macOS releases" }, { kind: "p", text: `The v${RELEASE_VERSION} macOS build is signed with the project's pinned Developer ID identity and notarized by Apple. Publication stops if the certificate, Team ID, hardened runtime, secure timestamp, stapled ticket, or Gatekeeper result drifts. You can also [build from source](#build-from-source). The repository is public at [LycaonLLC/t4-code](${REPO_URL}).`, @@ -597,7 +597,7 @@ const security: DocTopic = { { kind: "h2", id: "security-site", text: "This website" }, { kind: "p", - text: "t4code.net is a static site. It loads no third-party scripts, no analytics, and no trackers; fonts are bundled with the site.", + text: "t4code.com is a static site. It loads no third-party scripts, no analytics, and no trackers; fonts are bundled with the site.", }, ], }; diff --git a/apps/site/src/landing/Landing.tsx b/apps/site/src/landing/Landing.tsx index 4ea1015b..2563c319 100644 --- a/apps/site/src/landing/Landing.tsx +++ b/apps/site/src/landing/Landing.tsx @@ -246,10 +246,11 @@ export function Landing() {
- Heads up: the v{RELEASE_VERSION} macOS build is unsigned and not - notarized, so macOS warns before first launch. If you would rather not run an unsigned - binary, {renderInline(`[build it from source](/docs/#build-from-source)`)}. The whole - app is public. + Verified for macOS: the v{RELEASE_VERSION} release is signed with the + project's Developer ID identity and notarized by Apple. If Gatekeeper blocks it, + delete that copy and verify a fresh download against the release checksum instead of + bypassing the warning. {renderInline(`[First-launch help](/docs/#install-gatekeeper)`)} + .
diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 898dbba3..10c6dd92 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -1,7 +1,7 @@ // Single source of truth for the public release: repo, tag, asset names, // download URLs, and the platform-detection rule the hero button uses. -export const SITE_URL = "https://t4code.net"; +export const SITE_URL = "https://t4code.com"; export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; diff --git a/apps/site/src/site.css b/apps/site/src/site.css index e979e409..a0fb3606 100644 --- a/apps/site/src/site.css +++ b/apps/site/src/site.css @@ -3,7 +3,7 @@ @import "@fontsource/jetbrains-mono/600.css"; /* - * t4code.net stylesheet. Token values mirror packages/ui/src/tokens.css + * t4code.com stylesheet. Token values mirror packages/ui/src/tokens.css * (dark theme); this is the only site file with raw color values. */ diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index b973b998..ca033000 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -40,7 +40,7 @@ describe("release assets", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); expect(RELEASE_TAG).toBe("v0.1.30"); expect(RELEASE_VERSION).toBe("0.1.30"); - expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); + expect(RELEASE_MANIFEST_URL).toBe("https://t4code.com/releases/latest.json"); }); it("splits assets by platform with correct architectures", () => { diff --git a/cluster/images/site/Dockerfile b/cluster/images/site/Dockerfile new file mode 100644 index 00000000..0e88705b --- /dev/null +++ b/cluster/images/site/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.7 +FROM docker.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 AS build +WORKDIR /opt/t4 + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/site/package.json apps/site/package.json +RUN corepack enable \ + && pnpm install --filter @t4-code/site... --frozen-lockfile + +COPY apps/site/ apps/site/ +RUN pnpm --filter @t4-code/site build + +FROM docker.io/nginxinc/nginx-unprivileged:1.29.4-alpine3.23@sha256:a6c4f61f456b85b8fdf7ec7ab28cc3e299440e6fb4a9dea520e5fd8fd440025e +ARG SOURCE_REPOSITORY=https://github.com/LycaonLLC/t4-code +LABEL org.opencontainers.image.source="${SOURCE_REPOSITORY}" \ + org.opencontainers.image.title="T4 Code website" + +COPY <<'EOF' /etc/nginx/conf.d/default.conf +server { + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + charset utf-8; + server_tokens off; + + location = /healthz { + access_log off; + default_type text/plain; + add_header Cache-Control "no-store" always; + return 200 "ok\n"; + } + + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + location ~* \.html$ { + try_files $uri =404; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + } + + location / { + try_files $uri $uri/ =404; + add_header Cache-Control "public, max-age=3600" always; + } + + error_page 404 /404.html; +} +EOF + +COPY --from=build --chown=101:101 /opt/t4/apps/site/dist/ /usr/share/nginx/html/ + +USER 101:101 +EXPOSE 8080 +ENTRYPOINT [] +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/charts/t4-site/Chart.yaml b/deploy/charts/t4-site/Chart.yaml new file mode 100644 index 00000000..2cd43d96 --- /dev/null +++ b/deploy/charts/t4-site/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: t4-site +description: T4 Code production website +version: 0.1.0 +appVersion: "0.1.30" +type: application +kubeVersion: ">=1.30.0-0" diff --git a/deploy/charts/t4-site/templates/_helpers.tpl b/deploy/charts/t4-site/templates/_helpers.tpl new file mode 100644 index 00000000..0ebaa259 --- /dev/null +++ b/deploy/charts/t4-site/templates/_helpers.tpl @@ -0,0 +1,34 @@ +{{- define "t4-site.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "t4-site.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else if contains (include "t4-site.name" .) .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name (include "t4-site.name" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "t4-site.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "t4-site.labels" -}} +helm.sh/chart: {{ include "t4-site.chart" . | quote }} +{{ include "t4-site.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/part-of: "t4-site" +app.kubernetes.io/managed-by: {{ .Release.Service | quote }} +{{- end -}} + +{{- define "t4-site.selectorLabels" -}} +app.kubernetes.io/name: {{ include "t4-site.name" . | quote }} +app.kubernetes.io/instance: {{ .Release.Name | quote }} +{{- end -}} + +{{- define "t4-site.image" -}} +{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}} +{{- end -}} diff --git a/deploy/charts/t4-site/templates/deployment.yaml b/deploy/charts/t4-site/templates/deployment.yaml new file mode 100644 index 00000000..d9693e79 --- /dev/null +++ b/deploy/charts/t4-site/templates/deployment.yaml @@ -0,0 +1,91 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "t4-site.fullname" . | quote }} + labels: + {{- include "t4-site.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 3 + minReadySeconds: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "t4-site.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "t4-site.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + fsGroup: 101 + seccompProfile: + type: RuntimeDefault + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: site + image: {{ include "t4-site.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy | quote }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + startupProbe: + httpGet: {path: /healthz, port: http} + failureThreshold: 30 + periodSeconds: 2 + readinessProbe: + httpGet: {path: /healthz, port: http} + failureThreshold: 3 + periodSeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: http} + failureThreshold: 3 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: [ALL] + volumeMounts: + - {name: temporary, mountPath: /tmp} + - {name: nginx-cache, mountPath: /var/cache/nginx} + volumes: + - name: temporary + emptyDir: {sizeLimit: 16Mi} + - name: nginx-cache + emptyDir: {sizeLimit: 32Mi} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/t4-site/templates/ingress.yaml b/deploy/charts/t4-site/templates/ingress.yaml new file mode 100644 index 00000000..093b746e --- /dev/null +++ b/deploy/charts/t4-site/templates/ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "t4-site.fullname" . | quote }} + labels: + {{- include "t4-site.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host | quote }} + secretName: {{ .Values.ingress.tls.secretName | quote }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "t4-site.fullname" . | quote }} + port: + name: http +{{- end }} diff --git a/deploy/charts/t4-site/templates/service.yaml b/deploy/charts/t4-site/templates/service.yaml new file mode 100644 index 00000000..00b2c7d1 --- /dev/null +++ b/deploy/charts/t4-site/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "t4-site.fullname" . | quote }} + labels: + {{- include "t4-site.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "t4-site.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/deploy/charts/t4-site/values.yaml b/deploy/charts/t4-site/values.yaml new file mode 100644 index 00000000..1443add6 --- /dev/null +++ b/deploy/charts/t4-site/values.yaml @@ -0,0 +1,39 @@ +replicaCount: 2 + +image: + repository: harbor.tailb18de3.ts.net/linkedin-bot/t4-site + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: {} + host: t4code.com + path: / + pathType: Prefix + tls: + enabled: true + secretName: t4code-com-tls + +resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + +podAnnotations: {} +podLabels: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/package.json b/package.json index b3f80664..a8a81a86 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build:flutter:ios": "vp run --filter @t4-code/flutter build:ios", "build:demo": "node scripts/build-demo.mjs", "deploy:site": "node scripts/deploy-site.mjs", + "test:deploy:site": "node --test scripts/deploy-site.test.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && pnpm build:host && node scripts/package-preflight.mjs", "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", diff --git a/scripts/build-site-image.sh b/scripts/build-site-image.sh new file mode 100755 index 00000000..21427ab3 --- /dev/null +++ b/scripts/build-site-image.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +umask 077 +canonical_build_source_repository=usr-bin-roygbiv/t4-code +authorized_ci_mirror=z-peterson/t4-code +dockerfile=cluster/images/site/Dockerfile + +case "${CI_COMMIT_SHA:-}" in + [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; + *) echo "CI_COMMIT_SHA must be an exact lowercase 40-character SHA" >&2; exit 64 ;; +esac + +: "${BUILDKIT_ADDR:?BUILDKIT_ADDR is required}" +: "${HARBOR_REGISTRY:?HARBOR_REGISTRY is required}" +: "${HARBOR_PROJECT:?HARBOR_PROJECT is required}" +if [ "$HARBOR_REGISTRY" != "harbor.tailb18de3.ts.net" ]; then + echo "HARBOR_REGISTRY must be the exact HTTPS tailnet Harbor host" >&2 + exit 64 +fi +if [ "${CI_REPO:-}" != "$authorized_ci_mirror" ]; then + echo "CI_REPO must identify the authorized Woodpecker mirror" >&2 + exit 64 +fi + +auth_dir=${T4_REGISTRY_AUTH_DIR:-${CI_WORKSPACE:-$PWD}/.cluster-ci/registry-auth} +test -r "$auth_dir/config.json" +export DOCKER_CONFIG="$auth_dir" +test -f "$dockerfile" + +repository="$HARBOR_REGISTRY/$HARBOR_PROJECT/quarantine/t4-site" +reference="$repository:$CI_COMMIT_SHA" + +buildctl --addr "$BUILDKIT_ADDR" build \ + --frontend dockerfile.v0 \ + --local context=. \ + --local dockerfile=. \ + --opt "filename=$dockerfile" \ + --opt platform=linux/amd64,linux/arm64 \ + --opt "build-arg:SOURCE_COMMIT=$CI_COMMIT_SHA" \ + --opt "build-arg:SOURCE_REPOSITORY=https://github.com/$canonical_build_source_repository" \ + --opt "label:org.opencontainers.image.source=https://github.com/$canonical_build_source_repository" \ + --opt "label:org.opencontainers.image.revision=$CI_COMMIT_SHA" \ + --output "type=image,name=$reference,push=true,compression=zstd,force-compression=true,oci-mediatypes=true" \ + --attest type=sbom \ + --attest type=provenance,mode=max diff --git a/scripts/deploy-site.mjs b/scripts/deploy-site.mjs index fe903fd7..780f48f1 100644 --- a/scripts/deploy-site.mjs +++ b/scripts/deploy-site.mjs @@ -1,21 +1,36 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const BUCKET_PATTERN = /^(?!\d+\.\d+\.\d+\.\d+$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/; -const DISTRIBUTION_PATTERN = /^E[A-Z0-9]{8,20}$/; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/u; +const FIXED_CONFIG = Object.freeze({ + namespace: "t4-site", + release: "t4-site", + chart: "deploy/charts/t4-site", + imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site", + hostname: "t4code.com", +}); export function resolveDeployConfig(environment = process.env) { - const bucket = environment.T4_SITE_BUCKET?.trim() ?? ""; - const distributionId = environment.T4_CLOUDFRONT_DISTRIBUTION_ID?.trim() ?? ""; - if (!BUCKET_PATTERN.test(bucket)) { - throw new Error("T4_SITE_BUCKET must be a valid S3 bucket name"); + const imageTag = (environment.T4_SITE_IMAGE_TAG ?? environment.CI_COMMIT_SHA ?? "").trim(); + if (!COMMIT_PATTERN.test(imageTag)) { + throw new Error("T4_SITE_IMAGE_TAG or CI_COMMIT_SHA must be an exact lowercase 40-character commit SHA"); } - if (!DISTRIBUTION_PATTERN.test(distributionId)) { - throw new Error("T4_CLOUDFRONT_DISTRIBUTION_ID must be a CloudFront distribution ID"); + + for (const [name, expected] of [ + ["T4_SITE_NAMESPACE", FIXED_CONFIG.namespace], + ["T4_SITE_RELEASE", FIXED_CONFIG.release], + ["T4_SITE_CHART", FIXED_CONFIG.chart], + ["T4_SITE_IMAGE_REPOSITORY", FIXED_CONFIG.imageRepository], + ["T4_SITE_HOSTNAME", FIXED_CONFIG.hostname], + ]) { + const configured = environment[name]?.trim(); + if (configured && configured !== expected) { + throw new Error(`${name} must be ${expected}`); + } } - return { bucket, distributionId }; + + return { ...FIXED_CONFIG, imageTag }; } function run(command, args, cwd) { @@ -26,66 +41,58 @@ function run(command, args, cwd) { } } -export function deploySite( - config, - repoRoot = resolve(import.meta.dirname, ".."), - runCommand = run, - releaseVersion = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")).version, -) { - const destination = `s3://${config.bucket}`; - runCommand("pnpm", ["--filter", "@t4-code/site", "build"], repoRoot); - runCommand( - "node", - [ - "scripts/generate-release-manifest.mjs", - "--version", - releaseVersion, - "--output", - "apps/site/dist/releases/latest.json", - ], - repoRoot, - ); - // Publish new content hashes first and retain old hashes for already-cached entry documents. +export function deploySite(config, repoRoot = resolve(import.meta.dirname, ".."), runCommand = run) { + const timeout = "10m"; runCommand( - "aws", + "helm", [ - "s3", - "sync", - "apps/site/dist/assets", - `${destination}/assets`, - "--cache-control", - "public,max-age=31536000,immutable", - "--only-show-errors", + "upgrade", + "--install", + config.release, + config.chart, + "--namespace", + config.namespace, + "--create-namespace", + "--atomic", + "--wait", + "--timeout", + timeout, + "--set-string", + `image.repository=${config.imageRepository}`, + "--set-string", + `image.tag=${config.imageTag}`, ], repoRoot, ); runCommand( - "aws", + "kubectl", [ - "s3", - "sync", - "apps/site/dist", - destination, - "--delete", - "--exclude", - "assets/*", - "--exclude", - "demo/*", - "--cache-control", - "public,max-age=0,must-revalidate", - "--only-show-errors", + "--namespace", + config.namespace, + "rollout", + "status", + `deployment/${config.release}`, + "--timeout", + timeout, ], repoRoot, ); runCommand( - "aws", + "curl", [ - "cloudfront", - "create-invalidation", - "--distribution-id", - config.distributionId, - "--paths", - "/*", + "--fail", + "--silent", + "--show-error", + "--location", + "--retry", + "12", + "--retry-all-errors", + "--retry-delay", + "5", + "--proto", + "=https", + "--tlsv1.2", + `https://${config.hostname}/`, ], repoRoot, ); diff --git a/scripts/deploy-site.test.mjs b/scripts/deploy-site.test.mjs index 3468b020..6bfd9bd9 100644 --- a/scripts/deploy-site.test.mjs +++ b/scripts/deploy-site.test.mjs @@ -3,72 +3,89 @@ import test from "node:test"; import { deploySite, resolveDeployConfig } from "./deploy-site.mjs"; -test("site deploy config accepts exact scoped AWS targets", () => { - assert.deepEqual( - resolveDeployConfig({ - T4_SITE_BUCKET: "t4code-net-site-595529182031", - T4_CLOUDFRONT_DISTRIBUTION_ID: "E1ABCDEF234567", - }), - { - bucket: "t4code-net-site-595529182031", - distributionId: "E1ABCDEF234567", - }, - ); +const commit = "a".repeat(40); +const expectedConfig = { + namespace: "t4-site", + release: "t4-site", + chart: "deploy/charts/t4-site", + imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site", + hostname: "t4code.com", + imageTag: commit, +}; + +test("site deploy config binds production to the exact commit image", () => { + assert.deepEqual(resolveDeployConfig({ CI_COMMIT_SHA: commit }), expectedConfig); + assert.deepEqual(resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit }), expectedConfig); }); -test("site deploy config rejects missing or malformed targets", () => { - assert.throws(() => resolveDeployConfig({}), /T4_SITE_BUCKET/); +test("site deploy config rejects mutable image tags and production target overrides", () => { + assert.throws(() => resolveDeployConfig({}), /40-character commit SHA/u); + assert.throws(() => resolveDeployConfig({ T4_SITE_IMAGE_TAG: "latest" }), /40-character commit SHA/u); + assert.throws( + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit.toUpperCase() }), + /40-character commit SHA/u, + ); assert.throws( - () => - resolveDeployConfig({ - T4_SITE_BUCKET: "https://example.com", - T4_CLOUDFRONT_DISTRIBUTION_ID: "E1ABCDEF234567", - }), - /T4_SITE_BUCKET/, + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_NAMESPACE: "default" }), + /T4_SITE_NAMESPACE must be t4-site/u, ); assert.throws( - () => - resolveDeployConfig({ - T4_SITE_BUCKET: "t4code-net-site-595529182031", - T4_CLOUDFRONT_DISTRIBUTION_ID: "not-a-distribution", - }), - /T4_CLOUDFRONT_DISTRIBUTION_ID/, + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_HOSTNAME: "t4code.net" }), + /T4_SITE_HOSTNAME must be t4code.com/u, ); }); -test("site deploy uploads immutable assets before switching entry documents", () => { +test("site deploy atomically applies Helm, confirms rollout, and verifies HTTPS", () => { const calls = []; - deploySite( - { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" }, - "/repo", - (command, args, cwd) => calls.push({ command, args, cwd }), - "0.1.17", - ); + deploySite(expectedConfig, "/repo", (command, args, cwd) => calls.push({ command, args, cwd })); - assert.equal(calls.length, 5); assert.deepEqual( calls.map(({ command }) => command), - ["pnpm", "node", "aws", "aws", "aws"], + ["helm", "kubectl", "curl"], ); + assert.deepEqual(calls[0].args, [ + "upgrade", + "--install", + "t4-site", + "deploy/charts/t4-site", + "--namespace", + "t4-site", + "--create-namespace", + "--atomic", + "--wait", + "--timeout", + "10m", + "--set-string", + "image.repository=harbor.tailb18de3.ts.net/linkedin-bot/t4-site", + "--set-string", + `image.tag=${commit}`, + ]); assert.deepEqual(calls[1].args, [ - "scripts/generate-release-manifest.mjs", - "--version", - "0.1.17", - "--output", - "apps/site/dist/releases/latest.json", + "--namespace", + "t4-site", + "rollout", + "status", + "deployment/t4-site", + "--timeout", + "10m", + ]); + assert.deepEqual(calls[2].args, [ + "--fail", + "--silent", + "--show-error", + "--location", + "--retry", + "12", + "--retry-all-errors", + "--retry-delay", + "5", + "--proto", + "=https", + "--tlsv1.2", + "https://t4code.com/", ]); - assert.equal(calls[2].args[2], "apps/site/dist/assets"); - assert.equal(calls[3].args[2], "apps/site/dist"); - assert.equal(calls[2].args.includes("--delete"), false); - assert.equal(calls[3].args.includes("--delete"), true); - assert.deepEqual( - calls[3].args.flatMap((argument, index) => - argument === "--exclude" ? [calls[3].args[index + 1]] : [], - ), - ["assets/*", "demo/*"], - ); assert.deepEqual( calls.map(({ cwd }) => cwd), - ["/repo", "/repo", "/repo", "/repo", "/repo"], + ["/repo", "/repo", "/repo"], ); }); diff --git a/scripts/promote-site-image.sh b/scripts/promote-site-image.sh new file mode 100755 index 00000000..67a71d1a --- /dev/null +++ b/scripts/promote-site-image.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +umask 077 +: "${CI_COMMIT_SHA:?CI_COMMIT_SHA is required}" +: "${HARBOR_REGISTRY:?HARBOR_REGISTRY is required}" +: "${HARBOR_PROJECT:?HARBOR_PROJECT is required}" +if [ "$HARBOR_REGISTRY" != "harbor.tailb18de3.ts.net" ]; then + echo "HARBOR_REGISTRY must be the exact HTTPS tailnet Harbor host" >&2 + exit 64 +fi +case "$CI_COMMIT_SHA" in + [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; + *) echo "CI_COMMIT_SHA must be an exact lowercase 40-character SHA" >&2; exit 64 ;; +esac + +auth_dir=${T4_REGISTRY_AUTH_DIR:-${CI_WORKSPACE:-$PWD}/.cluster-ci/registry-auth} +test -r "$auth_dir/config.json" +export DOCKER_CONFIG="$auth_dir" +source="$HARBOR_REGISTRY/$HARBOR_PROJECT/quarantine/t4-site:$CI_COMMIT_SHA" +destination="$HARBOR_REGISTRY/$HARBOR_PROJECT/t4-site:$CI_COMMIT_SHA" +source_digest=$(oras resolve "$source") +case "$source_digest" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) echo "site quarantine image did not resolve to an immutable digest" >&2; exit 65 ;; +esac + +if destination_digest=$(oras resolve "$destination" 2>&1); then + if [ "$destination_digest" != "$source_digest" ]; then + echo "site destination commit tag already resolves to a different digest" >&2 + exit 65 + fi +else + case "$destination_digest" in + *"failed to resolve digest: $CI_COMMIT_SHA: not found") ;; + *) printf '%s\n' "$destination_digest" >&2; exit 65 ;; + esac + oras copy --recursive "$source" "$destination" +fi + +test "$(oras resolve "$destination")" = "$source_digest" From 1d6d6f93333ba4060868a1c2dc9d838339591702 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:44:31 +0000 Subject: [PATCH 02/12] Deploy t4code.com from the cluster --- .github/workflows/deploy-site.yml | 110 ++++---- .gitignore | 6 + .woodpecker.yml | 57 +++- cluster/images/site/Dockerfile | 13 +- deploy/bootstrap/t4-site-deployer.yaml | 44 +++ deploy/bootstrap/t4-site-upstream-mirror.yaml | 95 +++++++ deploy/charts/t4-site/templates/ingress.yaml | 25 +- deploy/charts/t4-site/values.schema.json | 63 +++++ deploy/charts/t4-site/values.yaml | 15 +- infra/site/.terraform.lock.hcl | 22 ++ infra/site/cloudformation.yml | 259 ------------------ infra/site/main.tf | 112 ++++++++ infra/site/outputs.tf | 29 ++ infra/site/tests/public_edge.tftest.hcl | 40 +++ infra/site/variables.tf | 27 ++ infra/site/versions.tf | 14 + package.json | 2 +- scripts/check-release-consistency.mjs | 27 +- scripts/check-release-consistency.test.mjs | 22 ++ scripts/deploy-site.mjs | 44 ++- scripts/deploy-site.test.mjs | 40 ++- scripts/site-pipeline.test.mjs | 74 +++++ scripts/verify-site-revision.mjs | 165 +++++++++++ scripts/verify-site-revision.test.mjs | 113 ++++++++ 24 files changed, 1029 insertions(+), 389 deletions(-) create mode 100644 deploy/bootstrap/t4-site-deployer.yaml create mode 100644 deploy/bootstrap/t4-site-upstream-mirror.yaml create mode 100644 deploy/charts/t4-site/values.schema.json create mode 100644 infra/site/.terraform.lock.hcl delete mode 100644 infra/site/cloudformation.yml create mode 100644 infra/site/main.tf create mode 100644 infra/site/outputs.tf create mode 100644 infra/site/tests/public_edge.tftest.hcl create mode 100644 infra/site/variables.tf create mode 100644 infra/site/versions.tf create mode 100644 scripts/site-pipeline.test.mjs create mode 100644 scripts/verify-site-revision.mjs create mode 100644 scripts/verify-site-revision.test.mjs diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index a270d27f..ab8e1a40 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -4,21 +4,10 @@ run-name: Deploy project site ${{ inputs.release_tag || github.ref_name }} ${{ i on: push: branches: [main, master] - paths: - - "apps/site/**" - - "packages/**" - - "cluster/images/site/**" - - "deploy/charts/t4-site/**" - - "scripts/deploy-site.mjs" - - "scripts/generate-release-manifest.mjs" - - "package.json" - - "pnpm-lock.yaml" - - ".github/workflows/deploy-site.yml" - - ".woodpecker.yml" workflow_dispatch: inputs: release_tag: - description: Published release tag whose immutable source must be deployed. + description: Published release tag whose immutable source must be verified. required: true type: string dispatch_nonce: @@ -34,8 +23,8 @@ concurrency: cancel-in-progress: false jobs: - deploy: - if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} + verify-production: + if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) }} runs-on: ubuntu-24.04 timeout-minutes: 30 environment: @@ -49,8 +38,28 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Install Node.js for immutable-source checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Classify the stable release referenced by an ordinary main push + if: ${{ github.event_name == 'push' }} + id: release_state + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + RELEASE_VERSION=$(node -p "require('./package.json').version") + node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT" + + - name: Record that the ordinary main source is not yet a published release + if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'not-published' }} + run: echo "The mirrored main push is still expected to deploy this exact source revision." + - name: Resolve immutable deployment source - id: source + id: immutable_source shell: bash env: EVENT_NAME: ${{ github.event_name }} @@ -59,15 +68,21 @@ jobs: TRUSTED_SHA: ${{ github.sha }} run: | set -euo pipefail + TRUSTED_VERSION=$(node -p "require('./package.json').version") + expected_tag="v${TRUSTED_VERSION}" + release_tag="$expected_tag" source_sha="$TRUSTED_SHA" + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - version=$(node -p "require('./package.json').version") - expected_tag="v${version}" - if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" || "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then + if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" ]]; then + echo "release deployment requested an unexpected tag" >&2 + exit 1 + fi + if [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then echo "release deployment must run from the current immutable tag ${expected_tag}" >&2 exit 1 fi - release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${expected_tag}" --jq '[.draft, .prerelease] | @tsv') + release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" --jq '[.draft, .prerelease] | @tsv') if [[ "$release_flags" != $'false\tfalse' ]]; then echo "release_tag must name a published, non-prerelease GitHub release" >&2 exit 1 @@ -79,12 +94,20 @@ jobs: exit 1 fi fi - printf 'source_sha=%s\n' "$source_sha" >> "$GITHUB_OUTPUT" + + if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "deployment source must be an exact lowercase commit SHA" >&2 + exit 1 + fi + git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA" + git fetch --force origin "refs/heads/main:refs/remotes/origin/main" + git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main + printf 'source_sha=%s\nrelease_tag=%s\nversion=%s\n' "$source_sha" "$release_tag" "$TRUSTED_VERSION" >> "$GITHUB_OUTPUT" - name: Check out immutable deployment source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ steps.source.outputs.source_sha }} + ref: ${{ steps.immutable_source.outputs.source_sha }} persist-credentials: false - name: Install pnpm @@ -92,15 +115,23 @@ jobs: with: version: 11.10.0 - - name: Install Node.js + - name: Install immutable-source Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24.13.1 - cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Test deployment verification contracts + run: pnpm test:deploy:site + + - name: Wait for the immutable release bundle + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + RELEASE_VERSION: ${{ steps.immutable_source.outputs.version }} + run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000 + - name: Validate the site build shell: bash run: | @@ -111,28 +142,13 @@ jobs: --output apps/site/public/releases/latest.json pnpm build:site - - name: Install pinned Helm - shell: bash - run: | - set -euo pipefail - curl -fsSLo /tmp/helm.tar.gz https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz - echo "a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 /tmp/helm.tar.gz" | sha256sum -c - - tar -xzf /tmp/helm.tar.gz -C /tmp - sudo install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm - - - name: Configure production cluster access - shell: bash - env: - T4_KUBECONFIG_BASE64: ${{ secrets.T4_KUBECONFIG_BASE64 }} - run: | - set -euo pipefail - test -n "$T4_KUBECONFIG_BASE64" - install -d -m 0700 "$RUNNER_TEMP/kube" - printf '%s' "$T4_KUBECONFIG_BASE64" | base64 --decode > "$RUNNER_TEMP/kube/config" - chmod 0600 "$RUNNER_TEMP/kube/config" - - - name: Deploy exact commit image and verify production + - name: Wait for the mirrored deployment at the public edge env: - KUBECONFIG: ${{ runner.temp }}/kube/config - T4_SITE_IMAGE_TAG: ${{ steps.source.outputs.source_sha }} - run: pnpm deploy:site + SOURCE_SHA: ${{ steps.immutable_source.outputs.source_sha }} + run: >- + node scripts/verify-site-revision.mjs + --target public + --expected "$SOURCE_SHA" + --timeout-ms 900000 + --interval-ms 10000 + --request-timeout-ms 5000 diff --git a/.gitignore b/.gitignore index 1e0549d8..eba139f9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ test-results/ /output/playwright/ *.log .todos/ + +# Local Terraform runtime artifacts +.terraform/ +*.tfplan +*.tfstate +*.tfstate.* diff --git a/.woodpecker.yml b/.woodpecker.yml index c9b895e0..46aec7a7 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -335,9 +335,34 @@ steps: memory: 1Gi ephemeral-storage: 1Gi + harbor-auth-site: + depends_on: [dependencies] + image: harbor.tailb18de3.ts.net/linkedin-bot/woodpecker-buildkit-deploy-tools@sha256:7936be1edac8ce3c225b148cff0e89b1446fc88185d72379398617f7279ad8cc + environment: + T4_REGISTRY_AUTH_DIR: .site-ci/registry-auth + commands: + - sh scripts/cluster-ci/load-registry-auth.sh + when: + - event: push + branch: main + - event: manual + branch: main + backend_options: + kubernetes: + serviceAccountName: woodpecker-dev-verifier + resources: + requests: + cpu: "0" + memory: 128Mi + ephemeral-storage: 128Mi + limits: + cpu: 500m + memory: 512Mi + ephemeral-storage: 512Mi + prepare-site-image: - depends_on: [harbor-auth] + depends_on: [dependencies] image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 commands: - version=$(node -p "require('./package.json').version") && node scripts/generate-release-manifest.mjs --version "$version" --output apps/site/public/releases/latest.json @@ -360,12 +385,13 @@ steps: ephemeral-storage: 512Mi build-site: - depends_on: [build-controller, prepare-site-image] + depends_on: [harbor-auth-site, prepare-site-image] image: mirror.gcr.io/moby/buildkit:v0.30.0-rootless@sha256:d76eb1caecac5733ef7553c1e90a1b21f1bb218cd1142d3553de0747b4a14ba9 environment: BUILDKIT_ADDR: tcp://woodpecker-buildkitd.linkedin-ci.svc.cluster.local:1234 HARBOR_REGISTRY: harbor.tailb18de3.ts.net HARBOR_PROJECT: linkedin-bot + T4_REGISTRY_AUTH_DIR: .site-ci/registry-auth commands: - scripts/build-site-image.sh when: @@ -392,6 +418,7 @@ steps: environment: HARBOR_REGISTRY: harbor.tailb18de3.ts.net HARBOR_PROJECT: linkedin-bot + T4_REGISTRY_AUTH_DIR: .site-ci/registry-auth commands: - scripts/promote-site-image.sh when: @@ -436,6 +463,32 @@ steps: cpu: 500m memory: 512Mi ephemeral-storage: 512Mi + + cleanup-site-registry-auth: + depends_on: [deploy-site] + image: mirror.gcr.io/library/busybox:1.37.0-musl@sha256:222ad6d973c0d198014546a65cd02c5fdedcc172123c5b4c2bf0af636550bd94 + commands: + - rm -rf .site-ci/registry-auth + when: + - event: push + branch: main + status: [success, failure] + - event: manual + branch: main + status: [success, failure] + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 16Mi + ephemeral-storage: 16Mi + limits: + cpu: 100m + memory: 64Mi + ephemeral-storage: 64Mi + sbom-controller: depends_on: [build-session-runtime] image: anchore/syft:v1.33.0-debug@sha256:421a1367ee81b35b094defc7c1c42774e9f0c2f818e278848175015987c4a034 diff --git a/cluster/images/site/Dockerfile b/cluster/images/site/Dockerfile index 0e88705b..c2d7a59f 100644 --- a/cluster/images/site/Dockerfile +++ b/cluster/images/site/Dockerfile @@ -2,17 +2,23 @@ FROM docker.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 AS build WORKDIR /opt/t4 -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ COPY apps/site/package.json apps/site/package.json RUN corepack enable \ && pnpm install --filter @t4-code/site... --frozen-lockfile COPY apps/site/ apps/site/ RUN pnpm --filter @t4-code/site build +ARG SOURCE_COMMIT +RUN test "${#SOURCE_COMMIT}" -eq 40 \ + && case "${SOURCE_COMMIT}" in *[!0-9a-f]*) exit 1 ;; esac \ + && printf '{"revision":"%s"}\n' "${SOURCE_COMMIT}" > apps/site/dist/revision.json FROM docker.io/nginxinc/nginx-unprivileged:1.29.4-alpine3.23@sha256:a6c4f61f456b85b8fdf7ec7ab28cc3e299440e6fb4a9dea520e5fd8fd440025e ARG SOURCE_REPOSITORY=https://github.com/LycaonLLC/t4-code +ARG SOURCE_COMMIT LABEL org.opencontainers.image.source="${SOURCE_REPOSITORY}" \ + org.opencontainers.image.revision="${SOURCE_COMMIT}" \ org.opencontainers.image.title="T4 Code website" COPY <<'EOF' /etc/nginx/conf.d/default.conf @@ -33,6 +39,11 @@ server { return 200 "ok\n"; } + location = /revision.json { + try_files $uri =404; + add_header Cache-Control "no-store" always; + } + location /assets/ { try_files $uri =404; add_header Cache-Control "public, max-age=31536000, immutable" always; diff --git a/deploy/bootstrap/t4-site-deployer.yaml b/deploy/bootstrap/t4-site-deployer.yaml new file mode 100644 index 00000000..713fed5d --- /dev/null +++ b/deploy/bootstrap/t4-site-deployer.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: t4-site + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: t4-site-deployer + namespace: t4-site +rules: + - apiGroups: [""] + resources: ["secrets", "services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: t4-site-deployer + namespace: t4-site +subjects: + - kind: ServiceAccount + name: woodpecker-dev-verifier + namespace: linkedin-ci +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: t4-site-deployer diff --git a/deploy/bootstrap/t4-site-upstream-mirror.yaml b/deploy/bootstrap/t4-site-upstream-mirror.yaml new file mode 100644 index 00000000..6a3afde3 --- /dev/null +++ b/deploy/bootstrap/t4-site-upstream-mirror.yaml @@ -0,0 +1,95 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: t4-site-upstream-mirror + namespace: linkedin-ci +spec: + schedule: "* * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 120 + backoffLimit: 2 + ttlSecondsAfterFinished: 600 + template: + metadata: + labels: + app.kubernetes.io/name: t4-site-upstream-mirror + app.kubernetes.io/part-of: t4-site + spec: + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: mirror + image: mirror.gcr.io/alpine/git:2.49.1@sha256:c0280cf9572316299b08544065d3bf35db65043d5e3963982ec50647d2746e26 + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-ec"] + args: + - | + worktree="$(mktemp -d /tmp/t4-site-mirror.XXXXXX)" + trap 'rm -rf "${worktree}" /tmp/.gitconfig /tmp/git-credentials' EXIT + git config --global credential.helper 'store --file=/tmp/git-credentials' + printf 'protocol=https\nhost=gitlab.com\nusername=%s\npassword=%s\n\n' \ + "${GITLAB_USERNAME}" "${GITLAB_TOKEN}" | git credential approve + git clone --branch main --single-branch --no-tags \ + https://github.com/LycaonLLC/t4-code.git "${worktree}/repository" + git -C "${worktree}/repository" remote add gitlab \ + https://gitlab.com/z-peterson/t4-code.git + git -C "${worktree}/repository" push --force gitlab \ + HEAD:refs/heads/main + commit="$(git -C "${worktree}/repository" rev-parse HEAD)" + payload="$(printf '{\"repo\":\"z-peterson/t4-code\",\"branch\":\"main\",\"commit\":\"%s\",\"event\":\"manual\",\"environment\":\"development\"}' "${commit}")" + wget --quiet --timeout=90 --tries=1 --output-document=- \ + --header="Authorization: Bearer ${WOODPECKER_TRIGGER_TOKEN}" \ + --header='Content-Type: application/json' \ + --post-data="${payload}" \ + http://woodpecker-ci-trigger.linkedin-ci.svc.cluster.local/trigger + printf '\n' + env: + - name: HOME + value: /tmp + - name: GIT_TERMINAL_PROMPT + value: "0" + - name: GITLAB_USERNAME + valueFrom: + secretKeyRef: + name: t4-site-mirror-gitlab + key: username + - name: GITLAB_TOKEN + valueFrom: + secretKeyRef: + name: t4-site-mirror-gitlab + key: token + - name: WOODPECKER_TRIGGER_TOKEN + valueFrom: + secretKeyRef: + name: gitlab-maintenance-secrets + key: GITLAB_API_TOKEN + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 250m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: temporary + mountPath: /tmp + volumes: + - name: temporary + emptyDir: + sizeLimit: 1Gi diff --git a/deploy/charts/t4-site/templates/ingress.yaml b/deploy/charts/t4-site/templates/ingress.yaml index 093b746e..e6d81897 100644 --- a/deploy/charts/t4-site/templates/ingress.yaml +++ b/deploy/charts/t4-site/templates/ingress.yaml @@ -10,24 +10,13 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- with .Values.ingress.className }} - ingressClassName: {{ . | quote }} - {{- end }} - {{- if .Values.ingress.tls.enabled }} + ingressClassName: {{ .Values.ingress.className | quote }} + defaultBackend: + service: + name: {{ include "t4-site.fullname" . | quote }} + port: + name: http tls: - hosts: - - {{ .Values.ingress.host | quote }} - secretName: {{ .Values.ingress.tls.secretName | quote }} - {{- end }} - rules: - - host: {{ .Values.ingress.host | quote }} - http: - paths: - - path: {{ .Values.ingress.path | quote }} - pathType: {{ .Values.ingress.pathType }} - backend: - service: - name: {{ include "t4-site.fullname" . | quote }} - port: - name: http + - {{ .Values.ingress.hostname | quote }} {{- end }} diff --git a/deploy/charts/t4-site/values.schema.json b/deploy/charts/t4-site/values.schema.json new file mode 100644 index 00000000..87257e8e --- /dev/null +++ b/deploy/charts/t4-site/values.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["replicaCount", "image", "imagePullSecrets", "service", "ingress", "resources"], + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "tag", "pullPolicy"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { "name": { "type": "string", "minLength": 1 } } + } + }, + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["type", "port"], + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "className", "annotations", "hostname"], + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string", "const": "tailscale" }, + "annotations": { + "type": "object", + "additionalProperties": { "type": "string" }, + "required": ["tailscale.com/funnel"], + "properties": { + "tailscale.com/funnel": { "type": "string", "const": "true" } + } + }, + "hostname": { "type": "string", "const": "t4-site" } + } + }, + "resources": { "type": "object" }, + "podAnnotations": { "type": "object", "additionalProperties": { "type": "string" } }, + "podLabels": { "type": "object", "additionalProperties": { "type": "string" } }, + "nodeSelector": { "type": "object", "additionalProperties": { "type": "string" } }, + "tolerations": { "type": "array", "items": { "type": "object" } }, + "affinity": { "type": "object" } + } +} diff --git a/deploy/charts/t4-site/values.yaml b/deploy/charts/t4-site/values.yaml index 1443add6..90d16377 100644 --- a/deploy/charts/t4-site/values.yaml +++ b/deploy/charts/t4-site/values.yaml @@ -5,7 +5,8 @@ image: tag: "" pullPolicy: IfNotPresent -imagePullSecrets: [] +imagePullSecrets: + - name: harbor-registry-credentials nameOverride: "" fullnameOverride: "" @@ -15,14 +16,10 @@ service: ingress: enabled: true - className: nginx - annotations: {} - host: t4code.com - path: / - pathType: Prefix - tls: - enabled: true - secretName: t4code-com-tls + className: tailscale + annotations: + tailscale.com/funnel: "true" + hostname: t4-site resources: requests: diff --git a/infra/site/.terraform.lock.hcl b/infra/site/.terraform.lock.hcl new file mode 100644 index 00000000..0a1f03d6 --- /dev/null +++ b/infra/site/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.50.0" + constraints = "~> 6.0" + hashes = [ + "h1:faTJQOetP9/RYuHwA3r2SWnuYoyzQNm4tUWZrZggcgY=", + "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", + "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", + "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", + "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", + "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", + "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", + "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", + "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", + "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", + "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", + ] +} diff --git a/infra/site/cloudformation.yml b/infra/site/cloudformation.yml deleted file mode 100644 index 94f6240d..00000000 --- a/infra/site/cloudformation.yml +++ /dev/null @@ -1,259 +0,0 @@ -AWSTemplateFormatVersion: "2010-09-09" -Description: T4 Code static site on private S3 with CloudFront - -Parameters: - SiteBucketName: - Type: String - Description: Globally unique S3 bucket name for the built site - CertificateArn: - Type: String - Description: ACM certificate ARN in us-east-1 for t4code.net and www.t4code.net - -Resources: - SiteBucket: - Type: AWS::S3::Bucket - DeletionPolicy: Retain - UpdateReplacePolicy: Retain - Properties: - BucketName: !Ref SiteBucketName - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 - OwnershipControls: - Rules: - - ObjectOwnership: BucketOwnerEnforced - PublicAccessBlockConfiguration: - BlockPublicAcls: true - BlockPublicPolicy: true - IgnorePublicAcls: true - RestrictPublicBuckets: true - - OriginAccessControl: - Type: AWS::CloudFront::OriginAccessControl - Properties: - OriginAccessControlConfig: - Description: Signed access from the T4 Code distribution to its private S3 bucket - Name: t4-code-site-oac - OriginAccessControlOriginType: s3 - SigningBehavior: always - SigningProtocol: sigv4 - - UrlRewriteFunction: - Type: AWS::CloudFront::Function - Properties: - AutoPublish: true - FunctionCode: | - function handler(event) { - var request = event.request; - var uri = request.uri; - if (uri.endsWith('/')) { - request.uri += 'index.html'; - } else if (!uri.split('/').pop().includes('.')) { - request.uri += '/index.html'; - } - return request; - } - FunctionConfig: - Comment: Resolve extensionless T4 Code site paths to static index files - Runtime: cloudfront-js-2.0 - Name: t4-code-static-paths - - SiteResponseHeadersPolicy: - Type: AWS::CloudFront::ResponseHeadersPolicy - Properties: - ResponseHeadersPolicyConfig: - Name: t4-code-site-security - Comment: Security headers for the T4 Code project site - CustomHeadersConfig: - Items: - - Header: Cross-Origin-Opener-Policy - Override: true - Value: same-origin - - Header: Permissions-Policy - Override: true - Value: camera=(), geolocation=(), microphone=(), payment=(), usb=() - SecurityHeadersConfig: - ContentSecurityPolicy: - ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" - Override: true - ContentTypeOptions: - Override: true - FrameOptions: - FrameOption: DENY - Override: true - ReferrerPolicy: - Override: true - ReferrerPolicy: strict-origin-when-cross-origin - StrictTransportSecurity: - AccessControlMaxAgeSec: 63072000 - IncludeSubdomains: true - Override: true - Preload: true - XSSProtection: - ModeBlock: true - Override: true - Protection: true - - DemoResponseHeadersPolicy: - Type: AWS::CloudFront::ResponseHeadersPolicy - Properties: - ResponseHeadersPolicyConfig: - Name: t4-code-demo-security - Comment: Security headers for the Flutter web demo - CustomHeadersConfig: - Items: - - Header: Cross-Origin-Opener-Policy - Override: true - Value: same-origin - - Header: Permissions-Policy - Override: true - Value: camera=(), geolocation=(), microphone=(), payment=(), usb=() - SecurityHeadersConfig: - ContentSecurityPolicy: - ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests" - Override: true - ContentTypeOptions: - Override: true - FrameOptions: - FrameOption: DENY - Override: true - ReferrerPolicy: - Override: true - ReferrerPolicy: strict-origin-when-cross-origin - StrictTransportSecurity: - AccessControlMaxAgeSec: 63072000 - IncludeSubdomains: true - Override: true - Preload: true - XSSProtection: - ModeBlock: true - Override: true - Protection: true - - Distribution: - Type: AWS::CloudFront::Distribution - Properties: - DistributionConfig: - Aliases: - - t4code.net - - www.t4code.net - Comment: T4 Code public website and docs - DefaultCacheBehavior: - AllowedMethods: [GET, HEAD, OPTIONS] - CachedMethods: [GET, HEAD, OPTIONS] - CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 - Compress: true - FunctionAssociations: - - EventType: viewer-request - FunctionARN: !GetAtt UrlRewriteFunction.FunctionARN - ResponseHeadersPolicyId: !Ref SiteResponseHeadersPolicy - TargetOriginId: SiteOrigin - ViewerProtocolPolicy: redirect-to-https - CacheBehaviors: - - PathPattern: demo* - AllowedMethods: [GET, HEAD, OPTIONS] - CachedMethods: [GET, HEAD, OPTIONS] - CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 - Compress: true - FunctionAssociations: - - EventType: viewer-request - FunctionARN: !GetAtt UrlRewriteFunction.FunctionARN - ResponseHeadersPolicyId: !Ref DemoResponseHeadersPolicy - TargetOriginId: SiteOrigin - ViewerProtocolPolicy: redirect-to-https - DefaultRootObject: index.html - CustomErrorResponses: - - ErrorCachingMinTTL: 60 - ErrorCode: 403 - ResponseCode: 404 - ResponsePagePath: /404.html - - ErrorCachingMinTTL: 60 - ErrorCode: 404 - ResponseCode: 404 - ResponsePagePath: /404.html - Enabled: true - HttpVersion: http2and3 - IPV6Enabled: true - Origins: - - DomainName: !GetAtt SiteBucket.RegionalDomainName - Id: SiteOrigin - OriginAccessControlId: !GetAtt OriginAccessControl.Id - S3OriginConfig: - OriginAccessIdentity: "" - PriceClass: PriceClass_100 - ViewerCertificate: - AcmCertificateArn: !Ref CertificateArn - MinimumProtocolVersion: TLSv1.2_2021 - SslSupportMethod: sni-only - - SiteBucketPolicy: - Type: AWS::S3::BucketPolicy - Properties: - Bucket: !Ref SiteBucket - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: AllowCloudFrontReadOnly - Effect: Allow - Principal: - Service: cloudfront.amazonaws.com - Action: s3:GetObject - Resource: !Sub "${SiteBucket.Arn}/*" - Condition: - StringEquals: - AWS:SourceArn: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}" - - GitHubDeployRole: - Type: AWS::IAM::Role - Properties: - RoleName: t4-code-site-github-deploy - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Principal: - Federated: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com" - Action: sts:AssumeRoleWithWebIdentity - Condition: - StringEquals: - token.actions.githubusercontent.com:aud: sts.amazonaws.com - token.actions.githubusercontent.com:sub: repo:LycaonLLC/t4-code:environment:production - Policies: - - PolicyName: t4-code-site-deploy - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: ListSiteBucket - Effect: Allow - Action: - - s3:GetBucketLocation - - s3:ListBucket - Resource: !GetAtt SiteBucket.Arn - - Sid: PublishSiteObjects - Effect: Allow - Action: - - s3:DeleteObject - - s3:GetObject - - s3:PutObject - Resource: !Sub "${SiteBucket.Arn}/*" - - Sid: InvalidateSiteDistribution - Effect: Allow - Action: - - cloudfront:CreateInvalidation - - cloudfront:GetInvalidation - Resource: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}" - -Outputs: - BucketName: - Description: S3 bucket used for site uploads - Value: !Ref SiteBucket - DistributionId: - Description: CloudFront distribution ID used for invalidations - Value: !Ref Distribution - DistributionDomainName: - Description: CloudFront target for Namecheap ALIAS and CNAME records - Value: !GetAtt Distribution.DomainName - GitHubDeployRoleArn: - Description: GitHub Actions OIDC role for production site deployments - Value: !GetAtt GitHubDeployRole.Arn diff --git a/infra/site/main.tf b/infra/site/main.tf new file mode 100644 index 00000000..6edc1c01 --- /dev/null +++ b/infra/site/main.tf @@ -0,0 +1,112 @@ +locals { + domain = "t4code.com" + dns_zone = "t4code-com" + resource_tag = "t4-site" +} + +data "google_dns_managed_zone" "site" { + name = local.dns_zone + project = var.project_id +} + +resource "google_compute_global_network_endpoint_group" "origin" { + name = "${local.resource_tag}-origin-neg" + project = var.project_id + network_endpoint_type = "INTERNET_FQDN_PORT" + default_port = var.origin_port +} + +resource "google_compute_global_network_endpoint" "origin" { + project = var.project_id + global_network_endpoint_group = google_compute_global_network_endpoint_group.origin.name + fqdn = var.origin_fqdn + port = var.origin_port +} + +resource "google_compute_backend_service" "site" { + name = "${local.resource_tag}-backend" + project = var.project_id + protocol = "HTTPS" + port_name = "https" + load_balancing_scheme = "EXTERNAL_MANAGED" + timeout_sec = 30 + + custom_request_headers = ["Host: ${var.origin_fqdn}"] + + backend { + group = google_compute_global_network_endpoint_group.origin.self_link + } +} + +resource "google_compute_url_map" "site" { + name = "${local.resource_tag}-url-map" + project = var.project_id + default_service = google_compute_backend_service.site.self_link +} + +resource "google_compute_managed_ssl_certificate" "site" { + name = "${local.resource_tag}-certificate" + project = var.project_id + + managed { + domains = [local.domain] + } +} + +resource "google_compute_target_https_proxy" "site" { + name = "${local.resource_tag}-https-proxy" + project = var.project_id + url_map = google_compute_url_map.site.self_link + ssl_certificates = [google_compute_managed_ssl_certificate.site.self_link] +} + +resource "google_compute_global_address" "site" { + name = "${local.resource_tag}-address" + project = var.project_id + address_type = "EXTERNAL" + ip_version = "IPV4" +} + +resource "google_compute_global_forwarding_rule" "https" { + name = "${local.resource_tag}-https" + project = var.project_id + target = google_compute_target_https_proxy.site.self_link + ip_address = google_compute_global_address.site.address + port_range = "443" + load_balancing_scheme = "EXTERNAL_MANAGED" +} + +resource "google_compute_url_map" "http_redirect" { + name = "${local.resource_tag}-http-redirect" + project = var.project_id + + default_url_redirect { + https_redirect = true + redirect_response_code = "MOVED_PERMANENTLY_DEFAULT" + strip_query = false + } +} + +resource "google_compute_target_http_proxy" "http_redirect" { + name = "${local.resource_tag}-http-proxy" + project = var.project_id + url_map = google_compute_url_map.http_redirect.self_link +} + +resource "google_compute_global_forwarding_rule" "http" { + name = "${local.resource_tag}-http" + project = var.project_id + target = google_compute_target_http_proxy.http_redirect.self_link + ip_address = google_compute_global_address.site.address + port_range = "80" + load_balancing_scheme = "EXTERNAL_MANAGED" +} + +resource "google_dns_record_set" "site" { + name = "${local.domain}." + project = var.project_id + managed_zone = data.google_dns_managed_zone.site.name + type = "A" + ttl = 300 + rrdatas = [google_compute_global_address.site.address] +} diff --git a/infra/site/outputs.tf b/infra/site/outputs.tf new file mode 100644 index 00000000..74586ccd --- /dev/null +++ b/infra/site/outputs.tf @@ -0,0 +1,29 @@ +output "public_ip_address" { + description = "Global IPv4 address serving t4code.com over HTTP and HTTPS." + value = google_compute_global_address.site.address +} + +output "public_url" { + description = "Canonical HTTPS URL for the production site." + value = "https://${local.domain}" +} + +output "origin_endpoint" { + description = "HTTPS Funnel origin configured in the Internet NEG." + value = "https://${var.origin_fqdn}:${var.origin_port}" +} + +output "backend_service_name" { + description = "Global backend service forwarding requests to the Funnel origin." + value = google_compute_backend_service.site.name +} + +output "managed_certificate_name" { + description = "Google-managed certificate resource for t4code.com." + value = google_compute_managed_ssl_certificate.site.name +} + +output "dns_record_name" { + description = "Cloud DNS A record managed by this stack." + value = google_dns_record_set.site.name +} diff --git a/infra/site/tests/public_edge.tftest.hcl b/infra/site/tests/public_edge.tftest.hcl new file mode 100644 index 00000000..12107371 --- /dev/null +++ b/infra/site/tests/public_edge.tftest.hcl @@ -0,0 +1,40 @@ +mock_provider "google" {} + +run "default_public_edge_contract" { + command = apply + + assert { + condition = google_compute_global_network_endpoint_group.origin.network_endpoint_type == "INTERNET_FQDN_PORT" + error_message = "The origin must use a global Internet FQDN NEG." + } + + assert { + condition = google_compute_global_network_endpoint.origin.fqdn == "davailocal.tailb18de3.ts.net" && google_compute_global_network_endpoint.origin.port == 10000 + error_message = "The default origin must target the HTTPS Tailscale Funnel relay." + } + + assert { + condition = google_compute_backend_service.site.protocol == "HTTPS" && contains(google_compute_backend_service.site.custom_request_headers, "Host: davailocal.tailb18de3.ts.net") + error_message = "The backend must use HTTPS and send the Funnel hostname as the Host header." + } + + assert { + condition = length(google_compute_managed_ssl_certificate.site.managed[0].domains) == 1 && contains(google_compute_managed_ssl_certificate.site.managed[0].domains, "t4code.com") + error_message = "The Google-managed certificate must cover t4code.com." + } + + assert { + condition = google_compute_url_map.http_redirect.default_url_redirect[0].https_redirect && !google_compute_url_map.http_redirect.default_url_redirect[0].strip_query + error_message = "The port 80 URL map must redirect to HTTPS without dropping the query string." + } + + assert { + condition = google_compute_global_forwarding_rule.http.ip_address == google_compute_global_forwarding_rule.https.ip_address + error_message = "HTTP and HTTPS must share the same global address." + } + + assert { + condition = google_dns_record_set.site.name == "t4code.com." && google_dns_record_set.site.managed_zone == "t4code-com" && google_dns_record_set.site.type == "A" + error_message = "Cloud DNS must publish an A record for t4code.com in the existing t4code-com zone." + } +} diff --git a/infra/site/variables.tf b/infra/site/variables.tf new file mode 100644 index 00000000..8b890d8e --- /dev/null +++ b/infra/site/variables.tf @@ -0,0 +1,27 @@ +variable "project_id" { + description = "Google Cloud project that owns the public edge resources." + type = string + default = "media-node-636780" +} + +variable "origin_fqdn" { + description = "Public Tailscale Funnel hostname used by the load balancer's Internet NEG and Host header." + type = string + default = "davailocal.tailb18de3.ts.net" + + validation { + condition = can(regex("^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", var.origin_fqdn)) + error_message = "origin_fqdn must be a lowercase fully qualified DNS hostname without a trailing dot." + } +} + +variable "origin_port" { + description = "HTTPS port exposed by the Tailscale Funnel origin." + type = number + default = 10000 + + validation { + condition = var.origin_port >= 1 && var.origin_port <= 65535 + error_message = "origin_port must be between 1 and 65535." + } +} diff --git a/infra/site/versions.tf b/infra/site/versions.tf new file mode 100644 index 00000000..b5b26f04 --- /dev/null +++ b/infra/site/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7.0, < 2.0.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.0" + } + } +} + +provider "google" { + project = var.project_id +} diff --git a/package.json b/package.json index a8a81a86..8b08bd04 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "build:flutter:ios": "vp run --filter @t4-code/flutter build:ios", "build:demo": "node scripts/build-demo.mjs", "deploy:site": "node scripts/deploy-site.mjs", - "test:deploy:site": "node --test scripts/deploy-site.test.mjs", + "test:deploy:site": "node --test scripts/deploy-site.test.mjs scripts/site-pipeline.test.mjs scripts/verify-site-revision.test.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && pnpm build:host && node scripts/package-preflight.mjs", "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 7694fc50..eedebd17 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -28,6 +28,7 @@ export const RELEASE_CONTRACT_PATHS = [ "packages/client/src/omp-client-frames.ts", "scripts/check-release-publication.mjs", "scripts/deploy-site.mjs", + "scripts/verify-site-revision.mjs", "scripts/dispatch-site-deployment.mjs", "scripts/generate-release-manifest.mjs", "scripts/inspect-linux-update.mjs", @@ -1086,12 +1087,25 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ]) { requireText(manifestGenerator, expected, "scripts/generate-release-manifest.mjs", errors); } - requireText( - files.get("scripts/deploy-site.mjs") ?? "", - '"apps/site/dist/releases/latest.json"', - "scripts/deploy-site.mjs", - errors, - ); + const siteDeploy = files.get("scripts/deploy-site.mjs") ?? ""; + for (const expected of [ + 'imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site"', + 'revisionTarget: "origin"', + "waitForSiteRevision", + "expectedRevision: config.imageTag", + ]) { + requireText(siteDeploy, expected, "scripts/deploy-site.mjs", errors); + } + const siteRevisionVerifier = files.get("scripts/verify-site-revision.mjs") ?? ""; + for (const expected of [ + 'origin: "https://t4-site.tailb18de3.ts.net/revision.json"', + 'public: "https://t4code.com/revision.json"', + "readBoundedResponseBytes", + "document.revision", + 'redirect: "error"', + ]) { + requireText(siteRevisionVerifier, expected, "scripts/verify-site-revision.mjs", errors); + } const releasePreparation = releaseWorkflow.indexOf("--mode prepare"); const releaseUpload = releaseWorkflow.indexOf("softprops/action-gh-release@"); const releaseRemoteVerification = releaseWorkflow.indexOf("--mode verify"); @@ -1194,6 +1208,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { '[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]', '[[ "$source_sha" != "$TRUSTED_SHA" ]]', 'git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"', + 'git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main', ]) { requireText( files.get(".github/workflows/deploy-site.yml") ?? "", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index b049aba9..9a374d93 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -320,6 +320,28 @@ test("rejects updater channel, stable manifest, and publication-contract drift", 'WORKFLOW_PATH = ".github/workflows/release.yml"', ), ], + [ + "scripts/verify-site-revision.mjs", + (text) => + replaceRequired( + text, + 'public: "https://t4code.com/revision.json"', + 'public: "http://t4code.com/revision.json"', + ), + ], + [ + "scripts/deploy-site.mjs", + (text) => replaceRequired(text, 'revisionTarget: "origin"', 'revisionTarget: "public"'), + ], + [ + ".github/workflows/deploy-site.yml", + (text) => + replaceRequired( + text, + 'git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main', + 'git merge-base --is-ancestor "$source_sha" refs/remotes/origin/release', + ), + ], [ ".github/workflows/deploy-site.yml", (text) => diff --git a/scripts/deploy-site.mjs b/scripts/deploy-site.mjs index 780f48f1..f40088d4 100644 --- a/scripts/deploy-site.mjs +++ b/scripts/deploy-site.mjs @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { waitForSiteRevision } from "./verify-site-revision.mjs"; const COMMIT_PATTERN = /^[0-9a-f]{40}$/u; const FIXED_CONFIG = Object.freeze({ @@ -8,11 +9,16 @@ const FIXED_CONFIG = Object.freeze({ release: "t4-site", chart: "deploy/charts/t4-site", imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site", - hostname: "t4code.com", + revisionTarget: "origin", }); export function resolveDeployConfig(environment = process.env) { - const imageTag = (environment.T4_SITE_IMAGE_TAG ?? environment.CI_COMMIT_SHA ?? "").trim(); + const configuredTag = environment.T4_SITE_IMAGE_TAG?.trim(); + const ciCommit = environment.CI_COMMIT_SHA?.trim(); + if (configuredTag && ciCommit && configuredTag !== ciCommit) { + throw new Error("T4_SITE_IMAGE_TAG must match CI_COMMIT_SHA when both are set"); + } + const imageTag = configuredTag ?? ciCommit ?? ""; if (!COMMIT_PATTERN.test(imageTag)) { throw new Error("T4_SITE_IMAGE_TAG or CI_COMMIT_SHA must be an exact lowercase 40-character commit SHA"); } @@ -22,7 +28,7 @@ export function resolveDeployConfig(environment = process.env) { ["T4_SITE_RELEASE", FIXED_CONFIG.release], ["T4_SITE_CHART", FIXED_CONFIG.chart], ["T4_SITE_IMAGE_REPOSITORY", FIXED_CONFIG.imageRepository], - ["T4_SITE_HOSTNAME", FIXED_CONFIG.hostname], + ["T4_SITE_REVISION_TARGET", FIXED_CONFIG.revisionTarget], ]) { const configured = environment[name]?.trim(); if (configured && configured !== expected) { @@ -41,7 +47,12 @@ function run(command, args, cwd) { } } -export function deploySite(config, repoRoot = resolve(import.meta.dirname, ".."), runCommand = run) { +export async function deploySite( + config, + repoRoot = resolve(import.meta.dirname, ".."), + runCommand = run, + verifyRevision = waitForSiteRevision, +) { const timeout = "10m"; runCommand( "helm", @@ -77,32 +88,17 @@ export function deploySite(config, repoRoot = resolve(import.meta.dirname, "..") ], repoRoot, ); - runCommand( - "curl", - [ - "--fail", - "--silent", - "--show-error", - "--location", - "--retry", - "12", - "--retry-all-errors", - "--retry-delay", - "5", - "--proto", - "=https", - "--tlsv1.2", - `https://${config.hostname}/`, - ], - repoRoot, - ); + await verifyRevision({ + expectedRevision: config.imageTag, + target: config.revisionTarget, + }); } const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (isMain) { try { - deploySite(resolveDeployConfig()); + await deploySite(resolveDeployConfig()); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/scripts/deploy-site.test.mjs b/scripts/deploy-site.test.mjs index 6bfd9bd9..c73f6b1c 100644 --- a/scripts/deploy-site.test.mjs +++ b/scripts/deploy-site.test.mjs @@ -9,7 +9,7 @@ const expectedConfig = { release: "t4-site", chart: "deploy/charts/t4-site", imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site", - hostname: "t4code.com", + revisionTarget: "origin", imageTag: commit, }; @@ -25,23 +25,33 @@ test("site deploy config rejects mutable image tags and production target overri () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit.toUpperCase() }), /40-character commit SHA/u, ); + assert.throws( + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, CI_COMMIT_SHA: "b".repeat(40) }), + /must match CI_COMMIT_SHA/u, + ); assert.throws( () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_NAMESPACE: "default" }), /T4_SITE_NAMESPACE must be t4-site/u, ); assert.throws( - () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_HOSTNAME: "t4code.net" }), - /T4_SITE_HOSTNAME must be t4code.com/u, + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_REVISION_TARGET: "public" }), + /T4_SITE_REVISION_TARGET must be origin/u, ); }); -test("site deploy atomically applies Helm, confirms rollout, and verifies HTTPS", () => { +test("site deploy atomically applies Helm, confirms rollout, and verifies the exact origin revision", async () => { const calls = []; - deploySite(expectedConfig, "/repo", (command, args, cwd) => calls.push({ command, args, cwd })); + const revisions = []; + await deploySite( + expectedConfig, + "/repo", + (command, args, cwd) => calls.push({ command, args, cwd }), + async (options) => revisions.push(options), + ); assert.deepEqual( calls.map(({ command }) => command), - ["helm", "kubectl", "curl"], + ["helm", "kubectl"], ); assert.deepEqual(calls[0].args, [ "upgrade", @@ -69,23 +79,9 @@ test("site deploy atomically applies Helm, confirms rollout, and verifies HTTPS" "--timeout", "10m", ]); - assert.deepEqual(calls[2].args, [ - "--fail", - "--silent", - "--show-error", - "--location", - "--retry", - "12", - "--retry-all-errors", - "--retry-delay", - "5", - "--proto", - "=https", - "--tlsv1.2", - "https://t4code.com/", - ]); assert.deepEqual( calls.map(({ cwd }) => cwd), - ["/repo", "/repo", "/repo"], + ["/repo", "/repo"], ); + assert.deepEqual(revisions, [{ expectedRevision: commit, target: "origin" }]); }); diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs new file mode 100644 index 00000000..199a0b18 --- /dev/null +++ b/scripts/site-pipeline.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import yaml from "js-yaml"; + +async function loadYaml(path) { + return yaml.load(await readFile(path, "utf8")); +} + +test("GitHub only builds and verifies the exact public revision", async () => { + const [source, workflow] = await Promise.all([ + readFile(".github/workflows/deploy-site.yml", "utf8"), + loadYaml(".github/workflows/deploy-site.yml"), + ]); + assert.deepEqual(workflow.permissions, { contents: "read" }); + assert.deepEqual(workflow.on.push.branches, ["main", "master"]); + + const job = workflow.jobs["verify-production"]; + const commands = job.steps.map((step) => step.run ?? "").join("\n"); + assert.match(commands, /pnpm test:deploy:site/u); + assert.match(commands, /pnpm build:site/u); + assert.match(commands, /verify-site-revision\.mjs[\s\S]*--target public[\s\S]*--expected "\$SOURCE_SHA"/u); + assert.match(commands, /wait-for-release-assets\.mjs/u); + assert.match(commands, /git merge-base --is-ancestor "\$source_sha" "\$TRUSTED_SHA"/u); + assert.match(source, /steps\.release_state\.outputs\.state == 'not-published'/u); + assert.doesNotMatch(source, /kubeconfig|kubectl|\bhelm\b|aws-access-key|aws_secret|amazonaws/iu); +}); + +test("Woodpecker has a complete main-only site chain independent of controller publication", async () => { + const pipeline = await loadYaml(".woodpecker.yml"); + const siteSteps = { + "harbor-auth-site": ["dependencies"], + "prepare-site-image": ["dependencies"], + "build-site": ["harbor-auth-site", "prepare-site-image"], + "promote-site": ["build-site"], + "deploy-site": ["promote-site"], + "cleanup-site-registry-auth": ["deploy-site"], + }; + + for (const [name, dependencies] of Object.entries(siteSteps)) { + const step = pipeline.steps[name]; + assert.ok(step, `${name} must exist`); + assert.deepEqual(step.depends_on, dependencies); + assert.equal(step.when[0].event, "push"); + assert.equal(step.when[0].branch, "main"); + assert.ok(!JSON.stringify(step.depends_on).includes("controller")); + } + assert.equal( + pipeline.steps["deploy-site"].backend_options.kubernetes.serviceAccountName, + "woodpecker-dev-verifier", + ); + assert.equal( + pipeline.steps["build-site"].environment.T4_REGISTRY_AUTH_DIR, + ".site-ci/registry-auth", + ); + assert.equal( + pipeline.steps["promote-site"].environment.T4_REGISTRY_AUTH_DIR, + ".site-ci/registry-auth", + ); +}); + +test("site image build and promotion use only the immutable Woodpecker commit SHA", async () => { + const [build, promote] = await Promise.all([ + readFile("scripts/build-site-image.sh", "utf8"), + readFile("scripts/promote-site-image.sh", "utf8"), + ]); + assert.match(build, /build-arg:SOURCE_COMMIT=\$CI_COMMIT_SHA/u); + assert.match(build, /repository="\$HARBOR_REGISTRY\/\$HARBOR_PROJECT\/quarantine\/t4-site"/u); + assert.match(build, /reference="\$repository:\$CI_COMMIT_SHA"/u); + assert.match(promote, /quarantine\/t4-site:\$CI_COMMIT_SHA/u); + assert.match(promote, /linkedin-bot\/t4-site:\$CI_COMMIT_SHA|\$HARBOR_PROJECT\/t4-site:\$CI_COMMIT_SHA/u); + assert.doesNotMatch(`${build}\n${promote}`, /:latest\b/u); +}); diff --git a/scripts/verify-site-revision.mjs b/scripts/verify-site-revision.mjs new file mode 100644 index 00000000..9de7efb3 --- /dev/null +++ b/scripts/verify-site-revision.mjs @@ -0,0 +1,165 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { readBoundedResponseBytes } from "./read-bounded-response.mjs"; + +const COMMIT_PATTERN = /^[0-9a-f]{40}$/u; +const MAX_RESPONSE_BYTES = 1024; + +export const SITE_REVISION_URLS = Object.freeze({ + origin: "https://t4-site.tailb18de3.ts.net/revision.json", + public: "https://t4code.com/revision.json", +}); + +export const DEFAULT_REVISION_WAIT = Object.freeze({ + timeoutMs: 15 * 60 * 1000, + intervalMs: 10_000, + requestTimeoutMs: 5_000, +}); + +function positiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +export function requireSourceRevision(revision) { + if (typeof revision !== "string" || !COMMIT_PATTERN.test(revision)) { + throw new Error("expected revision must be an exact lowercase 40-character commit SHA"); + } + return revision; +} + +export function revisionUrl(target) { + if (!Object.hasOwn(SITE_REVISION_URLS, target)) { + throw new Error("target must be public or origin"); + } + const url = new URL(SITE_REVISION_URLS[target]); + if (url.protocol !== "https:") throw new Error(`${target} revision URL must use HTTPS`); + if (target === "public" && url.hostname !== "t4code.com") { + throw new Error("public revision URL must use t4code.com"); + } + return url.href; +} + +export async function fetchSiteRevision({ + target, + requestTimeoutMs = DEFAULT_REVISION_WAIT.requestTimeoutMs, + fetchImpl = fetch, +}) { + positiveInteger(requestTimeoutMs, "requestTimeoutMs"); + const url = revisionUrl(target); + const response = await fetchImpl(url, { + cache: "no-store", + headers: { Accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(requestTimeoutMs), + }); + if (response.status !== 200) { + await response.body?.cancel?.(); + throw new Error(`${target} revision endpoint returned HTTP ${response.status}`); + } + const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase(); + if (contentType !== "application/json") { + await response.body?.cancel?.(); + throw new Error(`${target} revision endpoint did not return application/json`); + } + + const bytes = await readBoundedResponseBytes(response, { + maxBytes: MAX_RESPONSE_BYTES, + label: `${target} revision response`, + }); + let document; + try { + document = JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error(`${target} revision endpoint returned invalid JSON`); + } + if ( + !document || + typeof document !== "object" || + Array.isArray(document) || + Object.keys(document).length !== 1 || + !COMMIT_PATTERN.test(document.revision) + ) { + throw new Error(`${target} revision endpoint returned an invalid revision document`); + } + return document.revision; +} + +export async function waitForSiteRevision({ + expectedRevision, + target = "public", + timeoutMs = DEFAULT_REVISION_WAIT.timeoutMs, + intervalMs = DEFAULT_REVISION_WAIT.intervalMs, + requestTimeoutMs = DEFAULT_REVISION_WAIT.requestTimeoutMs, + fetchImpl = fetch, + now = Date.now, + sleep = (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)), + logger = console, +}) { + const expected = requireSourceRevision(expectedRevision); + revisionUrl(target); + positiveInteger(timeoutMs, "timeoutMs"); + positiveInteger(intervalMs, "intervalMs"); + positiveInteger(requestTimeoutMs, "requestTimeoutMs"); + + const startedAt = now(); + let attempts = 0; + let lastObservation = "not checked"; + while (true) { + attempts += 1; + try { + const actual = await fetchSiteRevision({ target, requestTimeoutMs, fetchImpl }); + lastObservation = `revision ${actual}`; + if (actual === expected) { + const elapsedMs = now() - startedAt; + logger.log(`${target} revision ${expected} verified after ${attempts} check${attempts === 1 ? "" : "s"}.`); + return { target, url: revisionUrl(target), revision: actual, attempts, elapsedMs }; + } + } catch (error) { + lastObservation = error instanceof Error ? error.message : String(error); + } + + const elapsedMs = now() - startedAt; + if (elapsedMs >= timeoutMs) { + throw new Error( + `Timed out after ${elapsedMs} ms waiting for ${target} revision ${expected}; last observation: ${lastObservation}`, + ); + } + const delayMs = Math.min(intervalMs, timeoutMs - elapsedMs); + logger.log( + `${target} has not published revision ${expected} (${lastObservation}); checking again in ${delayMs} ms.`, + ); + await sleep(delayMs); + } +} + +function parseArguments(argv) { + const options = { target: "public", ...DEFAULT_REVISION_WAIT }; + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!value) throw new Error(`${name ?? "argument"} requires a value`); + if (name === "--expected") options.expectedRevision = value; + else if (name === "--target") options.target = value; + else if (name === "--timeout-ms") options.timeoutMs = Number(value); + else if (name === "--interval-ms") options.intervalMs = Number(value); + else if (name === "--request-timeout-ms") options.requestTimeoutMs = Number(value); + else throw new Error(`unknown argument: ${name}`); + } + if (!options.expectedRevision) throw new Error("--expected is required"); + return options; +} + +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + try { + await waitForSiteRevision(parseArguments(process.argv.slice(2))); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/verify-site-revision.test.mjs b/scripts/verify-site-revision.test.mjs new file mode 100644 index 00000000..8e5d4d73 --- /dev/null +++ b/scripts/verify-site-revision.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + fetchSiteRevision, + revisionUrl, + waitForSiteRevision, +} from "./verify-site-revision.mjs"; + +const expected = "a".repeat(40); +const stale = "b".repeat(40); + +function revisionResponse(revision, options = {}) { + return new Response(JSON.stringify({ revision }), { + status: options.status ?? 200, + headers: { "content-type": options.contentType ?? "application/json" }, + }); +} + +test("public verification is pinned to the HTTPS t4code.com revision endpoint", async () => { + assert.equal(revisionUrl("public"), "https://t4code.com/revision.json"); + assert.equal(revisionUrl("origin"), "https://t4-site.tailb18de3.ts.net/revision.json"); + assert.throws(() => revisionUrl("https://attacker.example/revision.json"), /public or origin/u); + + const requests = []; + const revision = await fetchSiteRevision({ + target: "public", + fetchImpl: async (url, init) => { + requests.push({ url, init }); + return revisionResponse(expected); + }, + }); + assert.equal(revision, expected); + assert.deepEqual(requests.map(({ url }) => url), ["https://t4code.com/revision.json"]); + assert.equal(requests[0].init.redirect, "error"); + assert.equal(requests[0].init.cache, "no-store"); +}); + +test("waiter retries stale and unavailable revisions until the exact SHA is public", async () => { + let clock = 0; + const logs = []; + const responses = [ + new Response("unavailable", { status: 503 }), + revisionResponse(stale), + revisionResponse(expected), + ]; + const result = await waitForSiteRevision({ + expectedRevision: expected, + target: "public", + timeoutMs: 50, + intervalMs: 10, + requestTimeoutMs: 5, + fetchImpl: async () => responses.shift(), + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + logger: { log: (message) => logs.push(message) }, + }); + + assert.deepEqual(result, { + target: "public", + url: "https://t4code.com/revision.json", + revision: expected, + attempts: 3, + elapsedMs: 20, + }); + assert.equal(logs.length, 3); +}); + +test("waiter has a deterministic deadline and reports the last observed revision", async () => { + let clock = 0; + await assert.rejects( + waitForSiteRevision({ + expectedRevision: expected, + target: "origin", + timeoutMs: 20, + intervalMs: 10, + requestTimeoutMs: 5, + fetchImpl: async () => revisionResponse(stale), + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + logger: { log() {} }, + }), + new RegExp(`Timed out after 20 ms.*revision ${stale}`, "u"), + ); +}); + +test("verifier rejects mutable SHAs and malformed revision documents", async () => { + await assert.rejects( + waitForSiteRevision({ expectedRevision: "main", timeoutMs: 1, intervalMs: 1 }), + /40-character commit SHA/u, + ); + await assert.rejects( + fetchSiteRevision({ + target: "public", + fetchImpl: async () => + new Response(JSON.stringify({ revision: expected, extra: true }), { + headers: { "content-type": "application/json" }, + }), + }), + /invalid revision document/u, + ); + await assert.rejects( + fetchSiteRevision({ + target: "public", + fetchImpl: async () => revisionResponse(expected, { contentType: "text/plain" }), + }), + /application\/json/u, + ); +}); From 8cf97a8136ce2c6c682c83f1d7d78102bebc4f6d Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:50:48 +0000 Subject: [PATCH 03/12] Harden site deployment provenance --- .github/workflows/deploy-site.yml | 4 ++-- .woodpecker.yml | 4 ++-- deploy/bootstrap/t4-site-upstream-mirror.yaml | 7 ++++++- deploy/charts/t4-site/templates/_helpers.tpl | 4 ++++ deploy/charts/t4-site/values.schema.json | 3 ++- deploy/charts/t4-site/values.yaml | 1 + scripts/build-site-image.sh | 2 +- scripts/deploy-site.mjs | 9 ++++++++- scripts/deploy-site.test.mjs | 20 +++++++++++++++---- scripts/promote-site-image.sh | 3 +++ 10 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index ab8e1a40..496951fc 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -19,8 +19,8 @@ permissions: contents: read concurrency: - group: t4code-com-production - cancel-in-progress: false + group: t4code-com-production-${{ github.event_name }} + cancel-in-progress: ${{ github.event_name == 'push' }} jobs: verify-production: diff --git a/.woodpecker.yml b/.woodpecker.yml index 46aec7a7..02885c97 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -445,7 +445,7 @@ steps: environment: T4_SITE_IMAGE_REPOSITORY: harbor.tailb18de3.ts.net/linkedin-bot/t4-site commands: - - node scripts/deploy-site.mjs + - export T4_SITE_IMAGE_DIGEST="$(cat .site-ci/site-image-digest)" && node scripts/deploy-site.mjs when: - event: push branch: main @@ -468,7 +468,7 @@ steps: depends_on: [deploy-site] image: mirror.gcr.io/library/busybox:1.37.0-musl@sha256:222ad6d973c0d198014546a65cd02c5fdedcc172123c5b4c2bf0af636550bd94 commands: - - rm -rf .site-ci/registry-auth + - rm -rf .site-ci when: - event: push branch: main diff --git a/deploy/bootstrap/t4-site-upstream-mirror.yaml b/deploy/bootstrap/t4-site-upstream-mirror.yaml index 6a3afde3..bb4bbf7d 100644 --- a/deploy/bootstrap/t4-site-upstream-mirror.yaml +++ b/deploy/bootstrap/t4-site-upstream-mirror.yaml @@ -44,9 +44,14 @@ spec: https://github.com/LycaonLLC/t4-code.git "${worktree}/repository" git -C "${worktree}/repository" remote add gitlab \ https://gitlab.com/z-peterson/t4-code.git + commit="$(git -C "${worktree}/repository" rev-parse HEAD)" + current="$(git ls-remote --heads gitlab refs/heads/main | awk '{print $1}')" + if [ "${current}" = "${commit}" ]; then + printf 'GitLab main already matches upstream revision %s.\n' "${commit}" + exit 0 + fi git -C "${worktree}/repository" push --force gitlab \ HEAD:refs/heads/main - commit="$(git -C "${worktree}/repository" rev-parse HEAD)" payload="$(printf '{\"repo\":\"z-peterson/t4-code\",\"branch\":\"main\",\"commit\":\"%s\",\"event\":\"manual\",\"environment\":\"development\"}' "${commit}")" wget --quiet --timeout=90 --tries=1 --output-document=- \ --header="Authorization: Bearer ${WOODPECKER_TRIGGER_TOKEN}" \ diff --git a/deploy/charts/t4-site/templates/_helpers.tpl b/deploy/charts/t4-site/templates/_helpers.tpl index 0ebaa259..d9d52932 100644 --- a/deploy/charts/t4-site/templates/_helpers.tpl +++ b/deploy/charts/t4-site/templates/_helpers.tpl @@ -30,5 +30,9 @@ app.kubernetes.io/instance: {{ .Release.Name | quote }} {{- end -}} {{- define "t4-site.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}} {{- end -}} +{{- end -}} diff --git a/deploy/charts/t4-site/values.schema.json b/deploy/charts/t4-site/values.schema.json index 87257e8e..89c78078 100644 --- a/deploy/charts/t4-site/values.schema.json +++ b/deploy/charts/t4-site/values.schema.json @@ -8,10 +8,11 @@ "image": { "type": "object", "additionalProperties": false, - "required": ["repository", "tag", "pullPolicy"], + "required": ["repository", "tag", "digest", "pullPolicy"], "properties": { "repository": { "type": "string", "minLength": 1 }, "tag": { "type": "string" }, + "digest": { "type": "string", "pattern": "^(|sha256:[0-9a-f]{64})$" }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } } }, diff --git a/deploy/charts/t4-site/values.yaml b/deploy/charts/t4-site/values.yaml index 90d16377..5a9d9eab 100644 --- a/deploy/charts/t4-site/values.yaml +++ b/deploy/charts/t4-site/values.yaml @@ -3,6 +3,7 @@ replicaCount: 2 image: repository: harbor.tailb18de3.ts.net/linkedin-bot/t4-site tag: "" + digest: "" pullPolicy: IfNotPresent imagePullSecrets: diff --git a/scripts/build-site-image.sh b/scripts/build-site-image.sh index 21427ab3..205b0d7f 100755 --- a/scripts/build-site-image.sh +++ b/scripts/build-site-image.sh @@ -2,7 +2,7 @@ set -eu umask 077 -canonical_build_source_repository=usr-bin-roygbiv/t4-code +canonical_build_source_repository=LycaonLLC/t4-code authorized_ci_mirror=z-peterson/t4-code dockerfile=cluster/images/site/Dockerfile diff --git a/scripts/deploy-site.mjs b/scripts/deploy-site.mjs index f40088d4..2792ebab 100644 --- a/scripts/deploy-site.mjs +++ b/scripts/deploy-site.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { waitForSiteRevision } from "./verify-site-revision.mjs"; const COMMIT_PATTERN = /^[0-9a-f]{40}$/u; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; const FIXED_CONFIG = Object.freeze({ namespace: "t4-site", release: "t4-site", @@ -22,6 +23,10 @@ export function resolveDeployConfig(environment = process.env) { if (!COMMIT_PATTERN.test(imageTag)) { throw new Error("T4_SITE_IMAGE_TAG or CI_COMMIT_SHA must be an exact lowercase 40-character commit SHA"); } + const imageDigest = environment.T4_SITE_IMAGE_DIGEST?.trim() ?? ""; + if (!DIGEST_PATTERN.test(imageDigest)) { + throw new Error("T4_SITE_IMAGE_DIGEST must be an exact lowercase sha256 digest"); + } for (const [name, expected] of [ ["T4_SITE_NAMESPACE", FIXED_CONFIG.namespace], @@ -36,7 +41,7 @@ export function resolveDeployConfig(environment = process.env) { } } - return { ...FIXED_CONFIG, imageTag }; + return { ...FIXED_CONFIG, imageTag, imageDigest }; } function run(command, args, cwd) { @@ -72,6 +77,8 @@ export async function deploySite( `image.repository=${config.imageRepository}`, "--set-string", `image.tag=${config.imageTag}`, + "--set-string", + `image.digest=${config.imageDigest}`, ], repoRoot, ); diff --git a/scripts/deploy-site.test.mjs b/scripts/deploy-site.test.mjs index c73f6b1c..e9c7a4ad 100644 --- a/scripts/deploy-site.test.mjs +++ b/scripts/deploy-site.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { deploySite, resolveDeployConfig } from "./deploy-site.mjs"; const commit = "a".repeat(40); +const digest = `sha256:${"b".repeat(64)}`; const expectedConfig = { namespace: "t4-site", release: "t4-site", @@ -11,15 +12,24 @@ const expectedConfig = { imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site", revisionTarget: "origin", imageTag: commit, + imageDigest: digest, }; test("site deploy config binds production to the exact commit image", () => { - assert.deepEqual(resolveDeployConfig({ CI_COMMIT_SHA: commit }), expectedConfig); - assert.deepEqual(resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit }), expectedConfig); + assert.deepEqual(resolveDeployConfig({ CI_COMMIT_SHA: commit, T4_SITE_IMAGE_DIGEST: digest }), expectedConfig); + assert.deepEqual(resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_IMAGE_DIGEST: digest }), expectedConfig); }); test("site deploy config rejects mutable image tags and production target overrides", () => { assert.throws(() => resolveDeployConfig({}), /40-character commit SHA/u); + assert.throws( + () => resolveDeployConfig({ CI_COMMIT_SHA: commit }), + /T4_SITE_IMAGE_DIGEST must be an exact lowercase sha256 digest/u, + ); + assert.throws( + () => resolveDeployConfig({ CI_COMMIT_SHA: commit, T4_SITE_IMAGE_DIGEST: "sha256:latest" }), + /T4_SITE_IMAGE_DIGEST must be an exact lowercase sha256 digest/u, + ); assert.throws(() => resolveDeployConfig({ T4_SITE_IMAGE_TAG: "latest" }), /40-character commit SHA/u); assert.throws( () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit.toUpperCase() }), @@ -30,11 +40,11 @@ test("site deploy config rejects mutable image tags and production target overri /must match CI_COMMIT_SHA/u, ); assert.throws( - () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_NAMESPACE: "default" }), + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_IMAGE_DIGEST: digest, T4_SITE_NAMESPACE: "default" }), /T4_SITE_NAMESPACE must be t4-site/u, ); assert.throws( - () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_REVISION_TARGET: "public" }), + () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_IMAGE_DIGEST: digest, T4_SITE_REVISION_TARGET: "public" }), /T4_SITE_REVISION_TARGET must be origin/u, ); }); @@ -69,6 +79,8 @@ test("site deploy atomically applies Helm, confirms rollout, and verifies the ex "image.repository=harbor.tailb18de3.ts.net/linkedin-bot/t4-site", "--set-string", `image.tag=${commit}`, + "--set-string", + `image.digest=${digest}`, ]); assert.deepEqual(calls[1].args, [ "--namespace", diff --git a/scripts/promote-site-image.sh b/scripts/promote-site-image.sh index 67a71d1a..519d83f8 100755 --- a/scripts/promote-site-image.sh +++ b/scripts/promote-site-image.sh @@ -19,6 +19,7 @@ test -r "$auth_dir/config.json" export DOCKER_CONFIG="$auth_dir" source="$HARBOR_REGISTRY/$HARBOR_PROJECT/quarantine/t4-site:$CI_COMMIT_SHA" destination="$HARBOR_REGISTRY/$HARBOR_PROJECT/t4-site:$CI_COMMIT_SHA" +digest_file=${T4_SITE_DIGEST_FILE:-.site-ci/site-image-digest} source_digest=$(oras resolve "$source") case "$source_digest" in sha256:????????????????????????????????????????????????????????????????) ;; @@ -39,3 +40,5 @@ else fi test "$(oras resolve "$destination")" = "$source_digest" +mkdir -p "$(dirname "$digest_file")" +printf '%s\n' "$source_digest" > "$digest_file" From f943a3773f3e4a257e5304fa1f94af22dcbc2ce0 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 04:21:12 +0000 Subject: [PATCH 04/12] fix(site): make cluster deployment reachable --- .woodpecker.yml | 33 +---- deploy/charts/t4-site/values.schema.json | 6 +- deploy/charts/t4-site/values.yaml | 3 +- infra/site/edge-startup.sh.tftpl | 85 ++++++++++++ infra/site/main.tf | 125 +++++++++++++++--- infra/site/outputs.tf | 23 +++- infra/site/tests/public_edge.tftest.hcl | 22 ++- infra/site/variables.tf | 57 ++++++-- .../cluster-ci/cluster-ci-contract.test.mjs | 10 +- scripts/site-pipeline.test.mjs | 7 +- 10 files changed, 295 insertions(+), 76 deletions(-) create mode 100755 infra/site/edge-startup.sh.tftpl diff --git a/.woodpecker.yml b/.woodpecker.yml index 02885c97..b0ea632a 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -339,7 +339,7 @@ steps: depends_on: [dependencies] image: harbor.tailb18de3.ts.net/linkedin-bot/woodpecker-buildkit-deploy-tools@sha256:7936be1edac8ce3c225b148cff0e89b1446fc88185d72379398617f7279ad8cc environment: - T4_REGISTRY_AUTH_DIR: .site-ci/registry-auth + T4_REGISTRY_AUTH_DIR: .cluster-ci/site-registry-auth commands: - sh scripts/cluster-ci/load-registry-auth.sh when: @@ -361,37 +361,14 @@ steps: ephemeral-storage: 512Mi - prepare-site-image: - depends_on: [dependencies] - image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 - commands: - - version=$(node -p "require('./package.json').version") && node scripts/generate-release-manifest.mjs --version "$version" --output apps/site/public/releases/latest.json - when: - - event: push - branch: main - - event: manual - branch: main - backend_options: - kubernetes: - serviceAccountName: woodpecker-ci-untrusted - resources: - requests: - cpu: "0" - memory: 128Mi - ephemeral-storage: 128Mi - limits: - cpu: 500m - memory: 512Mi - ephemeral-storage: 512Mi - build-site: - depends_on: [harbor-auth-site, prepare-site-image] + depends_on: [harbor-auth-site] image: mirror.gcr.io/moby/buildkit:v0.30.0-rootless@sha256:d76eb1caecac5733ef7553c1e90a1b21f1bb218cd1142d3553de0747b4a14ba9 environment: BUILDKIT_ADDR: tcp://woodpecker-buildkitd.linkedin-ci.svc.cluster.local:1234 HARBOR_REGISTRY: harbor.tailb18de3.ts.net HARBOR_PROJECT: linkedin-bot - T4_REGISTRY_AUTH_DIR: .site-ci/registry-auth + T4_REGISTRY_AUTH_DIR: .cluster-ci/site-registry-auth commands: - scripts/build-site-image.sh when: @@ -418,7 +395,7 @@ steps: environment: HARBOR_REGISTRY: harbor.tailb18de3.ts.net HARBOR_PROJECT: linkedin-bot - T4_REGISTRY_AUTH_DIR: .site-ci/registry-auth + T4_REGISTRY_AUTH_DIR: .cluster-ci/site-registry-auth commands: - scripts/promote-site-image.sh when: @@ -468,7 +445,7 @@ steps: depends_on: [deploy-site] image: mirror.gcr.io/library/busybox:1.37.0-musl@sha256:222ad6d973c0d198014546a65cd02c5fdedcc172123c5b4c2bf0af636550bd94 commands: - - rm -rf .site-ci + - rm -rf .site-ci .cluster-ci/site-registry-auth when: - event: push branch: main diff --git a/deploy/charts/t4-site/values.schema.json b/deploy/charts/t4-site/values.schema.json index 89c78078..8f1a0249 100644 --- a/deploy/charts/t4-site/values.schema.json +++ b/deploy/charts/t4-site/values.schema.json @@ -45,11 +45,7 @@ "className": { "type": "string", "const": "tailscale" }, "annotations": { "type": "object", - "additionalProperties": { "type": "string" }, - "required": ["tailscale.com/funnel"], - "properties": { - "tailscale.com/funnel": { "type": "string", "const": "true" } - } + "additionalProperties": { "type": "string" } }, "hostname": { "type": "string", "const": "t4-site" } } diff --git a/deploy/charts/t4-site/values.yaml b/deploy/charts/t4-site/values.yaml index 5a9d9eab..50c38f61 100644 --- a/deploy/charts/t4-site/values.yaml +++ b/deploy/charts/t4-site/values.yaml @@ -18,8 +18,7 @@ service: ingress: enabled: true className: tailscale - annotations: - tailscale.com/funnel: "true" + annotations: {} hostname: t4-site resources: diff --git a/infra/site/edge-startup.sh.tftpl b/infra/site/edge-startup.sh.tftpl new file mode 100755 index 00000000..83cc513d --- /dev/null +++ b/infra/site/edge-startup.sh.tftpl @@ -0,0 +1,85 @@ +#!/bin/bash +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends ca-certificates curl jq nginx +curl -fsSL https://tailscale.com/install.sh | sh + +cat >/usr/local/sbin/configure-t4-site-edge <<'SCRIPT' +#!/bin/bash +set -euo pipefail + +metadata() { + curl -fsS -H 'Metadata-Flavor: Google' "http://metadata.google.internal/computeMetadata/v1/$1" +} + +if ! tailscale status --json | jq -e '.BackendState == "Running"' >/dev/null; then + access_token="$(metadata instance/service-accounts/default/token | jq -er '.access_token')" + oauth_secret="$( + curl -fsS \ + -H "Authorization: Bearer $access_token" \ + 'https://secretmanager.googleapis.com/v1/projects/${project_id}/secrets/${secret_id}/versions/latest:access' \ + | jq -er '.payload.data' \ + | base64 -d + )" + + tailscale up \ + --accept-dns=true \ + --advertise-tags='${tailscale_tag}' \ + --auth-key="$oauth_secret" \ + --hostname=t4-site-edge + unset oauth_secret access_token +fi + +tailscale status --json | jq -e '.BackendState == "Running"' >/dev/null +getent hosts '${origin_hostname}' >/dev/null + +cat >/etc/nginx/sites-available/default <<'NGINX' +server { + listen ${edge_port} default_server; + listen [::]:${edge_port} default_server; + server_name _; + + location / { + proxy_pass https://${origin_hostname}; + proxy_http_version 1.1; + proxy_set_header Host ${origin_hostname}; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_ssl_server_name on; + proxy_ssl_name ${origin_hostname}; + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + } +} +NGINX + +nginx -t +systemctl enable nginx +systemctl restart nginx +SCRIPT +chmod 0750 /usr/local/sbin/configure-t4-site-edge + +cat >/etc/systemd/system/t4-site-edge.service <<'UNIT' +[Unit] +Description=t4code.com Tailscale edge gateway +After=network-online.target tailscaled.service +Wants=network-online.target tailscaled.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/configure-t4-site-edge +RemainAfterExit=yes +Restart=on-failure +RestartSec=15s + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl daemon-reload +systemctl enable --now t4-site-edge.service diff --git a/infra/site/main.tf b/infra/site/main.tf index 6edc1c01..944795d9 100644 --- a/infra/site/main.tf +++ b/infra/site/main.tf @@ -9,32 +9,127 @@ data "google_dns_managed_zone" "site" { project = var.project_id } -resource "google_compute_global_network_endpoint_group" "origin" { - name = "${local.resource_tag}-origin-neg" - project = var.project_id - network_endpoint_type = "INTERNET_FQDN_PORT" - default_port = var.origin_port +resource "google_service_account" "edge" { + account_id = "t4-site-edge" + display_name = "t4code.com edge gateway" + project = var.project_id +} + +resource "google_secret_manager_secret" "tailscale_oauth" { + project = var.project_id + secret_id = "t4-site-edge-tailscale-oauth-client-secret" + + replication { + auto {} + } +} + +resource "google_secret_manager_secret_iam_member" "edge_oauth" { + project = var.project_id + secret_id = google_secret_manager_secret.tailscale_oauth.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.edge.email}" +} + +resource "google_compute_address" "edge" { + name = "${local.resource_tag}-edge-address" + project = var.project_id + region = var.region + address_type = "EXTERNAL" +} + +resource "google_compute_firewall" "edge_from_google" { + name = "${local.resource_tag}-edge-from-google" + project = var.project_id + network = var.network + direction = "INGRESS" + source_ranges = ["130.211.0.0/22", "35.191.0.0/16"] + target_tags = [local.resource_tag] + + allow { + protocol = "tcp" + ports = [tostring(var.edge_port)] + } +} + +resource "google_compute_instance" "edge" { + name = "${local.resource_tag}-edge" + project = var.project_id + zone = var.zone + machine_type = var.edge_machine_type + tags = [local.resource_tag] + + allow_stopping_for_update = true + + boot_disk { + initialize_params { + image = "projects/debian-cloud/global/images/family/debian-12" + size = 10 + type = "pd-standard" + } + } + + network_interface { + network = var.network + + access_config { + nat_ip = google_compute_address.edge.address + } + } + + service_account { + email = google_service_account.edge.email + scopes = ["cloud-platform"] + } + + metadata_startup_script = templatefile("${path.module}/edge-startup.sh.tftpl", { + edge_port = var.edge_port + origin_hostname = var.origin_hostname + project_id = var.project_id + secret_id = google_secret_manager_secret.tailscale_oauth.secret_id + tailscale_tag = var.tailscale_tag + }) + + depends_on = [google_secret_manager_secret_iam_member.edge_oauth] +} + +resource "google_compute_instance_group" "edge" { + name = "${local.resource_tag}-edge-group" + project = var.project_id + zone = var.zone + instances = [google_compute_instance.edge.self_link] + + named_port { + name = "http" + port = var.edge_port + } } -resource "google_compute_global_network_endpoint" "origin" { - project = var.project_id - global_network_endpoint_group = google_compute_global_network_endpoint_group.origin.name - fqdn = var.origin_fqdn - port = var.origin_port +resource "google_compute_health_check" "site" { + name = "${local.resource_tag}-health" + project = var.project_id + check_interval_sec = 10 + timeout_sec = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + + http_health_check { + port = var.edge_port + request_path = "/healthz" + } } resource "google_compute_backend_service" "site" { name = "${local.resource_tag}-backend" project = var.project_id - protocol = "HTTPS" - port_name = "https" + protocol = "HTTP" + port_name = "http" load_balancing_scheme = "EXTERNAL_MANAGED" timeout_sec = 30 - - custom_request_headers = ["Host: ${var.origin_fqdn}"] + health_checks = [google_compute_health_check.site.self_link] backend { - group = google_compute_global_network_endpoint_group.origin.self_link + group = google_compute_instance_group.edge.self_link } } diff --git a/infra/site/outputs.tf b/infra/site/outputs.tf index 74586ccd..557199f8 100644 --- a/infra/site/outputs.tf +++ b/infra/site/outputs.tf @@ -8,13 +8,28 @@ output "public_url" { value = "https://${local.domain}" } -output "origin_endpoint" { - description = "HTTPS Funnel origin configured in the Internet NEG." - value = "https://${var.origin_fqdn}:${var.origin_port}" +output "cluster_origin_hostname" { + description = "Tailnet-only Kubernetes ingress proxied by the edge gateway." + value = var.origin_hostname +} + +output "edge_instance_name" { + description = "Compute instance proxying Google Front End traffic into the tailnet." + value = google_compute_instance.edge.name +} + +output "edge_public_ip_address" { + description = "Static egress address used by the edge gateway to join the tailnet." + value = google_compute_address.edge.address +} + +output "tailscale_oauth_secret_name" { + description = "Secret Manager secret that must contain the edge Tailscale OAuth client secret." + value = google_secret_manager_secret.tailscale_oauth.secret_id } output "backend_service_name" { - description = "Global backend service forwarding requests to the Funnel origin." + description = "Global backend service forwarding requests to the edge gateway." value = google_compute_backend_service.site.name } diff --git a/infra/site/tests/public_edge.tftest.hcl b/infra/site/tests/public_edge.tftest.hcl index 12107371..a288645f 100644 --- a/infra/site/tests/public_edge.tftest.hcl +++ b/infra/site/tests/public_edge.tftest.hcl @@ -4,18 +4,28 @@ run "default_public_edge_contract" { command = apply assert { - condition = google_compute_global_network_endpoint_group.origin.network_endpoint_type == "INTERNET_FQDN_PORT" - error_message = "The origin must use a global Internet FQDN NEG." + condition = google_compute_instance.edge.zone == "us-central1-a" && google_compute_instance.edge.machine_type == "e2-micro" + error_message = "The edge gateway must use the small, explicitly zoned default instance." } assert { - condition = google_compute_global_network_endpoint.origin.fqdn == "davailocal.tailb18de3.ts.net" && google_compute_global_network_endpoint.origin.port == 10000 - error_message = "The default origin must target the HTTPS Tailscale Funnel relay." + condition = google_compute_backend_service.site.protocol == "HTTP" && google_compute_backend_service.site.port_name == "http" + error_message = "Google Front Ends must proxy HTTP to the private edge gateway port." } assert { - condition = google_compute_backend_service.site.protocol == "HTTPS" && contains(google_compute_backend_service.site.custom_request_headers, "Host: davailocal.tailb18de3.ts.net") - error_message = "The backend must use HTTPS and send the Funnel hostname as the Host header." + condition = contains(google_compute_firewall.edge_from_google.source_ranges, "130.211.0.0/22") && contains(google_compute_firewall.edge_from_google.source_ranges, "35.191.0.0/16") + error_message = "Only documented Google Front End and health-check ranges may reach the gateway." + } + + assert { + condition = google_secret_manager_secret.tailscale_oauth.secret_id == "t4-site-edge-tailscale-oauth-client-secret" && google_secret_manager_secret_iam_member.edge_oauth.member == "serviceAccount:${google_service_account.edge.email}" + error_message = "The gateway alone must be able to read its Tailscale credential." + } + + assert { + condition = strcontains(google_compute_instance.edge.metadata_startup_script, "t4-site.tailb18de3.ts.net") && strcontains(google_compute_instance.edge.metadata_startup_script, "tag:k8s-shared-ops-ingress-proxy") + error_message = "The gateway bootstrap must proxy the private Kubernetes ingress with the shared-ops identity." } assert { diff --git a/infra/site/variables.tf b/infra/site/variables.tf index 8b890d8e..12baa6c4 100644 --- a/infra/site/variables.tf +++ b/infra/site/variables.tf @@ -4,24 +4,59 @@ variable "project_id" { default = "media-node-636780" } -variable "origin_fqdn" { - description = "Public Tailscale Funnel hostname used by the load balancer's Internet NEG and Host header." +variable "region" { + description = "Google Cloud region for the public edge gateway." type = string - default = "davailocal.tailb18de3.ts.net" + default = "us-central1" +} + +variable "zone" { + description = "Google Cloud zone for the public edge gateway." + type = string + default = "us-central1-a" +} + +variable "network" { + description = "VPC network that carries load-balancer traffic to the edge gateway." + type = string + default = "default" +} + +variable "edge_machine_type" { + description = "Machine type for the single-purpose Tailscale edge gateway." + type = string + default = "e2-micro" +} + +variable "edge_port" { + description = "HTTP port exposed by the edge gateway to Google Front Ends." + type = number + default = 8080 validation { - condition = can(regex("^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", var.origin_fqdn)) - error_message = "origin_fqdn must be a lowercase fully qualified DNS hostname without a trailing dot." + condition = var.edge_port >= 1024 && var.edge_port <= 65535 + error_message = "edge_port must be an unprivileged TCP port between 1024 and 65535." } } -variable "origin_port" { - description = "HTTPS port exposed by the Tailscale Funnel origin." - type = number - default = 10000 +variable "origin_hostname" { + description = "Tailnet-only Kubernetes ingress hostname proxied by the edge gateway." + type = string + default = "t4-site.tailb18de3.ts.net" + + validation { + condition = can(regex("^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", var.origin_hostname)) + error_message = "origin_hostname must be a lowercase fully qualified DNS hostname without a trailing dot." + } +} + +variable "tailscale_tag" { + description = "Existing tailnet ACL tag assigned to the edge gateway." + type = string + default = "tag:k8s-shared-ops-ingress-proxy" validation { - condition = var.origin_port >= 1 && var.origin_port <= 65535 - error_message = "origin_port must be between 1 and 65535." + condition = can(regex("^tag:[a-z0-9-]+$", var.tailscale_tag)) + error_message = "tailscale_tag must be a lowercase Tailscale tag." } } diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs index 184cad23..522bad37 100644 --- a/scripts/cluster-ci/cluster-ci-contract.test.mjs +++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs @@ -397,6 +397,13 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication ); } + const mainOnlySiteSteps = new Set([ + "harbor-auth-site", + "build-site", + "promote-site", + "deploy-site", + "cleanup-site-registry-auth", + ]); for (const [name, step] of Object.entries(steps)) { assert.match(step.image, /@sha256:[0-9a-f]{64}$/u, `${name} image must be immutable`); if (step.environment?.HARBOR_REGISTRY) { @@ -404,7 +411,8 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication } for (const condition of step.when ?? []) { if (condition.event === "manual") { - assert.deepEqual(condition.branch, ["main", "agent/t4-cluster-operator"]); + const expectedBranches = mainOnlySiteSteps.has(name) ? "main" : ["main", "agent/t4-cluster-operator"]; + assert.deepEqual(condition.branch, expectedBranches); } } } diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index 199a0b18..a260c1bd 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -31,8 +31,7 @@ test("Woodpecker has a complete main-only site chain independent of controller p const pipeline = await loadYaml(".woodpecker.yml"); const siteSteps = { "harbor-auth-site": ["dependencies"], - "prepare-site-image": ["dependencies"], - "build-site": ["harbor-auth-site", "prepare-site-image"], + "build-site": ["harbor-auth-site"], "promote-site": ["build-site"], "deploy-site": ["promote-site"], "cleanup-site-registry-auth": ["deploy-site"], @@ -52,11 +51,11 @@ test("Woodpecker has a complete main-only site chain independent of controller p ); assert.equal( pipeline.steps["build-site"].environment.T4_REGISTRY_AUTH_DIR, - ".site-ci/registry-auth", + ".cluster-ci/site-registry-auth", ); assert.equal( pipeline.steps["promote-site"].environment.T4_REGISTRY_AUTH_DIR, - ".site-ci/registry-auth", + ".cluster-ci/site-registry-auth", ); }); From d37e0edf6b6fa73032153eaf2c369fda868d3ebd Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 04:29:06 +0000 Subject: [PATCH 05/12] chore(site): persist edge Terraform state --- infra/site/versions.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infra/site/versions.tf b/infra/site/versions.tf index b5b26f04..69388cba 100644 --- a/infra/site/versions.tf +++ b/infra/site/versions.tf @@ -1,5 +1,10 @@ terraform { required_version = ">= 1.7.0, < 2.0.0" + backend "gcs" { + bucket = "roybench-tfstate-672373544179" + prefix = "t4code-com/prod" + } + required_providers { google = { From 48526637200b7446f3918dbd7b2c6b4a92604434 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 04:44:09 +0000 Subject: [PATCH 06/12] fix(site): isolate the public edge network --- infra/site/main.tf | 27 ++++++++++++++++++++++--- infra/site/tests/public_edge.tftest.hcl | 5 +++++ infra/site/variables.tf | 5 ----- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/infra/site/main.tf b/infra/site/main.tf index 944795d9..837d2c46 100644 --- a/infra/site/main.tf +++ b/infra/site/main.tf @@ -31,6 +31,22 @@ resource "google_secret_manager_secret_iam_member" "edge_oauth" { member = "serviceAccount:${google_service_account.edge.email}" } +resource "google_compute_network" "edge" { + name = "${local.resource_tag}-edge-network" + project = var.project_id + auto_create_subnetworks = false + routing_mode = "REGIONAL" +} + +resource "google_compute_subnetwork" "edge" { + name = "${local.resource_tag}-edge-subnet" + project = var.project_id + region = var.region + network = google_compute_network.edge.id + ip_cidr_range = "10.64.0.0/28" + private_ip_google_access = true +} + resource "google_compute_address" "edge" { name = "${local.resource_tag}-edge-address" project = var.project_id @@ -41,7 +57,7 @@ resource "google_compute_address" "edge" { resource "google_compute_firewall" "edge_from_google" { name = "${local.resource_tag}-edge-from-google" project = var.project_id - network = var.network + network = google_compute_network.edge.id direction = "INGRESS" source_ranges = ["130.211.0.0/22", "35.191.0.0/16"] target_tags = [local.resource_tag] @@ -70,7 +86,7 @@ resource "google_compute_instance" "edge" { } network_interface { - network = var.network + subnetwork = google_compute_subnetwork.edge.id access_config { nat_ip = google_compute_address.edge.address @@ -94,15 +110,20 @@ resource "google_compute_instance" "edge" { } resource "google_compute_instance_group" "edge" { - name = "${local.resource_tag}-edge-group" + name = "${local.resource_tag}-edge-group-v2" project = var.project_id zone = var.zone + network = google_compute_network.edge.id instances = [google_compute_instance.edge.self_link] named_port { name = "http" port = var.edge_port } + lifecycle { + create_before_destroy = true + } + } resource "google_compute_health_check" "site" { diff --git a/infra/site/tests/public_edge.tftest.hcl b/infra/site/tests/public_edge.tftest.hcl index a288645f..47a3b227 100644 --- a/infra/site/tests/public_edge.tftest.hcl +++ b/infra/site/tests/public_edge.tftest.hcl @@ -13,6 +13,11 @@ run "default_public_edge_contract" { error_message = "Google Front Ends must proxy HTTP to the private edge gateway port." } + assert { + condition = !google_compute_network.edge.auto_create_subnetworks && google_compute_subnetwork.edge.ip_cidr_range == "10.64.0.0/28" + error_message = "The public gateway must run in an isolated custom subnet." + } + assert { condition = contains(google_compute_firewall.edge_from_google.source_ranges, "130.211.0.0/22") && contains(google_compute_firewall.edge_from_google.source_ranges, "35.191.0.0/16") error_message = "Only documented Google Front End and health-check ranges may reach the gateway." diff --git a/infra/site/variables.tf b/infra/site/variables.tf index 12baa6c4..0b2958f7 100644 --- a/infra/site/variables.tf +++ b/infra/site/variables.tf @@ -16,11 +16,6 @@ variable "zone" { default = "us-central1-a" } -variable "network" { - description = "VPC network that carries load-balancer traffic to the edge gateway." - type = string - default = "default" -} variable "edge_machine_type" { description = "Machine type for the single-purpose Tailscale edge gateway." From 6800c16e1e79b5090d1ed652137674315f27d10f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 05:10:18 +0000 Subject: [PATCH 07/12] fix(site): use the validated registry workspace --- .woodpecker.yml | 6 +----- scripts/site-pipeline.test.mjs | 8 ++++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index b0ea632a..c21f921b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -338,8 +338,6 @@ steps: harbor-auth-site: depends_on: [dependencies] image: harbor.tailb18de3.ts.net/linkedin-bot/woodpecker-buildkit-deploy-tools@sha256:7936be1edac8ce3c225b148cff0e89b1446fc88185d72379398617f7279ad8cc - environment: - T4_REGISTRY_AUTH_DIR: .cluster-ci/site-registry-auth commands: - sh scripts/cluster-ci/load-registry-auth.sh when: @@ -368,7 +366,6 @@ steps: BUILDKIT_ADDR: tcp://woodpecker-buildkitd.linkedin-ci.svc.cluster.local:1234 HARBOR_REGISTRY: harbor.tailb18de3.ts.net HARBOR_PROJECT: linkedin-bot - T4_REGISTRY_AUTH_DIR: .cluster-ci/site-registry-auth commands: - scripts/build-site-image.sh when: @@ -395,7 +392,6 @@ steps: environment: HARBOR_REGISTRY: harbor.tailb18de3.ts.net HARBOR_PROJECT: linkedin-bot - T4_REGISTRY_AUTH_DIR: .cluster-ci/site-registry-auth commands: - scripts/promote-site-image.sh when: @@ -445,7 +441,7 @@ steps: depends_on: [deploy-site] image: mirror.gcr.io/library/busybox:1.37.0-musl@sha256:222ad6d973c0d198014546a65cd02c5fdedcc172123c5b4c2bf0af636550bd94 commands: - - rm -rf .site-ci .cluster-ci/site-registry-auth + - rm -rf .site-ci when: - event: push branch: main diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index a260c1bd..40d7f2d7 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -49,14 +49,18 @@ test("Woodpecker has a complete main-only site chain independent of controller p pipeline.steps["deploy-site"].backend_options.kubernetes.serviceAccountName, "woodpecker-dev-verifier", ); + assert.equal(pipeline.steps["harbor-auth-site"].environment, undefined); assert.equal( pipeline.steps["build-site"].environment.T4_REGISTRY_AUTH_DIR, - ".cluster-ci/site-registry-auth", + undefined, ); assert.equal( pipeline.steps["promote-site"].environment.T4_REGISTRY_AUTH_DIR, - ".cluster-ci/site-registry-auth", + undefined, ); + assert.deepEqual(pipeline.steps["cleanup-site-registry-auth"].commands, [ + "rm -rf .site-ci", + ]); }); test("site image build and promotion use only the immutable Woodpecker commit SHA", async () => { From abb57a9c2f409b33326d062465c88988bea8593b Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 05:17:23 +0000 Subject: [PATCH 08/12] fix(site): accept full immutable commit ids --- scripts/build-site-image.sh | 2 +- scripts/site-pipeline.test.mjs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/build-site-image.sh b/scripts/build-site-image.sh index 205b0d7f..ea6ed1b7 100755 --- a/scripts/build-site-image.sh +++ b/scripts/build-site-image.sh @@ -7,7 +7,7 @@ authorized_ci_mirror=z-peterson/t4-code dockerfile=cluster/images/site/Dockerfile case "${CI_COMMIT_SHA:-}" in - [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; + [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; *) echo "CI_COMMIT_SHA must be an exact lowercase 40-character SHA" >&2; exit 64 ;; esac diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index 40d7f2d7..bdcf6054 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { readFile } from "node:fs/promises"; import test from "node:test"; @@ -63,6 +64,22 @@ test("Woodpecker has a complete main-only site chain independent of controller p ]); }); +test("site image build accepts only an exact 40-character commit SHA", () => { + const run = (commit) => + spawnSync("sh", ["scripts/build-site-image.sh"], { + encoding: "utf8", + env: { CI_COMMIT_SHA: commit, PATH: process.env.PATH }, + }); + + const valid = run("a".repeat(40)); + assert.notEqual(valid.status, 64); + assert.match(valid.stderr, /BUILDKIT_ADDR.*required/u); + + const truncated = run("a".repeat(39)); + assert.equal(truncated.status, 64); + assert.match(truncated.stderr, /exact lowercase 40-character SHA/u); +}); + test("site image build and promotion use only the immutable Woodpecker commit SHA", async () => { const [build, promote] = await Promise.all([ readFile("scripts/build-site-image.sh", "utf8"), From 170d38f458779707db05eac19928f7bc87eb4fef Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 05:22:16 +0000 Subject: [PATCH 09/12] fix(ci): use buildctl attestation options --- scripts/build-site-image.sh | 4 ++-- scripts/cluster-ci/build-image.sh | 4 ++-- scripts/cluster-ci/cluster-ci-contract.test.mjs | 3 +++ scripts/site-pipeline.test.mjs | 3 +++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/build-site-image.sh b/scripts/build-site-image.sh index ea6ed1b7..fb053437 100755 --- a/scripts/build-site-image.sh +++ b/scripts/build-site-image.sh @@ -42,5 +42,5 @@ buildctl --addr "$BUILDKIT_ADDR" build \ --opt "label:org.opencontainers.image.source=https://github.com/$canonical_build_source_repository" \ --opt "label:org.opencontainers.image.revision=$CI_COMMIT_SHA" \ --output "type=image,name=$reference,push=true,compression=zstd,force-compression=true,oci-mediatypes=true" \ - --attest type=sbom \ - --attest type=provenance,mode=max + --opt "attest:sbom=" \ + --opt "attest:provenance=mode=max" diff --git a/scripts/cluster-ci/build-image.sh b/scripts/cluster-ci/build-image.sh index c143ef0c..a90b995a 100755 --- a/scripts/cluster-ci/build-image.sh +++ b/scripts/cluster-ci/build-image.sh @@ -66,8 +66,8 @@ buildctl --addr "$BUILDKIT_ADDR" build \ --opt "label:org.opencontainers.image.source=https://github.com/$canonical_build_source_repository" \ --opt "label:org.opencontainers.image.revision=$CI_COMMIT_SHA" \ --output "type=image,name=$reference,push=true,compression=zstd,force-compression=true,oci-mediatypes=true" \ - --attest type=sbom \ - --attest type=provenance,mode=max \ + --opt "attest:sbom=" \ + --opt "attest:provenance=mode=max" \ --metadata-file "$metadata" digest=$(sed -n 's/.*"containerimage\.digest"[[:space:]]*:[[:space:]]*"\(sha256:[0-9a-f]\{64\}\)".*/\1/p' "$metadata") diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs index 522bad37..adfcca02 100644 --- a/scripts/cluster-ci/cluster-ci-contract.test.mjs +++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs @@ -430,6 +430,9 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication const buildSource = await readFile(resolve(repoRoot, "scripts/cluster-ci/build-image.sh"), "utf8"); assert.match(buildSource, /platform=linux\/amd64,linux\/arm64/u); + assert.match(buildSource, /--opt "attest:sbom="/u); + assert.match(buildSource, /--opt "attest:provenance=mode=max"/u); + assert.doesNotMatch(buildSource, /--attest/u); assert.match(buildSource, /source_context="https:\/\/github\.com\/\$canonical_build_source_repository\.git#\$CI_COMMIT_SHA"/u); assert.match(buildSource, /SOURCE_REPOSITORY=https:\/\/github\.com\/\$canonical_build_source_repository/u); assert.doesNotMatch(buildSource, /https:\/\/github\.com\/z-peterson\/t4-code/u); diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index bdcf6054..f89ffd89 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -88,6 +88,9 @@ test("site image build and promotion use only the immutable Woodpecker commit SH assert.match(build, /build-arg:SOURCE_COMMIT=\$CI_COMMIT_SHA/u); assert.match(build, /repository="\$HARBOR_REGISTRY\/\$HARBOR_PROJECT\/quarantine\/t4-site"/u); assert.match(build, /reference="\$repository:\$CI_COMMIT_SHA"/u); + assert.match(build, /--opt "attest:sbom="/u); + assert.match(build, /--opt "attest:provenance=mode=max"/u); + assert.doesNotMatch(build, /--attest/u); assert.match(promote, /quarantine\/t4-site:\$CI_COMMIT_SHA/u); assert.match(promote, /linkedin-bot\/t4-site:\$CI_COMMIT_SHA|\$HARBOR_PROJECT\/t4-site:\$CI_COMMIT_SHA/u); assert.doesNotMatch(`${build}\n${promote}`, /:latest\b/u); From d3f1e0e0fa4c7604659450bcf4520e48ecc200d0 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 05:32:28 +0000 Subject: [PATCH 10/12] fix(site): promote through cluster registry endpoint --- scripts/promote-site-image.sh | 8 ++++---- scripts/site-pipeline.test.mjs | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/promote-site-image.sh b/scripts/promote-site-image.sh index 519d83f8..95e4f5ce 100755 --- a/scripts/promote-site-image.sh +++ b/scripts/promote-site-image.sh @@ -20,13 +20,13 @@ export DOCKER_CONFIG="$auth_dir" source="$HARBOR_REGISTRY/$HARBOR_PROJECT/quarantine/t4-site:$CI_COMMIT_SHA" destination="$HARBOR_REGISTRY/$HARBOR_PROJECT/t4-site:$CI_COMMIT_SHA" digest_file=${T4_SITE_DIGEST_FILE:-.site-ci/site-image-digest} -source_digest=$(oras resolve "$source") +source_digest=$(oras resolve --plain-http "$source") case "$source_digest" in sha256:????????????????????????????????????????????????????????????????) ;; *) echo "site quarantine image did not resolve to an immutable digest" >&2; exit 65 ;; esac -if destination_digest=$(oras resolve "$destination" 2>&1); then +if destination_digest=$(oras resolve --plain-http "$destination" 2>&1); then if [ "$destination_digest" != "$source_digest" ]; then echo "site destination commit tag already resolves to a different digest" >&2 exit 65 @@ -36,9 +36,9 @@ else *"failed to resolve digest: $CI_COMMIT_SHA: not found") ;; *) printf '%s\n' "$destination_digest" >&2; exit 65 ;; esac - oras copy --recursive "$source" "$destination" + oras copy --plain-http --recursive "$source" "$destination" fi -test "$(oras resolve "$destination")" = "$source_digest" +test "$(oras resolve --plain-http "$destination")" = "$source_digest" mkdir -p "$(dirname "$digest_file")" printf '%s\n' "$source_digest" > "$digest_file" diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index f89ffd89..b657584a 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -93,5 +93,7 @@ test("site image build and promotion use only the immutable Woodpecker commit SH assert.doesNotMatch(build, /--attest/u); assert.match(promote, /quarantine\/t4-site:\$CI_COMMIT_SHA/u); assert.match(promote, /linkedin-bot\/t4-site:\$CI_COMMIT_SHA|\$HARBOR_PROJECT\/t4-site:\$CI_COMMIT_SHA/u); + assert.match(promote, /oras resolve --plain-http/u); + assert.match(promote, /oras copy --plain-http --recursive/u); assert.doesNotMatch(`${build}\n${promote}`, /:latest\b/u); }); From 63b6572c436a0d64142190fc692c6d8dfbe76341 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 05:40:46 +0000 Subject: [PATCH 11/12] fix(ci): recognize qualified missing image tags --- scripts/cluster-ci/cluster-ci-contract.test.mjs | 2 +- scripts/cluster-ci/promote-images.sh | 2 +- scripts/promote-site-image.sh | 2 +- scripts/site-pipeline.test.mjs | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs index adfcca02..fb34ef90 100644 --- a/scripts/cluster-ci/cluster-ci-contract.test.mjs +++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs @@ -473,7 +473,7 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication ); const promotionGuard = promotionSource.slice(preflightResolveOffset, recursiveCopyOffset); assert.match(promotionGuard, /if \[ "\$resolved" != "\$digest" \]; then[\s\S]*?exit 65\n fi\n else/u); - assert.match(promotionGuard, /"failed to resolve digest: \$CI_COMMIT_SHA: not found"/u); + assert.match(promotionGuard, /"failed to resolve digest: "\*"\$CI_COMMIT_SHA: not found"/u); assert.doesNotMatch(promotionSource.slice(0, preflightResolveOffset), /oras copy/u); const authSource = await readFile(resolve(repoRoot, "scripts/cluster-ci/load-registry-auth.sh"), "utf8"); assert.match(authSource, /chmod 0711 "\$auth_parent" "\$auth_dir"/u); diff --git a/scripts/cluster-ci/promote-images.sh b/scripts/cluster-ci/promote-images.sh index fef7a978..28490bb2 100755 --- a/scripts/cluster-ci/promote-images.sh +++ b/scripts/cluster-ci/promote-images.sh @@ -41,7 +41,7 @@ do fi else case "$resolved" in - *"failed to resolve digest: $CI_COMMIT_SHA: not found") ;; + *"failed to resolve digest: "*"$CI_COMMIT_SHA: not found") ;; *) printf '%s\n' "$resolved" >&2 echo "$component destination commit tag could not be resolved safely" >&2 diff --git a/scripts/promote-site-image.sh b/scripts/promote-site-image.sh index 95e4f5ce..b404076f 100755 --- a/scripts/promote-site-image.sh +++ b/scripts/promote-site-image.sh @@ -33,7 +33,7 @@ if destination_digest=$(oras resolve --plain-http "$destination" 2>&1); then fi else case "$destination_digest" in - *"failed to resolve digest: $CI_COMMIT_SHA: not found") ;; + *"failed to resolve digest: "*"$CI_COMMIT_SHA: not found") ;; *) printf '%s\n' "$destination_digest" >&2; exit 65 ;; esac oras copy --plain-http --recursive "$source" "$destination" diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index b657584a..5e0de99f 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -95,5 +95,6 @@ test("site image build and promotion use only the immutable Woodpecker commit SH assert.match(promote, /linkedin-bot\/t4-site:\$CI_COMMIT_SHA|\$HARBOR_PROJECT\/t4-site:\$CI_COMMIT_SHA/u); assert.match(promote, /oras resolve --plain-http/u); assert.match(promote, /oras copy --plain-http --recursive/u); + assert.match(promote, /"failed to resolve digest: "\*"\$CI_COMMIT_SHA: not found"/u); assert.doesNotMatch(`${build}\n${promote}`, /:latest\b/u); }); From 8dd000b70dee9f85b385cf9be8a9e25f80182317 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 05:56:45 +0000 Subject: [PATCH 12/12] fix(site): set ORAS copy transport per endpoint --- scripts/promote-site-image.sh | 2 +- scripts/site-pipeline.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/promote-site-image.sh b/scripts/promote-site-image.sh index b404076f..1aad14b2 100755 --- a/scripts/promote-site-image.sh +++ b/scripts/promote-site-image.sh @@ -36,7 +36,7 @@ else *"failed to resolve digest: "*"$CI_COMMIT_SHA: not found") ;; *) printf '%s\n' "$destination_digest" >&2; exit 65 ;; esac - oras copy --plain-http --recursive "$source" "$destination" + oras copy --from-plain-http --to-plain-http --recursive "$source" "$destination" fi test "$(oras resolve --plain-http "$destination")" = "$source_digest" diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs index 5e0de99f..aa385588 100644 --- a/scripts/site-pipeline.test.mjs +++ b/scripts/site-pipeline.test.mjs @@ -94,7 +94,7 @@ test("site image build and promotion use only the immutable Woodpecker commit SH assert.match(promote, /quarantine\/t4-site:\$CI_COMMIT_SHA/u); assert.match(promote, /linkedin-bot\/t4-site:\$CI_COMMIT_SHA|\$HARBOR_PROJECT\/t4-site:\$CI_COMMIT_SHA/u); assert.match(promote, /oras resolve --plain-http/u); - assert.match(promote, /oras copy --plain-http --recursive/u); + assert.match(promote, /oras copy --from-plain-http --to-plain-http --recursive/u); assert.match(promote, /"failed to resolve digest: "\*"\$CI_COMMIT_SHA: not found"/u); assert.doesNotMatch(`${build}\n${promote}`, /:latest\b/u); });