Compare commits
No commits in common. "main" and "codex/gateway-home-hero" have entirely different histories.
main
...
codex/gate
@ -1,15 +0,0 @@
|
||||
.git
|
||||
.github
|
||||
.next
|
||||
.contentlayer
|
||||
node_modules
|
||||
coverage
|
||||
dist
|
||||
build
|
||||
test-results
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
deploy/single-node/.env.runtime
|
||||
knowledge/.git
|
||||
36
.env.example
36
.env.example
@ -1,35 +1,3 @@
|
||||
# Frontend site base URLs
|
||||
APP_BASE_URL=
|
||||
NEXT_PUBLIC_APP_BASE_URL=
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
NEXT_PUBLIC_LOGIN_URL=
|
||||
NEXT_PUBLIC_DOCS_BASE_URL=
|
||||
DOCS_SERVICE_URL=https://docs.svc.plus
|
||||
DOCS_SERVICE_INTERNAL_URL=
|
||||
SESSION_COOKIE_SECURE=true
|
||||
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
|
||||
RUNTIME_HOSTNAME=
|
||||
NEXT_RUNTIME_HOSTNAME=
|
||||
DEPLOYMENT_HOSTNAME=
|
||||
RUNTIME_ENV=prod
|
||||
REGION=cn
|
||||
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
|
||||
NEXT_PUBLIC_RUNTIME_REGION=cn
|
||||
|
||||
# Upstream service endpoints
|
||||
# Use root service origins only. Do not point ACCOUNT_SERVICE_URL at console.svc.plus
|
||||
# and do not include /api/auth or any other path suffix here.
|
||||
ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
||||
NEXT_PUBLIC_ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
||||
SERVER_SERVICE_URL=https://api.svc.plus
|
||||
NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus
|
||||
SERVER_SERVICE_INTERNAL_URL=
|
||||
|
||||
# XWorkmate bridge runtime
|
||||
# Read server-side by /api/xworkmate/bridge. Do not expose the token as NEXT_PUBLIC_*.
|
||||
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
|
||||
BRIDGE_AUTH_TOKEN=
|
||||
|
||||
# OpenClaw assistant integrations
|
||||
# Use environment variables to prefill the assistant and integrations page.
|
||||
# Values are read server-side and are not hardcoded into the UI.
|
||||
@ -52,12 +20,9 @@ NEXT_PUBLIC_GISCUS_CATEGORY_ID=DIC_kwDOQoiZ_s4Clj_q
|
||||
INTERNAL_SERVICE_TOKEN=
|
||||
|
||||
# Cloudflare Web Analytics GraphQL credentials
|
||||
CLOUDFLARE_DNS_API_TOKEN=
|
||||
CLOUDFLARE_DNS_ZONE_TAG=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
|
||||
CLOUDFLARE_ZONE_TAG=
|
||||
|
||||
# Root email whitelist for privileged user-creation actions (comma-separated)
|
||||
# Default: admin@svc.plus
|
||||
@ -65,7 +30,6 @@ ROOT_EMAIL_WHITELIST=admin@svc.plus
|
||||
|
||||
# Stripe public price ids used by /prices, product pages, and /panel/subscription
|
||||
# These values are safe to expose to the browser. Use Stripe test-mode price ids for local/dev.
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
|
||||
|
||||
37
.github/actions/auto-tag/action.yml
vendored
Normal file
37
.github/actions/auto-tag/action.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: "Cloud-Neutral Auto Tag"
|
||||
description: "Generate Docker tags for main, release, PR and dev branches"
|
||||
inputs:
|
||||
image:
|
||||
description: "Base image name (e.g. ghcr.io/.../image)"
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
tags:
|
||||
description: "Generated Docker tags"
|
||||
value: ${{ steps.meta.outputs.tags }}
|
||||
labels:
|
||||
description: "Generated Docker labels"
|
||||
value: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Generate metadata (auto tags)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ inputs.image }}
|
||||
|
||||
tags: |
|
||||
# main → latest
|
||||
type=raw,enable=${{ github.ref == 'refs/heads/main' }},value=latest
|
||||
|
||||
# release tag(v1.2.3)
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
# PR → pr-123
|
||||
type=raw,enable=${{ startsWith(github.ref, 'refs/pull/') }},value=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
# dev/feature branches → branch name
|
||||
type=ref,event=branch
|
||||
90
.github/actions/build/action.yml
vendored
Normal file
90
.github/actions/build/action.yml
vendored
Normal file
@ -0,0 +1,90 @@
|
||||
name: Build
|
||||
description: Build artifacts for each service and platform with optional container publishing.
|
||||
inputs:
|
||||
service:
|
||||
description: Target service name
|
||||
required: true
|
||||
platform:
|
||||
description: Target platform (e.g., linux/amd64)
|
||||
required: true
|
||||
environment:
|
||||
description: Deployment environment (dev or prod)
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare matrix context
|
||||
id: matrix
|
||||
uses: ../matrix-support
|
||||
with:
|
||||
service: ${{ inputs.service }}
|
||||
platform: ${{ inputs.platform }}
|
||||
environment: ${{ inputs.environment }}
|
||||
enable_docker: 'true'
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
build/${{ inputs.service }}
|
||||
dashboard/.next
|
||||
key: build-${{ inputs.service }}-${{ inputs.platform }}-${{ hashFiles('**/go.sum', 'dashboard/yarn.lock') }}-${{ inputs.environment }}
|
||||
restore-keys: |
|
||||
build-${{ inputs.service }}-${{ inputs.platform }}-
|
||||
build-${{ inputs.service }}-
|
||||
|
||||
- name: Prepare Go toolchain
|
||||
if: inputs.service != 'dashboard'
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
|
||||
- name: Build Go binaries
|
||||
if: inputs.service != 'dashboard'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
goos="${{ steps.matrix.outputs.goos }}"
|
||||
goarch="${{ steps.matrix.outputs.goarch }}"
|
||||
mkdir -p build/${{ inputs.service }}/"${goos}-${goarch}"
|
||||
declare -a targets
|
||||
if [[ "${{ inputs.service }}" == "rag-server" ]]; then
|
||||
targets=("rag-server/cmd/xcontrol-server" "rag-server/cmd/rag-server-cli")
|
||||
elif [[ "${{ inputs.service }}" == "account" ]]; then
|
||||
targets=("account/cmd/accountsvc")
|
||||
else
|
||||
targets=("./...")
|
||||
fi
|
||||
for target in "${targets[@]}"; do
|
||||
binary_name=$(basename "$target")
|
||||
GOOS="$goos" GOARCH="$goarch" go build -o build/${{ inputs.service }}/"${goos}-${goarch}"/"${binary_name}" "$target"
|
||||
done
|
||||
|
||||
- name: Upload Go artifacts
|
||||
if: inputs.service != 'dashboard'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.service }}-${{ inputs.platform }}-${{ inputs.environment }}
|
||||
path: build/${{ inputs.service }}/
|
||||
|
||||
- name: Install dashboard dependencies
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build dashboard
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
env:
|
||||
NEXT_PUBLIC_ENV: ${{ inputs.environment }}
|
||||
run: yarn build
|
||||
|
||||
- name: Upload dashboard build output
|
||||
if: inputs.service == 'dashboard'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dashboard-${{ inputs.platform }}-${{ inputs.environment }}
|
||||
path: dashboard/.next
|
||||
53
.github/actions/code-quality/action.yml
vendored
Normal file
53
.github/actions/code-quality/action.yml
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
name: Code Quality
|
||||
description: Run linting and basic quality checks per service/platform/environment matrix entry.
|
||||
inputs:
|
||||
service:
|
||||
description: Target service name
|
||||
required: true
|
||||
platform:
|
||||
description: Target platform (e.g., linux/amd64)
|
||||
required: true
|
||||
environment:
|
||||
description: Deployment environment (dev or prod)
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare matrix context
|
||||
id: matrix
|
||||
uses: ./.github/actions/matrix-support
|
||||
with:
|
||||
service: ${{ inputs.service }}
|
||||
platform: ${{ inputs.platform }}
|
||||
environment: ${{ inputs.environment }}
|
||||
|
||||
- name: Install git-secrets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://github.com/awslabs/git-secrets.git
|
||||
sudo make install -C git-secrets
|
||||
git secrets --install
|
||||
git secrets --scan
|
||||
|
||||
- name: Go vet
|
||||
if: inputs.service != 'dashboard'
|
||||
shell: bash
|
||||
run: go vet ./...
|
||||
|
||||
- name: Go unit tests (quality gate)
|
||||
if: inputs.service != 'dashboard'
|
||||
shell: bash
|
||||
run: go test ./...
|
||||
|
||||
- name: Install dashboard dependencies
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Dashboard lint
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: yarn lint
|
||||
48
.github/actions/deploy/action.yml
vendored
Normal file
48
.github/actions/deploy/action.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
name: Deploy
|
||||
description: Coordinate deployments per service/environment.
|
||||
inputs:
|
||||
service:
|
||||
description: Target service name
|
||||
required: true
|
||||
platform:
|
||||
description: Target platform (e.g., linux/amd64)
|
||||
required: true
|
||||
environment:
|
||||
description: Deployment environment (dev or prod)
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare matrix context
|
||||
id: matrix
|
||||
uses: ./.github/actions/matrix-support
|
||||
with:
|
||||
service: ${{ inputs.service }}
|
||||
platform: ${{ inputs.platform }}
|
||||
environment: ${{ inputs.environment }}
|
||||
|
||||
- name: Prepare rollout context
|
||||
id: context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "service=${{ inputs.service }}" >> "$GITHUB_OUTPUT"
|
||||
echo "environment=${{ inputs.environment }}" >> "$GITHUB_OUTPUT"
|
||||
echo "platform=${{ inputs.platform }}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_channel=${{ steps.matrix.outputs.is_prod == 'true' && 'prod' || 'dev' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy placeholder
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_ENV: ${{ steps.context.outputs.environment }}
|
||||
TARGET_SERVICE: ${{ steps.context.outputs.service }}
|
||||
TARGET_PLATFORM: ${{ steps.context.outputs.platform }}
|
||||
RELEASE_CHANNEL: ${{ steps.context.outputs.release_channel }}
|
||||
run: |
|
||||
echo "Deploying ${TARGET_SERVICE} (${TARGET_PLATFORM}) to ${TARGET_ENV} namespace via ${RELEASE_CHANNEL} rollout"
|
||||
echo "Hook in Helm/kubectl/ArgoCD rollouts here"
|
||||
|
||||
- name: Rollback plan
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Rollback can be re-run per matrix entry by dispatching with allow_deploy=true"
|
||||
106
.github/actions/matrix-support/action.yml
vendored
Normal file
106
.github/actions/matrix-support/action.yml
vendored
Normal file
@ -0,0 +1,106 @@
|
||||
name: Matrix Support
|
||||
description: Common setup for matrix-driven workflows with language and cache bootstrapping.
|
||||
inputs:
|
||||
service:
|
||||
description: Target service name
|
||||
required: true
|
||||
platform:
|
||||
description: Target platform (e.g., linux/amd64)
|
||||
required: true
|
||||
environment:
|
||||
description: Deployment environment (dev or prod)
|
||||
required: true
|
||||
enable_docker:
|
||||
description: Enable Docker buildx/QEMU setup
|
||||
required: false
|
||||
default: 'false'
|
||||
outputs:
|
||||
goos:
|
||||
description: Derived GOOS from the platform input
|
||||
value: ${{ steps.platforms.outputs.goos }}
|
||||
goarch:
|
||||
description: Derived GOARCH from the platform input
|
||||
value: ${{ steps.platforms.outputs.goarch }}
|
||||
is_prod:
|
||||
description: Whether the environment is prod or the ref is a tag
|
||||
value: ${{ steps.flags.outputs.is_prod }}
|
||||
target_platforms:
|
||||
description: Platform list for builds (single in dev, multi-arch in prod)
|
||||
value: ${{ steps.flags.outputs.target_platforms }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive platform matrix values
|
||||
id: platforms
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
platform="${{ inputs.platform }}"
|
||||
goos="${platform%%/*}"
|
||||
goarch="${platform##*/}"
|
||||
echo "goos=${goos}" >> "$GITHUB_OUTPUT"
|
||||
echo "goarch=${goarch}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve environment flags
|
||||
id: flags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ inputs.environment }}" == "prod" || "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
echo "is_prod=true" >> "$GITHUB_OUTPUT"
|
||||
echo "target_platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_prod=false" >> "$GITHUB_OUTPUT"
|
||||
echo "target_platforms=${{ inputs.platform }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
if: inputs.service != 'dashboard'
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
|
||||
- name: Cache Go build data
|
||||
if: inputs.service != 'dashboard'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: go-${{ inputs.service }}-${{ inputs.platform }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
go-${{ inputs.service }}-${{ inputs.platform }}-
|
||||
go-${{ inputs.service }}-
|
||||
|
||||
- name: Set up Node.js
|
||||
if: inputs.service == 'dashboard'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
cache-dependency-path: dashboard/yarn.lock
|
||||
|
||||
- name: Cache dashboard artifacts
|
||||
if: inputs.service == 'dashboard'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
dashboard/.next/cache
|
||||
~/.cache/yarn
|
||||
key: dashboard-${{ inputs.platform }}-${{ hashFiles('dashboard/yarn.lock') }}
|
||||
restore-keys: |
|
||||
dashboard-${{ inputs.platform }}-
|
||||
dashboard-
|
||||
|
||||
- name: Enable Docker build tooling
|
||||
if: inputs.enable_docker == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up buildx
|
||||
if: inputs.enable_docker == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
76
.github/actions/security/action.yml
vendored
Normal file
76
.github/actions/security/action.yml
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
name: Security
|
||||
description: Security scanning per service/platform/environment.
|
||||
inputs:
|
||||
service:
|
||||
description: Target service name
|
||||
required: true
|
||||
platform:
|
||||
description: Target platform (e.g., linux/amd64)
|
||||
required: true
|
||||
environment:
|
||||
description: Deployment environment (dev or prod)
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare matrix context
|
||||
id: matrix
|
||||
uses: ./.github/actions/matrix-support
|
||||
with:
|
||||
service: ${{ inputs.service }}
|
||||
platform: ${{ inputs.platform }}
|
||||
environment: ${{ inputs.environment }}
|
||||
|
||||
- name: Run golangci-lint
|
||||
if: inputs.service != 'dashboard'
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: ./...
|
||||
|
||||
- name: Install gosec
|
||||
if: inputs.service != 'dashboard'
|
||||
shell: bash
|
||||
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
|
||||
- name: Run gosec
|
||||
if: inputs.service != 'dashboard'
|
||||
shell: bash
|
||||
run: gosec ./...
|
||||
|
||||
- name: Trivy filesystem scan
|
||||
if: inputs.service != 'dashboard'
|
||||
uses: aquasecurity/trivy-action@0.24.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
format: table
|
||||
exit-code: "0"
|
||||
|
||||
- name: Install dashboard dependencies
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run ESLint
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: yarn lint
|
||||
|
||||
- name: Semgrep security rules
|
||||
if: inputs.service == 'dashboard'
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: p/ci
|
||||
paths: dashboard
|
||||
|
||||
- name: npm audit (production)
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: npm audit --production
|
||||
continue-on-error: true
|
||||
52
.github/actions/test/action.yml
vendored
Normal file
52
.github/actions/test/action.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
name: Test
|
||||
description: Run service-specific tests.
|
||||
inputs:
|
||||
service:
|
||||
description: Target service name
|
||||
required: true
|
||||
platform:
|
||||
description: Target platform (e.g., linux/amd64)
|
||||
required: true
|
||||
environment:
|
||||
description: Deployment environment (dev or prod)
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare matrix context
|
||||
id: matrix
|
||||
uses: ./.github/actions/matrix-support
|
||||
with:
|
||||
service: ${{ inputs.service }}
|
||||
platform: ${{ inputs.platform }}
|
||||
environment: ${{ inputs.environment }}
|
||||
|
||||
- name: Run Go integration tests
|
||||
if: inputs.service != 'dashboard'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test ./... -run Integration -count=1
|
||||
|
||||
- name: Install dashboard dependencies
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run dashboard unit tests
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
env:
|
||||
NODE_ENV: ${{ inputs.environment }}
|
||||
run: yarn test:unit
|
||||
|
||||
- name: Run dashboard e2e tests
|
||||
if: inputs.service == 'dashboard'
|
||||
working-directory: dashboard
|
||||
shell: bash
|
||||
env:
|
||||
PORT: 3100
|
||||
NODE_ENV: ${{ inputs.environment }}
|
||||
run: yarn test:e2e
|
||||
20
.github/scripts/build-images/copy-image-to-dockerhub.sh
vendored
Normal file
20
.github/scripts/build-images/copy-image-to-dockerhub.sh
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${ORG:?ORG is required}"
|
||||
: "${MANIFEST_DIGEST:?MANIFEST_DIGEST is required}"
|
||||
: "${TARGET_NS:?TARGET_NS is required}"
|
||||
: "${GHCR_USERNAME:?GHCR_USERNAME is required}"
|
||||
: "${GHCR_TOKEN:?GHCR_TOKEN is required}"
|
||||
: "${DOCKERHUB_USERNAME:?DOCKERHUB_USERNAME is required}"
|
||||
: "${DOCKERHUB_TOKEN:?DOCKERHUB_TOKEN is required}"
|
||||
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y skopeo
|
||||
|
||||
src="docker://ghcr.io/${ORG}/dashboard@${MANIFEST_DIGEST}"
|
||||
dst="docker://docker.io/${TARGET_NS}/dashboard:latest"
|
||||
|
||||
skopeo login ghcr.io -u "${GHCR_USERNAME}" -p "${GHCR_TOKEN}"
|
||||
skopeo login docker.io -u "${DOCKERHUB_USERNAME}" -p "${DOCKERHUB_TOKEN}"
|
||||
skopeo copy --all "${src}" "${dst}"
|
||||
31
.github/scripts/build-images/create-ghcr-manifest.sh
vendored
Normal file
31
.github/scripts/build-images/create-ghcr-manifest.sh
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${REGISTRY:?REGISTRY is required}"
|
||||
: "${ORG:?ORG is required}"
|
||||
: "${IMAGE_SHA:?IMAGE_SHA is required}"
|
||||
: "${AMD_DIGEST:?AMD_DIGEST is required}"
|
||||
: "${ARM_DIGEST:?ARM_DIGEST is required}"
|
||||
: "${TAGS_CSV:?TAGS_CSV is required}"
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
|
||||
src_amd="${REGISTRY}/${ORG}/dashboard:build-${IMAGE_SHA}-linux-amd64@${AMD_DIGEST}"
|
||||
src_arm="${REGISTRY}/${ORG}/dashboard:build-${IMAGE_SHA}-linux-arm64@${ARM_DIGEST}"
|
||||
|
||||
first_tag=""
|
||||
while IFS= read -r tag; do
|
||||
[ -n "${tag}" ] || continue
|
||||
if [ -z "${first_tag}" ]; then
|
||||
first_tag="${tag}"
|
||||
fi
|
||||
docker buildx imagetools create -t "${tag}" "${src_amd}" "${src_arm}"
|
||||
done < <(printf '%s' "${TAGS_CSV}" | tr ',' '\n')
|
||||
|
||||
[ -n "${first_tag}" ] || {
|
||||
echo "No tags were generated." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
manifest_digest="$(docker buildx imagetools inspect "${first_tag}" --format '{{.Digest}}')"
|
||||
echo "MANIFEST_DIGEST=${manifest_digest}" >> "${GITHUB_ENV}"
|
||||
echo "FINAL_TAG=${first_tag}" >> "${GITHUB_ENV}"
|
||||
7
.github/scripts/build-images/load-image-digest.sh
vendored
Normal file
7
.github/scripts/build-images/load-image-digest.sh
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${DIGEST_FILE:?DIGEST_FILE is required}"
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
|
||||
echo "IMAGE_DIGEST=$(tr -d '\n' < "${DIGEST_FILE}")" >> "${GITHUB_ENV}"
|
||||
9
.github/scripts/build-images/load-manifest-digests.sh
vendored
Normal file
9
.github/scripts/build-images/load-manifest-digests.sh
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${AMD_DIGEST_FILE:?AMD_DIGEST_FILE is required}"
|
||||
: "${ARM_DIGEST_FILE:?ARM_DIGEST_FILE is required}"
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
|
||||
echo "AMD_DIGEST=$(tr -d '\n' < "${AMD_DIGEST_FILE}")" >> "${GITHUB_ENV}"
|
||||
echo "ARM_DIGEST=$(tr -d '\n' < "${ARM_DIGEST_FILE}")" >> "${GITHUB_ENV}"
|
||||
7
.github/scripts/build-images/record-digest.sh
vendored
Normal file
7
.github/scripts/build-images/record-digest.sh
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${IMAGE_DIGEST:?IMAGE_DIGEST is required}"
|
||||
: "${OUTPUT_FILE:?OUTPUT_FILE is required}"
|
||||
|
||||
printf '%s\n' "${IMAGE_DIGEST}" > "${OUTPUT_FILE}"
|
||||
12
.github/scripts/build-images/set-image-ref.sh
vendored
Normal file
12
.github/scripts/build-images/set-image-ref.sh
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${REGISTRY:?REGISTRY is required}"
|
||||
: "${ORG:?ORG is required}"
|
||||
: "${SERVICE_NAME:?SERVICE_NAME is required}"
|
||||
: "${IMAGE_DIGEST:?IMAGE_DIGEST is required}"
|
||||
: "${IMAGE_SHA:?IMAGE_SHA is required}"
|
||||
: "${IMAGE_ARTIFACT:?IMAGE_ARTIFACT is required}"
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
|
||||
echo "IMG=${REGISTRY}/${ORG}/${SERVICE_NAME}:build-${IMAGE_SHA}-${IMAGE_ARTIFACT}@${IMAGE_DIGEST}" >> "${GITHUB_ENV}"
|
||||
11
.github/scripts/build-images/validate-blog-content-mount.sh
vendored
Normal file
11
.github/scripts/build-images/validate-blog-content-mount.sh
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${IMAGE:?IMAGE is required}"
|
||||
: "${KNOWLEDGE_CONTENT_DIR:?KNOWLEDGE_CONTENT_DIR is required}"
|
||||
|
||||
docker pull "${IMAGE}"
|
||||
docker run --rm \
|
||||
-v "${KNOWLEDGE_CONTENT_DIR}:/app/dashboard/src/content/blog:ro" \
|
||||
"${IMAGE}" \
|
||||
sh -c 'test -d /app/dashboard/src/content/blog'
|
||||
12
.github/scripts/check-image/check-images.sh
vendored
Normal file
12
.github/scripts/check-image/check-images.sh
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${TAG:?TAG is required}"
|
||||
: "${IMAGE_LIST_FILE:?IMAGE_LIST_FILE is required}"
|
||||
|
||||
while IFS= read -r image; do
|
||||
[ -n "${image}" ] || continue
|
||||
echo "Checking ${image}:${TAG}"
|
||||
docker manifest inspect "${image}:${TAG}" > /dev/null
|
||||
docker pull "${image}:${TAG}" > /dev/null
|
||||
done < "${IMAGE_LIST_FILE}"
|
||||
12
.github/scripts/check-image/images.txt
vendored
Normal file
12
.github/scripts/check-image/images.txt
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
ghcr.io/cloud-neutral-toolkit/openresty-geoip
|
||||
ghcr.io/cloud-neutral-toolkit/postgres-runtime
|
||||
ghcr.io/cloud-neutral-toolkit/account
|
||||
ghcr.io/cloud-neutral-toolkit/dashboard
|
||||
ghcr.io/cloud-neutral-toolkit/rag-server
|
||||
ghcr.io/cloud-neutral-toolkit/xcontrol-init
|
||||
docker.io/cloudneutral/openresty-geoip
|
||||
docker.io/cloudneutral/postgres-runtime
|
||||
docker.io/cloudneutral/account
|
||||
docker.io/cloudneutral/dashboard
|
||||
docker.io/cloudneutral/rag-server
|
||||
docker.io/cloudneutral/xcontrol-init
|
||||
9
.github/scripts/cosign/sign.sh
vendored
Executable file
9
.github/scripts/cosign/sign.sh
vendored
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
REG="ghcr.io/cloud-neutral-toolkit"
|
||||
|
||||
cosign sign --yes "$REG/node-builder@$NODE_BUILDER_DIGEST"
|
||||
cosign sign --yes "$REG/node-runtime@$NODE_RUNTIME_DIGEST"
|
||||
cosign sign --yes "$REG/openresty-geoip@$OPENRESTY_GEOIP_DIGEST"
|
||||
cosign sign --yes "$REG/postgres-runtime@$POSTGRES_RUNTIME_DIGEST"
|
||||
28
.github/scripts/metadata/gen.py
vendored
Executable file
28
.github/scripts/metadata/gen.py
vendored
Executable file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, sys
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
print("Usage: gen.py <image-name> <digest> <tags>")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1]
|
||||
digest = sys.argv[2]
|
||||
raw_tags = sys.argv[3]
|
||||
tags = raw_tags.splitlines()
|
||||
|
||||
preferred = next((t for t in tags if t.endswith(":latest")), tags[0] if tags else "")
|
||||
|
||||
metadata = {
|
||||
"name": name,
|
||||
"digest": digest,
|
||||
"tags": tags,
|
||||
"preferred_tag": preferred,
|
||||
"image": f"ghcr.io/cloud-neutral-toolkit/{name}",
|
||||
"image_with_digest": f"ghcr.io/cloud-neutral-toolkit/{name}@{digest}",
|
||||
}
|
||||
|
||||
outfile = f"image-metadata-{name}.json"
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
print(f"[metadata] Wrote: {outfile}")
|
||||
7
.github/scripts/sbom/generate.sh
vendored
Executable file
7
.github/scripts/sbom/generate.sh
vendored
Executable file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
IMAGE="$1"
|
||||
OUT="$2"
|
||||
|
||||
anchore-cli sbom generate "$IMAGE" -o "$OUT"
|
||||
15
.github/scripts/utils/preferred-tag.sh
vendored
Executable file
15
.github/scripts/utils/preferred-tag.sh
vendored
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
tags="$1"
|
||||
preferred=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" == *":latest" ]] && preferred="$line" && break
|
||||
done <<< "$tags"
|
||||
|
||||
if [[ -z "$preferred" ]]; then
|
||||
preferred="$(echo "$tags" | head -n 1)"
|
||||
fi
|
||||
|
||||
echo "$preferred"
|
||||
274
.github/workflows/build-images.yml
vendored
Normal file
274
.github/workflows/build-images.yml
vendored
Normal file
@ -0,0 +1,274 @@
|
||||
name: Build Dashboard Images
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push_images:
|
||||
description: "Push service images instead of local builds"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
dockerhub_namespace:
|
||||
description: "Docker Hub namespace (user/org)"
|
||||
type: string
|
||||
|
||||
skip_security:
|
||||
description: "Skip security scans and signing"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
node_builder_image:
|
||||
type: string
|
||||
default: "node:22-bookworm"
|
||||
|
||||
node_runtime_image:
|
||||
type: string
|
||||
default: "node:22-slim"
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
dockerhub_namespace:
|
||||
description: "Docker Hub namespace (user/org)"
|
||||
type: string
|
||||
default: "cloudneutral"
|
||||
|
||||
skip_security:
|
||||
description: "Skip security scans and signing"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
node_builder_image:
|
||||
type: string
|
||||
default: "node:22-bookworm"
|
||||
|
||||
node_runtime_image:
|
||||
type: string
|
||||
default: "node:22-slim"
|
||||
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# ✅ 不硬编码:默认推到 ghcr.io/<当前仓库 owner>/...
|
||||
ORG: ${{ github.repository_owner }}
|
||||
|
||||
SKIP_SECURITY: ${{ inputs.skip_security || github.event.inputs.skip_security || 'false' }}
|
||||
|
||||
NODE_BUILDER_IMAGE: ${{ inputs.node_builder_image || github.event.inputs.node_builder_image || 'node:22-bookworm' }}
|
||||
NODE_RUNTIME_IMAGE: ${{ inputs.node_runtime_image || github.event.inputs.node_runtime_image || 'node:22-slim' }}
|
||||
|
||||
PUSH_IMAGES: ${{ github.event_name == 'push'
|
||||
|| (github.event_name == 'workflow_call' && inputs.push_images)
|
||||
|| (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true') }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
arch:
|
||||
- { platform: linux/amd64, artifact: linux-amd64 }
|
||||
- { platform: linux/arm64, artifact: linux-arm64 }
|
||||
service:
|
||||
- { name: dashboard, workdir: ., dockerfile: Dockerfile }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate Auto Tags
|
||||
id: meta
|
||||
uses: ./.github/actions/auto-tag
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.service.name }}
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Clone knowledge content
|
||||
run: git clone https://github.com/Cloud-Neutral-Workshop/knowledge.git knowledge
|
||||
|
||||
- name: Build Service Image (per-arch)
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.service.workdir }}
|
||||
file: ${{ matrix.service.dockerfile }}
|
||||
platforms: ${{ matrix.arch.platform }}
|
||||
push: ${{ env.PUSH_IMAGES }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.ORG }}/${{ matrix.service.name }}:build-${{ github.sha }}-${{ matrix.arch.artifact }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GO_RUNTIME_IMAGE=${{ env.GO_RUNTIME_IMAGE }}
|
||||
NODE_BUILDER_IMAGE=${{ env.NODE_BUILDER_IMAGE }}
|
||||
NODE_RUNTIME_IMAGE=${{ env.NODE_RUNTIME_IMAGE }}
|
||||
CONTENTLAYER_BUILD=true
|
||||
|
||||
- name: Record digest
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
OUTPUT_FILE: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}.txt
|
||||
run: bash .github/scripts/build-images/record-digest.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}
|
||||
path: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}.txt
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: ${{ (github.event_name == 'push' || inputs.push_images == true || github.event.inputs.push_images == 'true') && !((inputs.skip_security == true) || (github.event.inputs.skip_security == 'true')) }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
arch:
|
||||
- { platform: linux/amd64, artifact: linux-amd64 }
|
||||
- { platform: linux/arm64, artifact: linux-arm64 }
|
||||
service:
|
||||
- { name: dashboard }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}
|
||||
|
||||
- name: Load image digest
|
||||
env:
|
||||
DIGEST_FILE: digest-${{ matrix.service.name }}-${{ matrix.arch.artifact }}.txt
|
||||
run: bash .github/scripts/build-images/load-image-digest.sh
|
||||
|
||||
- name: Set image ref
|
||||
env:
|
||||
SERVICE_NAME: ${{ matrix.service.name }}
|
||||
IMAGE_SHA: ${{ github.sha }}
|
||||
IMAGE_ARTIFACT: ${{ matrix.arch.artifact }}
|
||||
run: bash .github/scripts/build-images/set-image-ref.sh
|
||||
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ${{ env.IMG }}
|
||||
output-file: sbom.spdx.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sbom-${{ matrix.service.name }}-${{ matrix.arch.artifact }}
|
||||
path: sbom.spdx.json
|
||||
|
||||
- uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: ${{ env.IMG }}
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: '1'
|
||||
|
||||
- uses: sigstore/cosign-installer@v3
|
||||
with:
|
||||
cosign-release: 'v2.4.1'
|
||||
|
||||
- name: Cosign Sign Image
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: "true"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign --yes "${{ env.IMG }}"
|
||||
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
- security
|
||||
if: ${{ needs.build.result == 'success' && (github.event_name == 'push' || inputs.push_images == true || github.event.inputs.push_images == 'true') && ((inputs.skip_security == true) || (github.event.inputs.skip_security == 'true') || (needs.security.result == 'success')) }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry:
|
||||
- ghcr.io
|
||||
- docker.io
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: digest-dashboard-linux-amd64
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: digest-dashboard-linux-arm64
|
||||
|
||||
- name: Load digests
|
||||
env:
|
||||
AMD_DIGEST_FILE: digest-dashboard-linux-amd64.txt
|
||||
ARM_DIGEST_FILE: digest-dashboard-linux-arm64.txt
|
||||
run: bash .github/scripts/build-images/load-manifest-digests.sh
|
||||
|
||||
- name: Generate Auto Tags
|
||||
id: meta
|
||||
uses: ./.github/actions/auto-tag
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.ORG }}/dashboard
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
if: matrix.registry == 'ghcr.io'
|
||||
with:
|
||||
registry: ${{ matrix.registry }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create & Push Multi-Arch Manifests (GHCR)
|
||||
if: matrix.registry == 'ghcr.io'
|
||||
env:
|
||||
IMAGE_SHA: ${{ github.sha }}
|
||||
TAGS_CSV: ${{ steps.meta.outputs.tags }}
|
||||
run: bash .github/scripts/build-images/create-ghcr-manifest.sh
|
||||
|
||||
- name: Clone knowledge content
|
||||
if: matrix.registry == 'ghcr.io'
|
||||
run: git clone https://github.com/Cloud-Neutral-Workshop/knowledge.git knowledge
|
||||
|
||||
- name: Validate blog content mount
|
||||
if: matrix.registry == 'ghcr.io'
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.ORG }}/dashboard@${{ env.MANIFEST_DIGEST }}
|
||||
KNOWLEDGE_CONTENT_DIR: ${{ github.workspace }}/knowledge/content
|
||||
run: bash .github/scripts/build-images/validate-blog-content-mount.sh
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: matrix.registry == 'docker.io'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Copy Multi-Arch Image to Docker Hub (skopeo)
|
||||
if: matrix.registry == 'docker.io'
|
||||
env:
|
||||
TARGET_NS: ${{ inputs.dockerhub_namespace || github.event.inputs.dockerhub_namespace || 'cloudneutral' }}
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: bash .github/scripts/build-images/copy-image-to-dockerhub.sh
|
||||
29
.github/workflows/check-image.yaml
vendored
Normal file
29
.github/workflows/check-image.yaml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: Check XControl Image Ready
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
required: false
|
||||
default: latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Authenticate to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check images exist and are pullable
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
IMAGE_LIST_FILE: .github/scripts/check-image/images.txt
|
||||
run: bash .github/scripts/check-image/check-images.sh
|
||||
240
.github/workflows/pipeline.yaml
vendored
240
.github/workflows/pipeline.yaml
vendored
@ -1,240 +0,0 @@
|
||||
name: Console Service Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/pipeline.yaml"
|
||||
- "Dockerfile"
|
||||
- "deploy/single-node/**"
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "scripts/github-actions/build-and-push-frontend-image.sh"
|
||||
- "scripts/github-actions/compute-frontend-release-metadata.sh"
|
||||
- "scripts/github-actions/render-frontend-build-args.sh"
|
||||
- "scripts/github-actions/render-frontend-runtime-env.sh"
|
||||
- "scripts/github-actions/prepare-frontend-build-context.sh"
|
||||
- "scripts/github-actions/run-console-deploy-playbook.sh"
|
||||
- "scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh"
|
||||
- "scripts/github-actions/verify-frontend-release-over-ssh.sh"
|
||||
- "scripts/github-actions/verify-frontend-release.sh"
|
||||
- "scripts/prebuild.sh"
|
||||
- "contentlayer.config.ts"
|
||||
- "next.config.js"
|
||||
- "next.config.mjs"
|
||||
- "src/**"
|
||||
- "public/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_host:
|
||||
description: Ansible host or alias
|
||||
required: false
|
||||
default: "jp-xhttp-contabo.svc.plus"
|
||||
type: string
|
||||
run_apply:
|
||||
description: Apply deployment
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: console-pipeline-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CANONICAL_DOMAIN: www.svc.plus
|
||||
SERVED_DOMAINS: www.svc.plus,console.svc.plus
|
||||
APP_BASE_URL: https://www.svc.plus
|
||||
NEXT_PUBLIC_APP_BASE_URL: https://www.svc.plus
|
||||
NEXT_PUBLIC_SITE_URL: https://www.svc.plus
|
||||
RUNTIME_HOSTNAME: www.svc.plus
|
||||
NEXT_RUNTIME_HOSTNAME: www.svc.plus
|
||||
NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod
|
||||
NEXT_PUBLIC_RUNTIME_REGION: cn
|
||||
ACCOUNT_SERVICE_URL: https://accounts.svc.plus
|
||||
CLOUDFLARE_ZONE_TAG: bf3427f83a2c52c8285ab3d741a6ee27
|
||||
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG: 0973e84ec8872c67c570f8072e92e21b
|
||||
CLOUDFLARE_ACCOUNT_ID: e71be5efb76a6c54f78f008da4404f00
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
|
||||
GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN }}
|
||||
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
|
||||
|
||||
jobs:
|
||||
prep:
|
||||
name: Prep
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
target_host: ${{ steps.inputs.outputs.target_host }}
|
||||
run_apply: ${{ steps.inputs.outputs.run_apply }}
|
||||
image_tag: ${{ steps.metadata.outputs.image_tag }}
|
||||
image_ref: ${{ steps.metadata.outputs.image_ref }}
|
||||
image_latest_ref: ${{ steps.metadata.outputs.image_latest_ref }}
|
||||
ghcr_namespace: ${{ steps.metadata.outputs.ghcr_namespace }}
|
||||
push_latest: ${{ steps.push.outputs.push_latest }}
|
||||
steps:
|
||||
- name: Check Out Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Resolve Inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_TARGET_HOST: ${{ inputs.target_host }}
|
||||
INPUT_RUN_APPLY: ${{ inputs.run_apply }}
|
||||
run: bash scripts/github-actions/resolve-workflow-inputs.sh
|
||||
|
||||
- name: Compute Image Metadata
|
||||
id: metadata
|
||||
run: |
|
||||
bash scripts/github-actions/compute-frontend-release-metadata.sh
|
||||
|
||||
- name: Resolve Push Latest
|
||||
id: push
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
run: bash scripts/github-actions/resolve-push-latest.sh
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: prep
|
||||
outputs:
|
||||
image_ref: ${{ steps.publish.outputs.image_ref }}
|
||||
image_tag: ${{ steps.publish.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Check Out Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set Up Docker Buildx
|
||||
run: bash scripts/github-actions/setup-docker-buildx.sh
|
||||
|
||||
- name: Log In To GHCR
|
||||
env:
|
||||
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: bash scripts/github-actions/login-ghcr.sh
|
||||
|
||||
- name: Publish Frontend Image
|
||||
id: publish
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.prep.outputs.image_ref }}
|
||||
IMAGE_TAG: ${{ needs.prep.outputs.image_tag }}
|
||||
IMAGE_LATEST_REF: ${{ needs.prep.outputs.image_latest_ref }}
|
||||
PUSH_LATEST: ${{ needs.prep.outputs.push_latest }}
|
||||
run: bash scripts/github-actions/publish-frontend-image.sh
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prep
|
||||
- build
|
||||
env:
|
||||
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
|
||||
RUN_APPLY: ${{ needs.prep.outputs.run_apply }}
|
||||
FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
|
||||
steps:
|
||||
- name: Check Out Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check Out Playbooks Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
# Intentionally pinned: playbooks@main regressed deploy reliability on 2026-04-12.
|
||||
# Any future bump must pass a full Deploy + Validate run before becoming the default.
|
||||
repository: x-evor/playbooks
|
||||
ref: 80c545a95c3b16459f6494ed13d951faac57bfa8
|
||||
path: playbooks
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Set Up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Ansible
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install ansible
|
||||
|
||||
- name: Configure SSH For Deploy Host
|
||||
env:
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
|
||||
run: bash scripts/github-actions/configure-ssh-for-deploy.sh
|
||||
|
||||
- name: Run Deploy Playbook
|
||||
working-directory: playbooks
|
||||
env:
|
||||
ANSIBLE_HOST_KEY_CHECKING: "False"
|
||||
run: bash ../scripts/github-actions/run-console-deploy-playbook.sh
|
||||
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prep
|
||||
- build
|
||||
- deploy
|
||||
if: ${{ always() && needs.deploy.result == 'success' }}
|
||||
env:
|
||||
EXPECTED_FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
|
||||
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Check Out Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Configure SSH For Validate Host
|
||||
run: bash scripts/github-actions/configure-ssh-for-deploy.sh
|
||||
|
||||
- name: Verify Frontend Release On Host
|
||||
run: bash scripts/github-actions/verify-frontend-release-over-ssh.sh
|
||||
|
||||
update_dns:
|
||||
name: Update DNS
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prep
|
||||
- build
|
||||
- deploy
|
||||
if: ${{ always() && needs.deploy.result == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
TARGET_HOST: ${{ needs.prep.outputs.target_host }}
|
||||
RUN_APPLY: ${{ needs.prep.outputs.run_apply }}
|
||||
FRONTEND_IMAGE: ${{ needs.build.outputs.image_ref }}
|
||||
steps:
|
||||
- name: Check Out Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check Out Playbooks Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: x-evor/playbooks
|
||||
ref: 80c545a95c3b16459f6494ed13d951faac57bfa8
|
||||
path: playbooks
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Set Up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Ansible
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install ansible
|
||||
|
||||
- name: Update Cloudflare svc.plus DNS
|
||||
working-directory: playbooks
|
||||
env:
|
||||
ANSIBLE_HOST_KEY_CHECKING: "False"
|
||||
run: bash ../scripts/github-actions/run-cloudflare-svc-plus-dns-playbook.sh
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,7 +1,6 @@
|
||||
.env
|
||||
models/
|
||||
*.tsbuildinfo
|
||||
knowledge/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@ -56,7 +55,6 @@ coverage/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
deploy/single-node/.env.runtime
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
@ -4,7 +4,7 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmRegistryServer: "https://registry.npmjs.org"
|
||||
npmRegistryServer: "https://registry.npmmirror.com"
|
||||
|
||||
packageExtensions:
|
||||
"next-contentlayer@*":
|
||||
|
||||
41
AGENTS.md
41
AGENTS.md
@ -51,20 +51,7 @@ yarn test:e2e path/to/spec.test.ts
|
||||
|
||||
---
|
||||
|
||||
## 2. Release Traceability Default Rule
|
||||
|
||||
For any change touching CI/CD, image tags, deploy contracts, `/api/ping`, or `validate` behavior:
|
||||
|
||||
- Treat `skills/release-traceability/SKILL.md` as the default reference before implementation.
|
||||
- Prefer release metadata that can be traced from `build` to `deploy` to `validate` without manual injection.
|
||||
- Keep the published image reference, runtime version, and validation output aligned.
|
||||
- Do not introduce a deploy path that rebuilds images on the target host.
|
||||
|
||||
When in doubt, follow the skill first and keep the release chain fully auditable end to end.
|
||||
|
||||
---
|
||||
|
||||
## 3. Repository Mental Model (Read This First)
|
||||
## 2. Repository Mental Model (Read This First)
|
||||
|
||||
This repository has **three clearly separated layers**:
|
||||
|
||||
@ -104,7 +91,7 @@ Used for build-time or runtime wiring only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Import & Alias Rules (Critical)
|
||||
## 3. Import & Alias Rules (Critical)
|
||||
|
||||
### Dashboard code (src/\*\*)
|
||||
|
||||
@ -125,7 +112,7 @@ import { UserCard } from "@/components/UserCard";
|
||||
|
||||
---
|
||||
|
||||
## 5. TypeScript & Formatting Rules
|
||||
## 4. TypeScript & Formatting Rules
|
||||
|
||||
- Strict mode enabled
|
||||
- Use `type` for type definitions, `interface` for object shapes
|
||||
@ -136,7 +123,7 @@ import { UserCard } from "@/components/UserCard";
|
||||
|
||||
---
|
||||
|
||||
## 6. Naming Conventions
|
||||
## 5. Naming Conventions
|
||||
|
||||
- Components: PascalCase (`UserProfile.tsx`)
|
||||
- Files: kebab-case for utilities (`user-utils.ts`), PascalCase for components
|
||||
@ -146,7 +133,7 @@ import { UserCard } from "@/components/UserCard";
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling & Logging
|
||||
## 6. Error Handling & Logging
|
||||
|
||||
- Use try/catch for async operations
|
||||
- Return Result types or throw errors consistently
|
||||
@ -155,7 +142,7 @@ import { UserCard } from "@/components/UserCard";
|
||||
|
||||
---
|
||||
|
||||
## 8. React Patterns
|
||||
## 7. React Patterns
|
||||
|
||||
- Use `'use client'` directive for client components
|
||||
- Prefer function components with hooks
|
||||
@ -164,7 +151,7 @@ import { UserCard } from "@/components/UserCard";
|
||||
|
||||
---
|
||||
|
||||
## 9. Global State Rules (Dashboard Only)
|
||||
## 8. Global State Rules (Dashboard Only)
|
||||
|
||||
✅ Zustand is the **only** allowed global state mechanism
|
||||
❌ React Context for shared/global state is forbidden
|
||||
@ -175,7 +162,7 @@ Rule: If state must survive navigation or be shared → it lives in Zustand.
|
||||
|
||||
---
|
||||
|
||||
## 10. URL-Synchronized State
|
||||
## 9. URL-Synchronized State
|
||||
|
||||
Anything involving:
|
||||
|
||||
@ -192,7 +179,7 @@ MUST be handled inside Zustand slices.
|
||||
|
||||
---
|
||||
|
||||
## 11. Component State Rules
|
||||
## 10. Component State Rules
|
||||
|
||||
Allowed:
|
||||
|
||||
@ -207,7 +194,7 @@ Forbidden:
|
||||
|
||||
---
|
||||
|
||||
## 12. packages/neurapress Rules (Very Important)
|
||||
## 11. packages/neurapress Rules (Very Important)
|
||||
|
||||
packages/neurapress is treated as a vendored internal library.
|
||||
|
||||
@ -227,7 +214,7 @@ MUST NOT:
|
||||
|
||||
---
|
||||
|
||||
## 13. Testing Guidelines
|
||||
## 12. Testing Guidelines
|
||||
|
||||
- Unit tests: Vitest with jsdom environment
|
||||
- E2E tests: Playwright
|
||||
@ -237,7 +224,7 @@ MUST NOT:
|
||||
|
||||
---
|
||||
|
||||
## 14. Environment & Runtime Config
|
||||
## 13. Environment & Runtime Config
|
||||
|
||||
- No new environment variables without approval
|
||||
- Runtime config must live in: src/config/runtime-service-config\*.yaml
|
||||
@ -246,13 +233,13 @@ MUST NOT:
|
||||
|
||||
---
|
||||
|
||||
## 15. Cursor / Copilot Rules
|
||||
## 14. Cursor / Copilot Rules
|
||||
|
||||
- No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` found
|
||||
|
||||
---
|
||||
|
||||
## 16. TL;DR for AI Agents
|
||||
## 15. TL;DR for AI Agents
|
||||
|
||||
- dashboard = application
|
||||
- packages = libraries
|
||||
|
||||
67
Dockerfile
67
Dockerfile
@ -4,27 +4,6 @@
|
||||
ARG NODE_BUILDER_IMAGE=node:22-bookworm
|
||||
ARG NODE_RUNTIME_IMAGE=node:22-slim
|
||||
ARG CONTENTLAYER_BUILD=true
|
||||
ARG NEXT_PUBLIC_APP_BASE_URL=
|
||||
ARG NEXT_PUBLIC_SITE_URL=
|
||||
ARG NEXT_PUBLIC_LOGIN_URL=
|
||||
ARG NEXT_PUBLIC_DOCS_BASE_URL=
|
||||
ARG NEXT_PUBLIC_RUNTIME_ENVIRONMENT=
|
||||
ARG NEXT_PUBLIC_RUNTIME_REGION=
|
||||
ARG NEXT_PUBLIC_GISCUS_REPO=
|
||||
ARG NEXT_PUBLIC_GISCUS_REPO_ID=
|
||||
ARG NEXT_PUBLIC_GISCUS_CATEGORY=
|
||||
ARG NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||
ARG NEXT_PUBLIC_PAYPAL_CLIENT_ID=
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=
|
||||
ARG NEXT_PUBLIC_RELEASE_IMAGE=
|
||||
ARG NEXT_PUBLIC_RELEASE_TAG=
|
||||
ARG NEXT_PUBLIC_RELEASE_COMMIT=
|
||||
ARG NEXT_PUBLIC_RELEASE_VERSION=
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Stage 1 — Builder (Turbopack + standalone)
|
||||
@ -33,51 +12,8 @@ FROM ${NODE_BUILDER_IMAGE} AS builder
|
||||
|
||||
WORKDIR /app/dashboard
|
||||
|
||||
ARG NEXT_PUBLIC_APP_BASE_URL
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ARG NEXT_PUBLIC_LOGIN_URL
|
||||
ARG NEXT_PUBLIC_DOCS_BASE_URL
|
||||
ARG NEXT_PUBLIC_RUNTIME_ENVIRONMENT
|
||||
ARG NEXT_PUBLIC_RUNTIME_REGION
|
||||
ARG NEXT_PUBLIC_GISCUS_REPO
|
||||
ARG NEXT_PUBLIC_GISCUS_REPO_ID
|
||||
ARG NEXT_PUBLIC_GISCUS_CATEGORY
|
||||
ARG NEXT_PUBLIC_GISCUS_CATEGORY_ID
|
||||
ARG NEXT_PUBLIC_PAYPAL_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO
|
||||
ARG NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION
|
||||
ARG NEXT_PUBLIC_RELEASE_IMAGE
|
||||
ARG NEXT_PUBLIC_RELEASE_TAG
|
||||
ARG NEXT_PUBLIC_RELEASE_COMMIT
|
||||
ARG NEXT_PUBLIC_RELEASE_VERSION
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NEXT_PRIVATE_TURBOPACK=1 \
|
||||
NEXT_PUBLIC_APP_BASE_URL=${NEXT_PUBLIC_APP_BASE_URL} \
|
||||
NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} \
|
||||
NEXT_PUBLIC_LOGIN_URL=${NEXT_PUBLIC_LOGIN_URL} \
|
||||
NEXT_PUBLIC_DOCS_BASE_URL=${NEXT_PUBLIC_DOCS_BASE_URL} \
|
||||
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${NEXT_PUBLIC_RUNTIME_ENVIRONMENT} \
|
||||
NEXT_PUBLIC_RUNTIME_REGION=${NEXT_PUBLIC_RUNTIME_REGION} \
|
||||
NEXT_PUBLIC_GISCUS_REPO=${NEXT_PUBLIC_GISCUS_REPO} \
|
||||
NEXT_PUBLIC_GISCUS_REPO_ID=${NEXT_PUBLIC_GISCUS_REPO_ID} \
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY=${NEXT_PUBLIC_GISCUS_CATEGORY} \
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=${NEXT_PUBLIC_GISCUS_CATEGORY_ID} \
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID=${NEXT_PUBLIC_PAYPAL_CLIENT_ID} \
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO} \
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION} \
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO} \
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION} \
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO} \
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION} \
|
||||
NEXT_PUBLIC_RELEASE_IMAGE=${NEXT_PUBLIC_RELEASE_IMAGE} \
|
||||
NEXT_PUBLIC_RELEASE_TAG=${NEXT_PUBLIC_RELEASE_TAG} \
|
||||
NEXT_PUBLIC_RELEASE_COMMIT=${NEXT_PUBLIC_RELEASE_COMMIT} \
|
||||
NEXT_PUBLIC_RELEASE_VERSION=${NEXT_PUBLIC_RELEASE_VERSION}
|
||||
NEXT_PRIVATE_TURBOPACK=1
|
||||
|
||||
# ---------------------------
|
||||
# 基础镜像升级到最新
|
||||
@ -123,6 +59,7 @@ RUN apt-get update \
|
||||
COPY --from=builder /app/dashboard/.next/standalone ./
|
||||
COPY --from=builder /app/dashboard/.next/static ./static
|
||||
COPY --from=builder /app/dashboard/public ./public
|
||||
COPY --from=builder /app/dashboard/src/content/blog ./src/content/blog
|
||||
|
||||
# ---------------------------
|
||||
# 额外瘦身(可减少 15–40 MB)
|
||||
|
||||
2
Makefile
2
Makefile
@ -49,6 +49,7 @@ init:
|
||||
echo "❌ Unsupported OS. Please install Yarn manually."; exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
yarn config set npmRegistryServer https://registry.npmmirror.com
|
||||
yarn install --immutable
|
||||
|
||||
ensure-deps:
|
||||
@ -82,6 +83,7 @@ test:
|
||||
@yarn test || echo "No tests configured"
|
||||
|
||||
build: init
|
||||
yarn config set npmRegistryServer https://registry.npmmirror.com
|
||||
@if [ -z "$(SKIP_SYNC)" ]; then \
|
||||
$(MAKE) sync-dl-index; \
|
||||
fi
|
||||
|
||||
19
README.md
19
README.md
@ -20,7 +20,7 @@ Cloud Neutral Toolkit 的开放云控制面板 (Open Cloud Control Panel).
|
||||
### 一键初始化 (Setup Script)
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://raw.githubusercontent.com/x-evor/console.svc.plus/main/scripts/setup.sh?$(date +%s)" \
|
||||
curl -fsSL "https://raw.githubusercontent.com/cloud-neutral-toolkit/console.svc.plus/main/scripts/setup.sh?$(date +%s)" \
|
||||
| bash -s -- console.svc.plus
|
||||
```
|
||||
|
||||
@ -41,29 +41,22 @@ cp .env.example .env
|
||||
## 主要入口 (Key Routes)
|
||||
|
||||
- `/services`:服务导航页,保留现有控制台布局。
|
||||
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 `xworkmate-bridge` 的 `/acp/rpc` 接入。
|
||||
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 OpenClaw gateway 接入。
|
||||
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
|
||||
|
||||
## AI 助手与集成能力 (Assistant & Integrations)
|
||||
|
||||
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
|
||||
|
||||
- 侧栏助手模式保留现有交互方式,但 `/xworkmate` 主工作区直接对接 `xworkmate-bridge`。
|
||||
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway。
|
||||
- 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。
|
||||
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
|
||||
- `/panel/api` 仍保留旧集成配置入口;`/xworkmate` 主路径不依赖它。
|
||||
- bridge 地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
|
||||
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测。
|
||||
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
|
||||
|
||||
## 环境变量 (Environment Variables)
|
||||
|
||||
以下变量用于 `/xworkmate` 主工作区的服务端 bridge 代理:
|
||||
|
||||
| 变量 | 用途 |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| `BRIDGE_SERVER_URL` | XWorkmate bridge 服务根地址 |
|
||||
| `BRIDGE_AUTH_TOKEN` | XWorkmate bridge bearer token,服务端 |
|
||||
|
||||
以下变量用于旧助手和集成页的服务端默认值预填:
|
||||
以下变量用于主页 AI 助手和集成页的服务端默认值预填:
|
||||
|
||||
| 变量 | 用途 |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
|
||||
6
agent.md
6
agent.md
@ -16,12 +16,6 @@ You are an AI agent working inside this repository.
|
||||
- Keep changes scoped to the request; avoid unrelated refactors.
|
||||
- Prefer minimal edits that preserve existing behavior and style.
|
||||
|
||||
## Release Traceability Default Rule
|
||||
|
||||
- For changes touching CI/CD, image tags, deploy contracts, `/api/ping`, or `validate`, treat `skills/release-traceability/SKILL.md` as the default reference first.
|
||||
- Keep build output, runtime version, and validate output aligned through the whole release chain.
|
||||
- Do not add a deploy path that rebuilds images on the target host.
|
||||
|
||||
## Repository Constraints (Quick View)
|
||||
|
||||
- App layer: src/app/**, src/components/**, src/lib/**, src/state/**, src/modules/\*\*
|
||||
|
||||
@ -1,332 +0,0 @@
|
||||
# Feature flag inventory for the dashboard and public site.
|
||||
# This file is a human-readable catalog of page-level and module-level flags.
|
||||
|
||||
meta:
|
||||
app: console.svc.plus
|
||||
scope:
|
||||
- public-site
|
||||
- dashboard
|
||||
- xworkmate
|
||||
- cloud-iac
|
||||
- docs
|
||||
source:
|
||||
- src/config/feature-toggles.json
|
||||
- src/modules/extensions/builtin
|
||||
- src/app
|
||||
|
||||
runtime:
|
||||
current_implementation:
|
||||
toggles_json: src/config/feature-toggles.json
|
||||
loader: src/lib/featureToggles.ts
|
||||
extension_flags: src/lib/featureFlags.ts
|
||||
notes:
|
||||
- "feature-toggles.json is the active runtime source for path-based gating."
|
||||
- "This YAML is an inventory and planning file; it does not currently drive runtime behavior."
|
||||
|
||||
sections:
|
||||
globalNavigation:
|
||||
enabled: true
|
||||
description: Top-level navigation and auth entry points.
|
||||
default_channel: stable
|
||||
routes:
|
||||
- path: /
|
||||
status: enabled
|
||||
channel: stable
|
||||
- path: /docs
|
||||
status: enabled
|
||||
channel: beta
|
||||
- path: /blogs
|
||||
status: enabled
|
||||
note: Public content listing, not currently toggle-gated in code.
|
||||
- path: /services
|
||||
status: enabled
|
||||
note: Public service directory.
|
||||
- path: /prices
|
||||
status: enabled
|
||||
- path: /support
|
||||
status: enabled
|
||||
- path: /about
|
||||
status: enabled
|
||||
- path: /login
|
||||
status: enabled
|
||||
channel: stable
|
||||
- path: /register
|
||||
status: enabled
|
||||
channel: stable
|
||||
- path: /panel
|
||||
status: enabled
|
||||
channel: stable
|
||||
- path: /panel/management
|
||||
status: enabled
|
||||
note: Shown only to admin/operator users.
|
||||
- path: /cloud_iac
|
||||
status: enabled
|
||||
channel: develop
|
||||
- path: /download
|
||||
status: enabled
|
||||
channel: stable
|
||||
- path: /insight
|
||||
status: enabled
|
||||
channel: develop
|
||||
- path: /xworkmate
|
||||
status: enabled
|
||||
|
||||
appModules:
|
||||
enabled: true
|
||||
description: Path-based module gating used by route handlers and content loaders.
|
||||
routes:
|
||||
- path: /docs
|
||||
status: enabled
|
||||
uses: src/app/docs/page.tsx
|
||||
- path: /docs/[collection]
|
||||
status: enabled
|
||||
uses: src/app/docs/[collection]/page.tsx
|
||||
- path: /docs/[collection]/[...slug]
|
||||
status: enabled
|
||||
uses: src/app/docs/[collection]/[...slug]/page.tsx
|
||||
- path: /download
|
||||
status: enabled
|
||||
uses: src/app/download/page.tsx
|
||||
- path: /download/[...segments]
|
||||
status: enabled
|
||||
uses: src/app/download/[...segments]/page.tsx
|
||||
- path: /cloud_iac
|
||||
status: enabled
|
||||
uses: src/app/cloud_iac/page.tsx
|
||||
- path: /cloud_iac/[provider]
|
||||
status: enabled
|
||||
uses: src/app/cloud_iac/[provider]/page.tsx
|
||||
- path: /cloud_iac/[provider]/[service]
|
||||
status: enabled
|
||||
uses: src/app/cloud_iac/[provider]/[service]/page.tsx
|
||||
- path: /insight
|
||||
status: enabled
|
||||
uses: src/app/services/insight/page.tsx
|
||||
- path: /editor
|
||||
status: enabled
|
||||
uses: src/app/editor/page.tsx
|
||||
- path: /editor/wechat
|
||||
status: enabled
|
||||
- path: /editor/xiaohongshu
|
||||
status: enabled
|
||||
- path: /xworkmate
|
||||
status: enabled
|
||||
uses: src/app/xworkmate/page.tsx
|
||||
- path: /xworkmate/admin
|
||||
status: enabled
|
||||
uses: src/app/xworkmate/admin/page.tsx
|
||||
- path: /xworkmate/integrations
|
||||
status: enabled
|
||||
uses: src/app/xworkmate/integrations/page.tsx
|
||||
|
||||
cmsExperience:
|
||||
enabled: true
|
||||
description: CMS/homepage experience gating.
|
||||
routes:
|
||||
- path: /homepage
|
||||
status: enabled
|
||||
children:
|
||||
- path: /homepage/dynamic
|
||||
status: enabled
|
||||
|
||||
extensions:
|
||||
builtin.user-center:
|
||||
enabled: true
|
||||
description: Core dashboard user center and account management extension.
|
||||
routes:
|
||||
- path: /panel
|
||||
id: dashboard
|
||||
enabled: true
|
||||
sidebar_section: workspace
|
||||
- path: /panel/agent
|
||||
id: agents
|
||||
enabled: true
|
||||
env_var: NEXT_PUBLIC_FEATURE_AGENT_MODULE
|
||||
default_enabled: true
|
||||
sidebar_section: productivity
|
||||
- path: /panel/api
|
||||
id: apis
|
||||
enabled: true
|
||||
env_var: NEXT_PUBLIC_FEATURE_API_MODULE
|
||||
default_enabled: true
|
||||
sidebar_section: productivity
|
||||
- path: /panel/account
|
||||
id: accounts
|
||||
enabled: true
|
||||
sidebar_section: management
|
||||
- path: /panel/subscription
|
||||
id: subscription
|
||||
enabled: true
|
||||
env_var: NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE
|
||||
default_enabled: true
|
||||
sidebar_section: management
|
||||
- path: /panel/ldp
|
||||
id: ldp
|
||||
enabled: true
|
||||
env_var: NEXT_PUBLIC_FEATURE_LDP_MODULE
|
||||
default_enabled: false
|
||||
sidebar_section: management
|
||||
- path: /panel/appearance
|
||||
id: appearance
|
||||
enabled: true
|
||||
sidebar_section: preferences
|
||||
- path: /panel/management
|
||||
id: management
|
||||
enabled: true
|
||||
roles:
|
||||
- admin
|
||||
- operator
|
||||
permissions:
|
||||
- admin.settings.read
|
||||
- admin.users.metrics.read
|
||||
- admin.users.list.read
|
||||
- admin.agents.status.read
|
||||
- admin.blacklist.read
|
||||
sidebar_hidden: true
|
||||
|
||||
builtin.infra:
|
||||
enabled: true
|
||||
description: Infrastructure and ops extension.
|
||||
routes:
|
||||
- path: /panel/deployments
|
||||
id: deployments
|
||||
enabled: true
|
||||
sidebar_section: infra
|
||||
- path: /panel/resources
|
||||
id: resources
|
||||
enabled: true
|
||||
sidebar_section: infra
|
||||
- path: /panel/api-keys
|
||||
id: apiKeys
|
||||
enabled: true
|
||||
sidebar_section: infra
|
||||
- path: /panel/observability
|
||||
id: logs
|
||||
enabled: true
|
||||
sidebar_section: infra
|
||||
- path: /panel/settings
|
||||
id: settings
|
||||
enabled: true
|
||||
sidebar_section: preferences
|
||||
|
||||
pages:
|
||||
public:
|
||||
- path: /
|
||||
component: src/app/page.tsx
|
||||
status: enabled
|
||||
- path: /services
|
||||
component: src/app/services/page.tsx
|
||||
status: enabled
|
||||
- path: /about
|
||||
component: src/app/about/page.tsx
|
||||
status: enabled
|
||||
- path: /prices
|
||||
component: src/app/prices/page.tsx
|
||||
status: enabled
|
||||
- path: /support
|
||||
component: src/app/support/page.tsx
|
||||
status: enabled
|
||||
- path: /support/discussions
|
||||
component: src/app/support/discussions/page.tsx
|
||||
status: enabled
|
||||
- path: /blogs
|
||||
component: src/app/blogs/page.tsx
|
||||
status: enabled
|
||||
- path: /blogs/[...slug]
|
||||
component: src/app/blogs/[...slug]/page.tsx
|
||||
status: enabled
|
||||
- path: /terms
|
||||
component: src/app/terms/page.tsx
|
||||
status: enabled
|
||||
- path: /privacy
|
||||
component: src/app/privacy/page.tsx
|
||||
status: enabled
|
||||
- path: /download
|
||||
component: src/app/download/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /download/[...segments]
|
||||
component: src/app/download/[...segments]/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /docs
|
||||
component: src/app/docs/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /docs/[collection]
|
||||
component: src/app/docs/[collection]/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /docs/[collection]/[...slug]
|
||||
component: src/app/docs/[collection]/[...slug]/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /cloud_iac
|
||||
component: src/app/cloud_iac/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /cloud_iac/[provider]
|
||||
component: src/app/cloud_iac/[provider]/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /cloud_iac/[provider]/[service]
|
||||
component: src/app/cloud_iac/[provider]/[service]/page.tsx
|
||||
status: gated_by_appModules
|
||||
- path: /editor
|
||||
component: src/app/editor/page.tsx
|
||||
status: redirect_external
|
||||
- path: /editor/wechat
|
||||
component: src/app/editor/wechat/page.tsx
|
||||
status: enabled
|
||||
- path: /editor/xiaohongshu
|
||||
component: src/app/editor/xiaohongshu/page.tsx
|
||||
status: enabled
|
||||
- path: /xworkmate
|
||||
component: src/app/xworkmate/page.tsx
|
||||
status: enabled
|
||||
- path: /xworkmate/admin
|
||||
component: src/app/xworkmate/admin/page.tsx
|
||||
status: enabled
|
||||
- path: /xworkmate/integrations
|
||||
component: src/app/xworkmate/integrations/page.tsx
|
||||
status: enabled
|
||||
|
||||
auth:
|
||||
- path: /login
|
||||
component: src/app/(auth)/login/page.tsx
|
||||
status: gated_by_globalNavigation
|
||||
- path: /register
|
||||
component: src/app/(auth)/register/page.tsx
|
||||
status: gated_by_globalNavigation
|
||||
- path: /email-verification
|
||||
component: src/app/(auth)/email-verification/page.tsx
|
||||
status: gated_by_globalNavigation
|
||||
- path: /logout
|
||||
component: src/app/logout/page.tsx
|
||||
status: enabled
|
||||
|
||||
panel:
|
||||
- path: /panel
|
||||
component: src/app/panel/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/account
|
||||
component: src/app/panel/account/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/agent
|
||||
component: src/app/panel/agent/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/api
|
||||
component: src/app/panel/api/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/appearance
|
||||
component: src/app/panel/appearance/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/ldp
|
||||
component: src/app/panel/ldp/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/management
|
||||
component: src/app/panel/management/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/subscription
|
||||
component: src/app/panel/subscription/page.tsx
|
||||
status: extension_route
|
||||
- path: /panel/[...segments]
|
||||
component: src/app/panel/[...segments]/page.tsx
|
||||
status: catch_all_extension
|
||||
|
||||
recommendations:
|
||||
- "If this file is intended to become runtime-configurable, wire it into src/lib/featureToggles.ts and keep feature-toggles.json as the generated artifact."
|
||||
- "If this file is intended only for documentation, keep it synchronized with feature-toggles.json and extension definitions."
|
||||
@ -1,54 +0,0 @@
|
||||
# Compose settings
|
||||
FRONTEND_IMAGE=ghcr.io/cloud-neutral-toolkit/dashboard:replace-me
|
||||
CANONICAL_DOMAIN=www.svc.plus
|
||||
SERVED_DOMAINS=www.svc.plus,console.svc.plus
|
||||
|
||||
# Frontend runtime
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
RUNTIME_ENV=prod
|
||||
REGION=cn
|
||||
APP_BASE_URL=https://www.svc.plus
|
||||
NEXT_PUBLIC_APP_BASE_URL=https://www.svc.plus
|
||||
NEXT_PUBLIC_SITE_URL=https://www.svc.plus
|
||||
NEXT_PUBLIC_LOGIN_URL=https://www.svc.plus/login
|
||||
NEXT_PUBLIC_DOCS_BASE_URL=https://www.svc.plus/docs
|
||||
SESSION_COOKIE_SECURE=true
|
||||
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
|
||||
RUNTIME_HOSTNAME=www.svc.plus
|
||||
DEPLOYMENT_HOSTNAME=www.svc.plus
|
||||
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
|
||||
NEXT_PUBLIC_RUNTIME_REGION=cn
|
||||
|
||||
# Upstream service URLs
|
||||
ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
||||
NEXT_PUBLIC_ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
||||
SERVER_SERVICE_URL=https://api.svc.plus
|
||||
NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus
|
||||
SERVER_SERVICE_INTERNAL_URL=
|
||||
|
||||
# Optional integrations
|
||||
OPENCLAW_GATEWAY_REMOTE_URL=
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
VAULT_SERVER_URL=
|
||||
VAULT_NAMESPACE=
|
||||
VAULT_TOKEN=
|
||||
APISIX_AI_GATEWAY_URL=
|
||||
AI_GATEWAY_ACCESS_TOKEN=
|
||||
INTERNAL_SERVICE_TOKEN=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
|
||||
CLOUDFLARE_ZONE_TAG=
|
||||
ROOT_EMAIL_WHITELIST=admin@svc.plus
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID=
|
||||
NEXT_PUBLIC_GISCUS_REPO=cloud-neutral-toolkit/console.svc.plus
|
||||
NEXT_PUBLIC_GISCUS_REPO_ID=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY=General
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=
|
||||
@ -1,28 +0,0 @@
|
||||
{$SERVED_DOMAINS} {
|
||||
encode zstd gzip
|
||||
|
||||
handle_path /_next/static/* {
|
||||
root * /srv
|
||||
header Cache-Control "public, max-age=31536000, immutable"
|
||||
file_server
|
||||
}
|
||||
|
||||
@public_assets {
|
||||
file {
|
||||
root /srv/public
|
||||
try_files {path}
|
||||
}
|
||||
}
|
||||
handle @public_assets {
|
||||
root * /srv/public
|
||||
header Cache-Control "public, max-age=3600"
|
||||
file_server
|
||||
}
|
||||
|
||||
reverse_proxy dashboard:3000 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
services:
|
||||
frontend-assets:
|
||||
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE in .env.runtime}
|
||||
restart: "no"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
rm -rf /assets/_next /assets/chunks /assets/public
|
||||
mkdir -p /assets /assets/public
|
||||
cp -R /app/dashboard/static/. /assets/
|
||||
cp -R /app/dashboard/public/. /assets/public
|
||||
volumes:
|
||||
- frontend_static:/assets
|
||||
|
||||
dashboard:
|
||||
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE in .env.runtime}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.runtime
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
volumes:
|
||||
- frontend_static:/app/dashboard/.next/static:ro
|
||||
networks:
|
||||
- frontend
|
||||
|
||||
caddy:
|
||||
image: caddy:2.10-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- dashboard
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
SERVED_DOMAINS: ${SERVED_DOMAINS:?set SERVED_DOMAINS in .env.runtime}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- frontend_static:/srv:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- frontend
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
frontend_static:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
58
dev.log
58
dev.log
@ -1,39 +1,31 @@
|
||||
▲ Next.js 16.1.6 (Turbopack)
|
||||
|
||||
> dashboard@1.0.0 dev
|
||||
> bash scripts/Dev-MCP-Server.sh && next dev --turbo
|
||||
|
||||
[MCP] Cleaning stale Chrome processes...
|
||||
[MCP] Starting Chrome (remote debugging)...
|
||||
[MCP] Waiting for Chrome DevTools endpoint...
|
||||
[MCP] Chrome DevTools ready on port 9222
|
||||
[MCP] (Optional) Pre-warming chrome-devtools-mcp...
|
||||
[MCP] Done. You can now run: npm run dev
|
||||
▲ Next.js 16.1.2 (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
- Network: http://192.168.0.2:3000
|
||||
|
||||
✓ Starting...
|
||||
|
||||
Attention: Next.js now collects completely anonymous telemetry regarding usage.
|
||||
This information is used to shape Next.js' roadmap and prioritize features.
|
||||
|
||||
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
|
||||
https://nextjs.org/telemetry
|
||||
|
||||
|
||||
✓ Ready in 4.4s
|
||||
|
||||
○ Compiling / ...
|
||||
|
||||
GET / 200 in 11.8s (compile: 11.0s, render: 755ms)
|
||||
|
||||
GET / 200 in 513ms (compile: 159ms, render: 354ms)
|
||||
|
||||
GET /api/integrations/defaults 200 in 1276ms (compile: 1242ms, render: 35ms)
|
||||
|
||||
✓ Ready in 2.5s
|
||||
○ Compiling /services ...
|
||||
GET /services 200 in 6.8s (compile: 6.4s, render: 327ms)
|
||||
[runtime-config] Loaded env: PROD
|
||||
|
||||
GET /api/auth/session 200 in 2.2s (compile: 2.2s, render: 15ms)
|
||||
|
||||
GET /api/marketing/home-stats 200 in 1435ms (compile: 1425ms, render: 9ms)
|
||||
|
||||
GET / 200 in 150ms (compile: 5ms, render: 145ms)
|
||||
|
||||
GET /api/auth/session 200 in 16ms (compile: 4ms, render: 12ms)
|
||||
|
||||
GET /api/integrations/defaults 200 in 20ms (compile: 12ms, render: 8ms)
|
||||
|
||||
GET /api/marketing/home-stats 200 in 29ms (compile: 22ms, render: 7ms)
|
||||
|
||||
GET /api/blogs/latest?limit=7 200 in 37ms (compile: 29ms, render: 8ms)
|
||||
GET /api/auth/session 200 in 1021ms (compile: 993ms, render: 29ms)
|
||||
GET /services 200 in 87ms (compile: 5ms, render: 82ms)
|
||||
GET /api/auth/session 200 in 11ms (compile: 3ms, render: 7ms)
|
||||
✓ Compiled in 310ms
|
||||
GET /services 200 in 441ms (compile: 149ms, render: 293ms)
|
||||
GET /api/auth/session 200 in 16ms (compile: 5ms, render: 10ms)
|
||||
GET /services 200 in 252ms (compile: 100ms, render: 152ms)
|
||||
GET /api/auth/session 200 in 14ms (compile: 5ms, render: 8ms)
|
||||
GET /services 200 in 251ms (compile: 86ms, render: 165ms)
|
||||
GET /api/auth/session 200 in 13ms (compile: 4ms, render: 8ms)
|
||||
GET /services 200 in 281ms (compile: 94ms, render: 187ms)
|
||||
GET /api/auth/session 200 in 12ms (compile: 4ms, render: 8ms)
|
||||
|
||||
@ -214,7 +214,7 @@ export default function NotFound() {
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://www.svc.plus'),
|
||||
metadataBase: new URL('https://console.svc.plus'),
|
||||
title: {
|
||||
default: 'Cloud-Neutral | Unified Cloud Native Tools',
|
||||
template: '%s | Cloud-Neutral',
|
||||
@ -232,7 +232,7 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'https://www.svc.plus',
|
||||
url: 'https://console.svc.plus',
|
||||
title: 'Cloud-Neutral | Unified Cloud Native Tools',
|
||||
description: 'Unified tools for your cloud native stack',
|
||||
siteName: 'Cloud-Neutral',
|
||||
@ -274,7 +274,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<link rel="canonical" href="https://www.svc.plus" />
|
||||
<link rel="canonical" href="https://console.svc.plus" />
|
||||
{/* ... rest of head */}
|
||||
</head>
|
||||
{/* ... rest of layout */}
|
||||
@ -343,7 +343,7 @@ Disallow: /admin/
|
||||
Disallow: /api/
|
||||
Disallow: /internal/
|
||||
|
||||
Sitemap: https://www.svc.plus/sitemap.xml
|
||||
Sitemap: https://console.svc.plus/sitemap.xml
|
||||
```
|
||||
|
||||
---
|
||||
@ -360,11 +360,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Cloud-Neutral',
|
||||
url: 'https://www.svc.plus',
|
||||
logo: 'https://www.svc.plus/logo.png',
|
||||
url: 'https://console.svc.plus',
|
||||
logo: 'https://console.svc.plus/logo.png',
|
||||
sameAs: [
|
||||
'https://twitter.com/cloudneutral',
|
||||
'https://github.com/x-evor',
|
||||
'https://github.com/cloud-neutral-toolkit',
|
||||
],
|
||||
}
|
||||
|
||||
@ -372,10 +372,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Cloud-Neutral',
|
||||
url: 'https://www.svc.plus',
|
||||
url: 'https://console.svc.plus',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://www.svc.plus/search?q={search_term_string}',
|
||||
target: 'https://console.svc.plus/search?q={search_term_string}',
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
# console.svc.plus Web Console Architecture
|
||||
|
||||
## Scope
|
||||
|
||||
`console.svc.plus` is the browser-facing control plane. It is a Next.js App Router application that combines public pages, docs browsing, account/admin panels, and a BFF layer that forwards requests to downstream services. It never reads PostgreSQL or Prometheus directly for billing or usage.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Pages["src/app pages"]
|
||||
Root["/ -> landing page"]
|
||||
Docs["/docs, /docs/[collection], /docs/[collection]/[...slug]\nDocs reader"]
|
||||
Auth["/login, /register, /email-verification, /logout\nAuth flows"]
|
||||
Panel["/panel/*\nUser / admin console"]
|
||||
Tools["/editor/*, /download/*, /cloud_iac/*, /xworkmate/*\nProduct tools"]
|
||||
Content["/blogs/*, /services/*, /support/*, /prices, /about, /privacy, /terms, /[slug]"]
|
||||
Admin["/dashboard/cms\nCMS/admin entry"]
|
||||
end
|
||||
|
||||
subgraph BFF["src/app/api route handlers"]
|
||||
AuthAPI["/api/auth/*"]
|
||||
AdminAPI["/api/admin/*"]
|
||||
AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"]
|
||||
RagAPI["/api/rag/query\n/api/askai"]
|
||||
UtilAPI["/api/users\n/api/ping\n/api/content-meta\n/api/render-markdown\n/api/dl-index/*\n/api/marketing/home-stats\n/api/integrations/*\n/api/moltbot/chat\n/api/openclaw/assistant\n/api/task/[...segments]\n/api/xworkmate/profile"]
|
||||
GuestAPI["/api/guest/*"]
|
||||
end
|
||||
|
||||
Accounts["accounts.svc.plus"]
|
||||
Rag["rag-server.svc.plus"]
|
||||
DocsSvc["docs.svc.plus"]
|
||||
Grafana["observability.svc.plus / Grafana"]
|
||||
External["Other upstream services"]
|
||||
Subscription["/panel/subscription\nUsage / billing panel"]
|
||||
|
||||
AuthAPI --> Accounts
|
||||
AdminAPI --> Accounts
|
||||
AgentAPI --> Accounts
|
||||
RagAPI --> Rag
|
||||
UtilAPI --> DocsSvc
|
||||
UtilAPI --> External
|
||||
SandboxAPI --> Accounts
|
||||
Subscription --> Accounts
|
||||
Subscription -.-> Grafana
|
||||
```
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Route family | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Home | `/` | Public landing page and site entry |
|
||||
| Docs | `/docs`, `/docs/[collection]`, `/docs/[collection]/[...slug]` | Documentation reader, sidebar, and TOC |
|
||||
| Auth | `/login`, `/register`, `/email-verification`, `/logout` | Sign in / sign up / email verification / session cleanup |
|
||||
| Panel | `/panel`, `/panel/account`, `/panel/agent`, `/panel/api`, `/panel/appearance`, `/panel/ldp`, `/panel/management`, `/panel/subscription`, `/panel/[...segments]` | Signed-in account and admin console |
|
||||
| Tools | `/editor`, `/editor/wechat`, `/editor/xiaohongshu`, `/download`, `/download/[...segments]`, `/cloud_iac`, `/cloud_iac/[provider]`, `/cloud_iac/[provider]/[service]`, `/xworkmate`, `/xworkmate/admin`, `/xworkmate/integrations` | Product tools and service explorers |
|
||||
| Content | `/blogs`, `/blogs/[...slug]`, `/services`, `/services/openclaw`, `/services/insight`, `/support`, `/support/discussions`, `/about`, `/prices`, `/privacy`, `/terms`, `/[slug]` | Marketing / informational pages |
|
||||
| Admin / CMS | `/dashboard/cms` | CMS or content-management entry |
|
||||
| Error pages | `/404`, `/500` | Static error surfaces |
|
||||
|
||||
## BFF / API Routes
|
||||
|
||||
| API family | Path | Purpose | Upstream target |
|
||||
| --- | --- | --- | --- |
|
||||
| Auth | `/api/auth/login`, `/api/auth/register`, `/api/auth/register/send`, `/api/auth/register/verify`, `/api/auth/verify-email`, `/api/auth/verify-email/send`, `/api/auth/session`, `/api/auth/token/exchange` | Login, registration, token exchange, session lookup | `accounts.svc.plus/api/auth/*` |
|
||||
| MFA | `/api/auth/mfa/status`, `/api/auth/mfa/setup`, `/api/auth/mfa/verify`, `/api/auth/mfa/disable` | TOTP setup and verification | `accounts.svc.plus/api/auth/*` |
|
||||
| OAuth / billing | `/api/auth/oauth/login/[provider]`, `/api/auth/stripe/checkout`, `/api/auth/stripe/portal`, `/api/auth/subscriptions`, `/api/auth/subscriptions/cancel` | OAuth redirects and billing actions | `accounts.svc.plus/api/auth/*` |
|
||||
| Admin | `/api/admin/settings`, `/api/admin/homepage-video`, `/api/admin/users/*`, `/api/admin/blacklist/*` | Account admin operations | `accounts.svc.plus/api/*` |
|
||||
| Agent bridge | `/api/agent-server/[...segments]`, `/api/agent/[...segments]` | Agent registry/status and legacy alias | `accounts.svc.plus` |
|
||||
| RAG | `/api/rag/query`, `/api/askai` | Retrieval and answer generation | `rag-server.svc.plus` |
|
||||
| Guest / demo runtime | `/api/guest/binding` | Guest read-only node resolution for demo access | `accounts.svc.plus/api/sandbox/binding` |
|
||||
| Content / docs | `/api/content-meta`, `/api/render-markdown`, `/api/blogs/latest`, `/api/dl-index/*` | Docs/content rendering and download manifests | docs / CDN / download service |
|
||||
| Integrations | `/api/integrations/defaults`, `/api/integrations/probe`, `/api/marketing/home-stats` | Integration defaults, health probes, marketing metrics | config-dependent external services |
|
||||
| Misc | `/api/ping`, `/api/users`, `/api/xworkmate/profile`, `/api/task/[...segments]`, `/api/openclaw/assistant`, `/api/moltbot/chat`, `/api/render-markdown` | Health, user lookup, profile, task and assistant proxies | `accounts.svc.plus`, internal API, task services |
|
||||
|
||||
## Auth and Session Notes
|
||||
|
||||
- Browser calls use the session cookie and BFF logic in `src/server/account/session.ts`.
|
||||
- Service-to-service calls use `INTERNAL_SERVICE_TOKEN` when configured.
|
||||
- `api/agent-server/[...segments]` keeps caller `Authorization` untouched when an agent token is already present.
|
||||
- `api/agent-server/[...segments]` forwards the dashboard session token for browser-driven calls.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `accounts.svc.plus` for identity, profile, sandbox, billing, and admin actions.
|
||||
- `accounts.svc.plus` for authoritative usage and billing summaries sourced from PostgreSQL.
|
||||
- `rag-server.svc.plus` for RAG query and AskAI.
|
||||
- `docs.svc.plus` for docs content and navigation data.
|
||||
- `observability.svc.plus` for Grafana dashboards and operational views only.
|
||||
- CDN / external providers for content, analytics, and integration checks.
|
||||
|
||||
## Notes
|
||||
|
||||
- Route groups in parentheses, such as `(auth)`, are Next.js organizational folders and do not appear in the public URL.
|
||||
- The BFF layer is the main place where console-specific auth shaping, cookie management, and upstream proxying happen.
|
||||
- The subscription panel displays usage and billing data from accounts only and treats Grafana as an embedded observability surface, not a billing source.
|
||||
@ -1,42 +1,31 @@
|
||||
# Deployment
|
||||
|
||||
## Production Baseline
|
||||
This repository primarily delivers a web frontend experience and should document product flows, UI boundaries, and integration touchpoints.
|
||||
|
||||
- Runtime: `Caddy + Docker Compose`
|
||||
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
|
||||
- Public domains:
|
||||
- `www.svc.plus`
|
||||
- `console.svc.plus`
|
||||
- Canonical public origin: `https://www.svc.plus`
|
||||
- Frontend release workflow: `.github/workflows/pipeline.yaml`
|
||||
Use this page to standardize deployment prerequisites, supported topologies, operational checks, and rollback notes.
|
||||
|
||||
## Operating Model
|
||||
## Current code-aligned notes
|
||||
|
||||
The frontend is built in GitHub Actions and shipped as a prebuilt `linux/amd64` image. The host only pulls the image and starts containers; it does not build locally.
|
||||
- Documentation target: `console.svc.plus`
|
||||
- Repo kind: `frontend`
|
||||
- Manifest and build evidence: package.json (`dashboard`)
|
||||
- Primary implementation and ops directories: `src/`, `scripts/`, `tests/`, `config/`, `public/`
|
||||
- Package scripts snapshot: `dev`, `prebuild`, `build`, `build:static`, `start`, `lint`
|
||||
|
||||
`yarn prebuild` now generates only console-owned marketing artifacts. `/docs` and `/blogs` no longer bundle `knowledge/` or synced markdown content into the frontend image. Those routes fetch rendered content from `docs.svc.plus` at request time through the server-side `docsServiceClient`.
|
||||
## Existing docs to reconcile
|
||||
|
||||
The stack is static-first:
|
||||
|
||||
- Caddy serves `/_next/static/*` and public assets from a shared read-only volume.
|
||||
- The Next.js standalone container serves dynamic HTML, auth endpoints, and API proxy routes. Static assets and hashed CSS/JS files are extracted by the `frontend-assets` helper task, so the runtime no longer needs to compile anything on the single-node host.
|
||||
- `docs.svc.plus` is the source of truth for rendered docs/blog pages; the browser does not call it directly.
|
||||
|
||||
Releases are orchestrated through `.github/workflows/pipeline.yaml`. That workflow builds/pushes the image, renders `.env.runtime` including `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane DNS automation then updates Cloudflare DNS for the release domains (via `scripts/github-actions/update-release-dns.sh`) so both `www.svc.plus` and `console.svc.plus` resolve to the same environment.
|
||||
|
||||
The release contract now uses:
|
||||
|
||||
- `CANONICAL_DOMAIN=www.svc.plus`
|
||||
- `SERVED_DOMAINS=www.svc.plus,console.svc.plus`
|
||||
|
||||
Validation must pass for both domains. A release is incomplete if either host serves a different runtime version, static asset family, or `dashboardUrl`.
|
||||
|
||||
This baseline is intentional for the weak-IO single-node host `root@jp-xhttp-contabo.svc.plus`. No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`.
|
||||
|
||||
`docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- `usage/deployment.md`
|
||||
- `governance/release-process.md`
|
||||
- `development/dev-setup.md`
|
||||
- `getting-started/installation.md`
|
||||
- `getting-started/quickstart.md`
|
||||
- `governance/release-process.md`
|
||||
- `operations/runbooks/README.md`
|
||||
- `operations/runbooks/rag-server.md`
|
||||
- `usage/deployment.md`
|
||||
- `zh/development/dev-setup.md`
|
||||
|
||||
## What this page should cover next
|
||||
|
||||
- Describe the current implementation rather than an aspirational future-only design.
|
||||
- Keep terminology aligned with the repository root README, manifests, and actual directories.
|
||||
- Link deeper runbooks, specs, or subsystem notes from the legacy docs listed above.
|
||||
- Verify deployment steps against current scripts, manifests, CI/CD flow, and environment contracts before each release.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Release Process
|
||||
|
||||
This page tracks release summaries for published versions of the public web console served under `www.svc.plus` and `console.svc.plus`.
|
||||
This page tracks release summaries for published versions of `console.svc.plus`.
|
||||
|
||||
## Current Release
|
||||
|
||||
@ -31,7 +31,7 @@ Published commit: `0fab89e`
|
||||
- Split observability into a tri-view workspace and refined panel assistant routing.
|
||||
- Unified navigation structure and persistent AI sidebar behavior.
|
||||
- Improved login and registration flows by using server-resolved account service URLs.
|
||||
- Guest and demo access must not expose any backing account identity in public UI or session payloads.
|
||||
- Consolidated demo and experience account handling around `sandbox@svc.plus`.
|
||||
- Added vault-backed token lookup for integrations.
|
||||
|
||||
#### Docs And Setup
|
||||
@ -47,7 +47,5 @@ Published commit: `0fab89e`
|
||||
|
||||
## Notes
|
||||
|
||||
- GitHub Release: `https://github.com/x-evor/console.svc.plus/releases/tag/v0.2`
|
||||
- GitHub Release: `https://github.com/cloud-neutral-toolkit/console.svc.plus/releases/tag/v0.2`
|
||||
- Related docs: `docs/README.md`, `docs/en/README.md`, `docs/zh/README.md`
|
||||
- Release validation must verify both `www.svc.plus` and `console.svc.plus` against the same `releaseImageRef`, `releaseImageTag`, and `releaseCommit`.
|
||||
- `www.svc.plus` is the canonical public domain for metadata, sitemap, `dashboardUrl`, and shared links.
|
||||
|
||||
@ -48,7 +48,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
|
||||
|
||||
### 本地开发
|
||||
|
||||
写入当前前端仓库的 `.env.local`:
|
||||
写入 `console.svc.plus/.env.local`:
|
||||
|
||||
```bash
|
||||
CLOUDFLARE_API_TOKEN=...
|
||||
@ -58,7 +58,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
|
||||
|
||||
### 线上部署
|
||||
|
||||
把同名变量写入前端部署环境。
|
||||
把同名变量写入 `console.svc.plus` 的部署环境(例如 Vercel/Cloud Run 的环境变量配置)。
|
||||
|
||||
> 注意:这些变量属于服务端密钥,不要暴露到 `NEXT_PUBLIC_*`。
|
||||
|
||||
@ -67,7 +67,7 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
|
||||
部署后访问:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://www.svc.plus/api/marketing/home-stats
|
||||
curl -fsSL https://console.svc.plus/api/marketing/home-stats
|
||||
```
|
||||
|
||||
期望返回中 `visits.daily/weekly/monthly` 为数字(非 `null`)。
|
||||
@ -77,3 +77,4 @@ curl -fsSL https://www.svc.plus/api/marketing/home-stats
|
||||
1. token 权限是否包含 Analytics Read
|
||||
2. Account ID 是否与 siteTag 属于同一账号
|
||||
3. 环境变量是否已在当前运行实例生效(重启/重新部署后再测)
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Browser │ │ www.svc.plus │ │accounts.svc.plus │
|
||||
│ Browser │ │ console.svc.plus │ │accounts.svc.plus │
|
||||
│ (User) │ │ (Frontend) │ │ (Backend) │
|
||||
└──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘
|
||||
│ 1. Click "Login │ │
|
||||
@ -40,7 +40,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
|
||||
|
||||
- A GitHub account with access to **Settings > Developer Settings**
|
||||
- A Google account with access to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
- Running `accounts.svc.plus` and the frontend served under `www.svc.plus` / `console.svc.plus`
|
||||
- Running `accounts.svc.plus` and `console.svc.plus` services
|
||||
|
||||
---
|
||||
|
||||
@ -55,7 +55,7 @@ This guide describes how to configure GitHub and Google OAuth login for the Clou
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Application name** | `Cloud Neutral Console` |
|
||||
| **Homepage URL** | `https://www.svc.plus` |
|
||||
| **Homepage URL** | `https://console.svc.plus` |
|
||||
| **Authorization callback URL** | `https://accounts.svc.plus/api/auth/oauth/callback/github` |
|
||||
| **Enable Device Flow** | ☐ (unchecked) |
|
||||
|
||||
@ -119,7 +119,7 @@ No additional GitHub permissions are required.
|
||||
|---|---|
|
||||
| **Application type** | `Web application` |
|
||||
| **Name** | `Cloud Neutral Console` |
|
||||
| **Authorized JavaScript origins** | `https://www.svc.plus` |
|
||||
| **Authorized JavaScript origins** | `https://console.svc.plus` |
|
||||
| **Authorized redirect URIs** | `https://accounts.svc.plus/api/auth/oauth/callback/google` |
|
||||
|
||||
4. Click **"Create"**
|
||||
@ -150,7 +150,7 @@ GOOGLE_CLIENT_SECRET=<your_google_client_secret>
|
||||
|
||||
# ── General OAuth ──
|
||||
OAUTH_REDIRECT_URL=https://accounts.svc.plus/api/auth/oauth/callback
|
||||
OAUTH_FRONTEND_URL=https://www.svc.plus
|
||||
OAUTH_FRONTEND_URL=https://console.svc.plus
|
||||
```
|
||||
|
||||
These variables are referenced in `config/account.yaml`:
|
||||
@ -159,7 +159,7 @@ These variables are referenced in `config/account.yaml`:
|
||||
auth:
|
||||
oauth:
|
||||
redirectUrl: "${OAUTH_REDIRECT_URL}"
|
||||
frontendUrl: "${OAUTH_FRONTEND_URL:-https://www.svc.plus}"
|
||||
frontendUrl: "${OAUTH_FRONTEND_URL:-https://console.svc.plus}"
|
||||
github:
|
||||
clientId: "${GITHUB_CLIENT_ID}"
|
||||
clientSecret: "${GITHUB_CLIENT_SECRET}"
|
||||
@ -172,7 +172,7 @@ auth:
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Configuration (`www.svc.plus` canonical, `console.svc.plus` secondary)
|
||||
## 4. Frontend Configuration (console.svc.plus)
|
||||
|
||||
The frontend resolves the accounts service URL **server-side** via `getAccountServiceBaseUrl()`, which reads:
|
||||
|
||||
@ -209,7 +209,7 @@ If not set, the function falls back to a runtime default. **No `NEXT_PUBLIC_*` e
|
||||
|
||||
### OAuth login redirects to wrong domain
|
||||
|
||||
Check that `OAUTH_FRONTEND_URL` in accounts.svc.plus matches the canonical public domain where users should be redirected after authentication. The current default is `https://www.svc.plus`.
|
||||
Check that `OAUTH_FRONTEND_URL` in accounts.svc.plus matches the console domain where users should be redirected after authentication.
|
||||
|
||||
### Google "Access blocked: This app's request is invalid"
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
- **区域**: asia-northeast1
|
||||
- **项目 ID**: xzerolab-480008
|
||||
- **服务 URL**: https://rag-server-svc-plus-266500572462.asia-northeast1.run.app
|
||||
- **代码仓库**: https://github.com/x-evor/rag-server.svc.plus
|
||||
- **代码仓库**: https://github.com/cloud-neutral-toolkit/rag-server.svc.plus
|
||||
|
||||
### 服务功能
|
||||
RAG (Retrieval-Augmented Generation) 服务器提供以下功能:
|
||||
@ -68,7 +68,7 @@ rag-server.svc.plus/
|
||||
#### 1. 代码变更
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/x-evor/rag-server.svc.plus.git
|
||||
git clone https://github.com/cloud-neutral-toolkit/rag-server.svc.plus.git
|
||||
cd rag-server.svc.plus
|
||||
|
||||
# 创建功能分支
|
||||
@ -407,7 +407,7 @@ gcloud run services update rag-server-svc-plus \
|
||||
- **紧急联系**: [电话]
|
||||
|
||||
### 相关链接
|
||||
- **GitHub**: https://github.com/x-evor/rag-server.svc.plus
|
||||
- **GitHub**: https://github.com/cloud-neutral-toolkit/rag-server.svc.plus
|
||||
- **Cloud Console**: https://console.cloud.google.com/run/detail/asia-northeast1/rag-server-svc-plus
|
||||
- **监控面板**: [Monitoring Dashboard URL]
|
||||
- **文档**: [Documentation URL]
|
||||
|
||||
@ -1,281 +0,0 @@
|
||||
# Console Frontend Single-Node Deployment Design
|
||||
|
||||
## Scope
|
||||
|
||||
- Repository: `console.svc.plus`
|
||||
- Target host: `root@cn-console.svc.plus`
|
||||
- Public domains:
|
||||
- `cn-console.svc.plus`
|
||||
- `cn-console.onwalk.net`
|
||||
- Delivery mode: `GitHub Actions + GHCR + Caddy + Docker Compose`
|
||||
|
||||
This document defines the deployment baseline for the China-facing frontend node. The source of truth is this upstream repository. The control-plane repository may consume the repo through git submodule, but should not become the primary place where this deployment design lives.
|
||||
|
||||
## Objective
|
||||
|
||||
Provide an independent frontend deployment pipeline for `console.svc.plus` that fits the current host constraints:
|
||||
|
||||
- the host IO is weak
|
||||
- the host must not build Docker images locally
|
||||
- the frontend should run in a static-first mode where possible
|
||||
- deployment logic should stay in checked-in scripts, not be embedded in GitHub Actions YAML
|
||||
|
||||
The result should support repeatable releases, quick rollback by image tag, and minimal work on the target machine.
|
||||
|
||||
## Constraints
|
||||
|
||||
### Host constraints
|
||||
|
||||
- `cn-console.svc.plus` is a single-node host
|
||||
- deployment user is `root`
|
||||
- local image build on the host is explicitly disallowed
|
||||
- IO pressure should be minimized during release
|
||||
|
||||
### Application constraints
|
||||
|
||||
- `console.svc.plus` is not a purely static site
|
||||
- auth routes, same-origin API proxy routes, and selected dynamic pages still require a running Next.js server
|
||||
- some `NEXT_PUBLIC_*` variables are compiled into the frontend bundle at image build time
|
||||
- `prebuild` pulls documentation and `knowledge` content, so CI must prepare those inputs before building the image
|
||||
|
||||
### Repository constraints
|
||||
|
||||
- workflow YAML should remain orchestration-only
|
||||
- service-local operational notes should remain in this repo
|
||||
- downstream control repos can reference this repo through submodule updates after upstream changes are pushed
|
||||
|
||||
## Recommended Topology
|
||||
|
||||
### 1. CI build on GitHub Actions
|
||||
|
||||
The workflow builds a single `linux/amd64` image in GitHub Actions and pushes it to GHCR.
|
||||
|
||||
Reasons:
|
||||
|
||||
- matches the target host architecture
|
||||
- avoids multi-arch overhead for this single-node release path
|
||||
- avoids local host build IO and CPU pressure
|
||||
- keeps release artifacts immutable and rollback-friendly
|
||||
|
||||
### 2. Runtime on the host
|
||||
|
||||
Use `docker compose` with three services:
|
||||
|
||||
- `dashboard`: Next.js standalone runtime
|
||||
- `frontend-assets`: one-shot container that copies static files from the image into a Docker volume
|
||||
- `caddy`: TLS termination, redirect handling, static file serving, and reverse proxy
|
||||
|
||||
This keeps the host work limited to:
|
||||
|
||||
- image pull
|
||||
- asset extraction from the image
|
||||
- container restart
|
||||
|
||||
### 3. Static-first request flow
|
||||
|
||||
Caddy serves:
|
||||
|
||||
- `/_next/static/*`
|
||||
- checked-in `public/` assets
|
||||
|
||||
Next.js serves:
|
||||
|
||||
- HTML responses
|
||||
- `/api/*` routes
|
||||
- auth/session flows
|
||||
- dynamic pages that still depend on server runtime
|
||||
|
||||
This reduces repeat disk reads and network hops for the bulk of frontend traffic while preserving the dynamic behavior the app still needs.
|
||||
|
||||
## Build-Time vs Runtime Configuration
|
||||
|
||||
### Build-time config
|
||||
|
||||
These values must be available during Docker build because the frontend bundle reads them directly:
|
||||
|
||||
- `NEXT_PUBLIC_APP_BASE_URL`
|
||||
- `NEXT_PUBLIC_SITE_URL`
|
||||
- `NEXT_PUBLIC_LOGIN_URL`
|
||||
- `NEXT_PUBLIC_DOCS_BASE_URL`
|
||||
- `NEXT_PUBLIC_RUNTIME_ENVIRONMENT`
|
||||
- `NEXT_PUBLIC_RUNTIME_REGION`
|
||||
- `NEXT_PUBLIC_GISCUS_*`
|
||||
- `NEXT_PUBLIC_PAYPAL_CLIENT_ID`
|
||||
- `NEXT_PUBLIC_STRIPE_*`
|
||||
|
||||
These are injected in GitHub Actions as Docker build args.
|
||||
|
||||
### Runtime config
|
||||
|
||||
These values are rendered into `.env.runtime` and copied to the host:
|
||||
|
||||
- upstream service URLs such as `ACCOUNT_SERVICE_URL`
|
||||
- tokens used only on the server side
|
||||
- Cloudflare analytics credentials
|
||||
- internal service token
|
||||
- runtime hostname hints
|
||||
|
||||
This separation avoids rebuilding for purely server-side secret or endpoint changes when the public frontend bundle does not change.
|
||||
|
||||
## Knowledge and Docs Handling
|
||||
|
||||
Current decision:
|
||||
|
||||
- `knowledge/` is cloned during CI
|
||||
- the cloned content is included in the image build context
|
||||
- the built image contains the resulting content needed by the current frontend
|
||||
|
||||
Reason:
|
||||
|
||||
- `prebuild` depends on this material
|
||||
- the host should not fetch or generate content during deployment
|
||||
|
||||
Temporary nature:
|
||||
|
||||
- today the frontend still carries docs-related payload
|
||||
- later, when `docs.svc.plus` becomes an API/service, docs delivery should move out of the frontend image
|
||||
- that future change should reduce image size and simplify the runtime responsibilities of `console.svc.plus`
|
||||
|
||||
## Domain Handling
|
||||
|
||||
Primary domain:
|
||||
|
||||
- `cn-console.svc.plus`
|
||||
|
||||
Secondary domain:
|
||||
|
||||
- `cn-console.onwalk.net`
|
||||
|
||||
Current routing decision:
|
||||
|
||||
- Caddy accepts both domains
|
||||
- requests for `cn-console.onwalk.net` are redirected permanently to `cn-console.svc.plus`
|
||||
|
||||
Reason:
|
||||
|
||||
- avoid duplicate canonical origins
|
||||
- keep cookie and login behavior centered on one primary host
|
||||
- simplify SEO and observability interpretation
|
||||
|
||||
## Release Workflow
|
||||
|
||||
### Trigger
|
||||
|
||||
Independent workflow:
|
||||
|
||||
- `.github/workflows/service_release_frontend-deploy.yml`
|
||||
|
||||
### Steps
|
||||
|
||||
1. check out repository
|
||||
2. clone `knowledge`
|
||||
3. build and push `ghcr.io/<owner>/dashboard:<tag>`
|
||||
4. render `.env.runtime`
|
||||
5. upload compose/caddy/env files to the host
|
||||
6. log in to GHCR on the host
|
||||
7. pull the new image
|
||||
8. run `frontend-assets`
|
||||
9. start or refresh `dashboard` and `caddy`
|
||||
10. verify both domains
|
||||
|
||||
### Why separate from the existing image workflow
|
||||
|
||||
The existing image workflow is broader and oriented toward generic image publishing. This single-node frontend workflow needs tighter control over:
|
||||
|
||||
- build-time public env injection
|
||||
- production deployment sequencing
|
||||
- SSH-based single-host rollout
|
||||
- host-specific runtime file rendering
|
||||
|
||||
So the frontend release path should remain explicit and independent.
|
||||
|
||||
## Rollback Model
|
||||
|
||||
Rollback unit:
|
||||
|
||||
- image tag reference in `.env.runtime`
|
||||
|
||||
Rollback steps:
|
||||
|
||||
1. set `FRONTEND_IMAGE` to a previous known-good tag
|
||||
2. rerun `frontend-assets`
|
||||
3. restart `dashboard` and `caddy`
|
||||
4. verify `cn-console.svc.plus`
|
||||
|
||||
This avoids rebuilding and keeps rollback cheap on the weak-IO host.
|
||||
|
||||
## Security and Secret Handling
|
||||
|
||||
Secrets must not be committed to the repo. The workflow should consume:
|
||||
|
||||
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`
|
||||
- service tokens
|
||||
- vault tokens
|
||||
- internal service token
|
||||
- optional Cloudflare credentials
|
||||
|
||||
Public defaults and non-secret values belong in checked-in examples or GitHub repository/environment variables. Secret-only values stay in GitHub Secrets and are rendered into the host runtime env during deployment.
|
||||
|
||||
## Operational Risks
|
||||
|
||||
### Risk 1: build-time public env mismatch
|
||||
|
||||
If GitHub environment variables are incomplete, the image may build successfully but the frontend can render wrong links or lose third-party integration IDs.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep `.env.example` aligned
|
||||
- document required GitHub `vars`
|
||||
- keep the build args list explicit
|
||||
|
||||
### Risk 2: image layout drift
|
||||
|
||||
If the Docker image no longer contains `/app/dashboard/static` or `/app/dashboard/public`, the `frontend-assets` step fails.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep asset extraction paths documented
|
||||
- update deploy scripts whenever Dockerfile output layout changes
|
||||
|
||||
### Risk 3: docs payload growth
|
||||
|
||||
Bundling docs and `knowledge` into the frontend image increases image size.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- accept it temporarily
|
||||
- revisit once `docs.svc.plus` is externalized
|
||||
|
||||
### Risk 4: single-node blast radius
|
||||
|
||||
The host handles both reverse proxy and app runtime. Misconfiguration affects the whole frontend surface.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep compose simple
|
||||
- keep Caddy config minimal
|
||||
- use image-tag rollback
|
||||
|
||||
## Future Follow-Up
|
||||
|
||||
### Near term
|
||||
|
||||
- populate required GitHub `vars` and `secrets`
|
||||
- run the workflow against `root@cn-console.svc.plus`
|
||||
- validate DNS, TLS, static assets, login flow, and upstream API proxy behavior
|
||||
|
||||
### Later
|
||||
|
||||
- move docs delivery out of the frontend image after `docs.svc.plus` is service/API based
|
||||
- consider splitting static assets to object storage or CDN if traffic grows
|
||||
- evaluate whether the host should keep only Caddy plus one app container, or whether docs can be removed entirely from this runtime
|
||||
|
||||
## Source of Truth Rule
|
||||
|
||||
For this deployment design:
|
||||
|
||||
- upstream repo source of truth: `console.svc.plus`
|
||||
- service-local design note location: `docs/plans/`
|
||||
- control-plane repo role: consume via git submodule after upstream commit is pushed
|
||||
|
||||
Do not move the primary design ownership to the control-plane repository.
|
||||
@ -1,5 +0,0 @@
|
||||
# Plans
|
||||
|
||||
This directory stores service-local design notes and implementation plans for `console.svc.plus`.
|
||||
|
||||
The source of truth stays in this upstream repository. Control-plane repositories may reference these documents through git submodule updates after upstream changes are pushed.
|
||||
@ -3,152 +3,146 @@
|
||||
## Scope
|
||||
|
||||
- Runtime: `console.svc.plus`
|
||||
- Topology: `Caddy + Docker Compose + GitHub Actions`
|
||||
- Deploy host: `root@jp-xhttp-contabo.svc.plus`
|
||||
- Public domains:
|
||||
- `https://www.svc.plus`
|
||||
- `https://console.svc.plus`
|
||||
- Canonical public origin: `https://www.svc.plus`
|
||||
- Frontend host: Vercel
|
||||
- Edge: Cloudflare
|
||||
- Auth backend: `https://accounts.svc.plus`
|
||||
|
||||
## Current Delivery Model
|
||||
This runbook is the minimum checklist for production incidents where login or MFA stops working and browser devtools show `/api/auth/login` or `/api/auth/mfa/*` failures.
|
||||
|
||||
The production frontend is deployed as a prebuilt container image from GitHub Actions.
|
||||
## Expected Request Flow
|
||||
|
||||
- The target host does not build images locally.
|
||||
- The workflow builds an `linux/amd64` image and pushes it to `ghcr.io/<owner>/dashboard:<sha>`.
|
||||
- The host only performs `docker login`, `docker compose pull`, static asset extraction, and `docker compose up`.
|
||||
- `/docs` and `/blogs` fetch their content from `docs.svc.plus` at runtime; the frontend image no longer packs `knowledge/` or synced markdown payloads.
|
||||
- Static assets are extracted from the image into a shared Docker volume so Caddy can serve `/_next/static/*` and checked-in public files directly.
|
||||
1. Browser loads `https://console.svc.plus/login`
|
||||
2. Browser calls same-origin Next routes on `console.svc.plus`
|
||||
3. Next route proxies server-side to `https://accounts.svc.plus/api/auth/*`
|
||||
4. `accounts.svc.plus` returns either a session token or an MFA challenge
|
||||
|
||||
This is intentionally static-first for the current weak-IO single-node host. Dynamic HTML, auth routes, and API proxy routes still run through the Next.js container, but docs/blog content delivery is now delegated to `docs.svc.plus`.
|
||||
The browser should not call `accounts.svc.plus` directly for login.
|
||||
|
||||
## Control Plane & DNS Stage
|
||||
## Fast Triage
|
||||
|
||||
The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/pipeline.yaml` finishes pushing the new image, the control-plane DNS automation calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable through the current production host `jp-xhttp-contabo.svc.plus`.
|
||||
|
||||
## Runtime Layout
|
||||
|
||||
Remote directory:
|
||||
Run these checks first:
|
||||
|
||||
```bash
|
||||
/opt/console-svc-plus
|
||||
curl -si https://console.svc.plus/login | sed -n '1,20p'
|
||||
curl -si https://console.svc.plus/api/auth/login | sed -n '1,20p'
|
||||
curl -si https://accounts.svc.plus/healthz | sed -n '1,20p'
|
||||
curl -si https://accounts.svc.plus/api/auth/login | sed -n '1,20p'
|
||||
```
|
||||
|
||||
Files deployed there:
|
||||
Interpretation:
|
||||
|
||||
```bash
|
||||
docker-compose.yml
|
||||
Caddyfile
|
||||
.env.runtime
|
||||
```
|
||||
- `console.svc.plus` returns `403` with `cf-mitigated: challenge`
|
||||
Cloudflare is blocking the page or auth API before Vercel sees it.
|
||||
- `console.svc.plus/api/auth/login` returns `404`
|
||||
Vercel production is not serving the expected Next route, or Cloudflare is pointing at the wrong origin/deployment behavior.
|
||||
- `accounts.svc.plus/healthz` fails
|
||||
Back-end outage. Fix backend first.
|
||||
- `accounts.svc.plus/api/auth/login` returns `200` with `mfaRequired`
|
||||
Backend is healthy; continue on console/Vercel/Cloudflare.
|
||||
|
||||
Containers:
|
||||
## Application Checks
|
||||
|
||||
- `dashboard`: Next.js standalone runtime on port `3000`
|
||||
- `frontend-assets`: one-shot task that copies `static/` and `public/` into a shared volume
|
||||
- `caddy`: TLS termination and reverse proxy
|
||||
|
||||
## GitHub Actions Inputs
|
||||
|
||||
Workflow:
|
||||
|
||||
```text
|
||||
.github/workflows/pipeline.yaml
|
||||
```
|
||||
|
||||
Secrets required:
|
||||
|
||||
- `SINGLE_NODE_VPS_SSH_PRIVATE_KEY`
|
||||
- `OPENCLAW_GATEWAY_TOKEN` if used
|
||||
- `VAULT_TOKEN` if used
|
||||
- `AI_GATEWAY_ACCESS_TOKEN` if used
|
||||
- `INTERNAL_SERVICE_TOKEN` if used
|
||||
- `CLOUDFLARE_DNS_API_TOKEN` for the Cloudflare DNS stage
|
||||
- `CLOUDFLARE_API_TOKEN` if homepage Cloudflare analytics are enabled at runtime
|
||||
|
||||
Repository/environment variables recommended:
|
||||
|
||||
- `CANONICAL_DOMAIN`
|
||||
- `SERVED_DOMAINS`
|
||||
- `APP_BASE_URL`
|
||||
- `NEXT_PUBLIC_APP_BASE_URL`
|
||||
- `NEXT_PUBLIC_SITE_URL`
|
||||
- `NEXT_PUBLIC_LOGIN_URL`
|
||||
- `NEXT_PUBLIC_DOCS_BASE_URL`
|
||||
- `ACCOUNT_SERVICE_URL`
|
||||
- `NEXT_PUBLIC_ACCOUNT_SERVICE_URL`
|
||||
- `SERVER_SERVICE_URL`
|
||||
- `NEXT_PUBLIC_SERVER_SERVICE_URL`
|
||||
- `RUNTIME_HOSTNAME`
|
||||
- `DEPLOYMENT_HOSTNAME`
|
||||
- `DOCS_SERVICE_URL`
|
||||
- `DOCS_SERVICE_INTERNAL_URL`
|
||||
- `NEXT_PUBLIC_RUNTIME_ENVIRONMENT`
|
||||
- `NEXT_PUBLIC_RUNTIME_REGION`
|
||||
- `NEXT_PUBLIC_GISCUS_*`
|
||||
- `NEXT_PUBLIC_STRIPE_*`
|
||||
- `NEXT_PUBLIC_PAYPAL_CLIENT_ID`
|
||||
- `CLOUDFLARE_ZONE_TAG` if homepage Cloudflare analytics are enabled at runtime
|
||||
- `CLOUDFLARE_DNS_ZONE_TAG` only for single-domain manual DNS override; the GitHub Actions DNS stage resolves zones from each domain automatically
|
||||
|
||||
## Release Flow
|
||||
|
||||
1. GitHub Actions checks out the repo.
|
||||
2. Docker builds the frontend image with the public `NEXT_PUBLIC_*` values needed at build time.
|
||||
3. The image is pushed to GHCR.
|
||||
4. The workflow updates Cloudflare DNS for the release domain.
|
||||
5. The workflow renders `.env.runtime`, including the canonical public origin and both served frontend domains.
|
||||
6. The workflow uploads `docker-compose.yml`, `Caddyfile`, and `.env.runtime` to the host.
|
||||
7. The host pulls the new image, refreshes the static asset volume, and starts `dashboard + caddy`.
|
||||
8. The workflow verifies both `www.svc.plus` and `console.svc.plus`, and fails the release if either domain serves a different runtime version.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Local syntax checks:
|
||||
Verify the current build still contains the auth routes:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/cloud-neutral-toolkit/console.svc.plus
|
||||
bash -n scripts/github-actions/render-frontend-runtime-env.sh
|
||||
bash -n scripts/github-actions/deploy-frontend-single-node.sh
|
||||
cp deploy/single-node/.env.runtime.example deploy/single-node/.env.runtime
|
||||
docker compose -f deploy/single-node/docker-compose.yml --env-file deploy/single-node/.env.runtime config >/tmp/console-compose.rendered.yaml
|
||||
rm -f deploy/single-node/.env.runtime
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
yaml.safe_load(Path('.github/workflows/pipeline.yaml').read_text())
|
||||
print('workflow yaml ok')
|
||||
PY
|
||||
yarn build
|
||||
cat .next/app-path-routes-manifest.json | jq 'with_entries(select(.key|test("/api/auth/")))'
|
||||
```
|
||||
|
||||
Remote checks:
|
||||
Verify the login page still uses same-origin routes:
|
||||
|
||||
```bash
|
||||
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps"
|
||||
ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: www.svc.plus' http://127.0.0.1/"
|
||||
ssh root@jp-xhttp-contabo.svc.plus "curl -fsSI -H 'Host: console.svc.plus' http://127.0.0.1/"
|
||||
curl -fsSIL https://www.svc.plus
|
||||
curl -fsSIL https://console.svc.plus
|
||||
nl -ba 'src/app/(auth)/login/LoginForm.tsx' | sed -n '64,180p'
|
||||
nl -ba 'src/app/api/auth/login/route.ts' | sed -n '1,180p'
|
||||
nl -ba 'src/app/api/auth/mfa/verify/route.ts' | sed -n '1,180p'
|
||||
```
|
||||
|
||||
## Failure Signatures
|
||||
Expected behavior:
|
||||
|
||||
- `docker login ghcr.io` fails
|
||||
The workflow token or package visibility is wrong.
|
||||
- `frontend-assets` fails
|
||||
The image layout changed and no longer contains `/app/dashboard/static` or `/app/dashboard/public`.
|
||||
- `www.svc.plus` or `console.svc.plus` returns `502`
|
||||
Caddy is up, but the `dashboard` container failed or is not reachable on port `3000`.
|
||||
- `LoginForm` posts to `/api/auth/login`
|
||||
- login proxy accepts backend `mfaRequired` / `mfaTicket`
|
||||
- MFA verify proxy calls `/api/auth/mfa/verify`
|
||||
|
||||
## Rollback
|
||||
## Vercel Checks
|
||||
|
||||
1. Re-run the workflow with a previous known-good image tag.
|
||||
2. Or update `/opt/console-svc-plus/.env.runtime` and set `FRONTEND_IMAGE=ghcr.io/<owner>/dashboard:<previous-tag>`.
|
||||
3. Restart the services:
|
||||
In the Vercel project for `console-svc-plus`, verify:
|
||||
|
||||
1. The production deployment corresponds to the intended git commit.
|
||||
2. Framework preset is `Next.js`.
|
||||
3. Build command is `yarn build` or the project default, not a static export command.
|
||||
4. Output is not being overridden to static export.
|
||||
5. Production Functions include `app/api/auth/login` and the other `app/api/auth/*` handlers.
|
||||
6. Required runtime env vars are present for the auth proxy path if they are managed in Vercel.
|
||||
|
||||
If the route exists locally but Vercel returns `404`, suspect:
|
||||
|
||||
- wrong production deployment selected
|
||||
- wrong root directory/project link
|
||||
- stale alias or domain assignment
|
||||
- build output mismatch between local and Vercel
|
||||
|
||||
## Cloudflare Checks
|
||||
|
||||
If `curl` shows `cf-mitigated: challenge`, check Cloudflare first.
|
||||
|
||||
Look for:
|
||||
|
||||
1. Managed Challenge or WAF custom rules affecting `/login`
|
||||
2. Managed Challenge or WAF custom rules affecting `/api/auth/*`
|
||||
3. Bot Fight Mode or Super Bot Fight Mode interactions
|
||||
4. Transform/redirect/cache rules that alter `/api/auth/*`
|
||||
5. Page Rules or Ruleset Engine policies applied only to the production hostname
|
||||
|
||||
Recommended policy for auth API:
|
||||
|
||||
- Do not cache `/api/auth/*`
|
||||
- Do not apply JS challenge to `/api/auth/*`
|
||||
- Keep standard security headers, but let requests reach Vercel
|
||||
|
||||
## Backend Verification
|
||||
|
||||
Use the backend directly to prove whether auth is healthy:
|
||||
|
||||
```bash
|
||||
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets"
|
||||
ssh root@jp-xhttp-contabo.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy"
|
||||
cd /Users/shenlan/workspaces/cloud-neutral-toolkit/accounts.svc.plus
|
||||
set -a; source .env; set +a
|
||||
payload=$(printf '{"identifier":"admin@svc.plus","password":"%s"}' "$SUPERADMIN_PASSWORD")
|
||||
curl -sS -X POST https://accounts.svc.plus/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload"
|
||||
```
|
||||
|
||||
4. Verify `https://www.svc.plus` and `https://console.svc.plus` again before closing the incident.
|
||||
Expected for an MFA-enabled admin:
|
||||
|
||||
- HTTP `200`
|
||||
- response contains `mfaRequired`
|
||||
- response contains `mfaTicket` or `mfaToken`
|
||||
|
||||
## Known Failure Signatures
|
||||
|
||||
- `POST https://console.svc.plus/api/auth/login 404`
|
||||
Likely Vercel deployment mismatch or route not published.
|
||||
- `403` with `cf-mitigated: challenge`
|
||||
Cloudflare blocked request before Vercel.
|
||||
- login returns generic failure even though backend returns MFA challenge
|
||||
Console auth proxy is not parsing MFA fields correctly.
|
||||
- MFA code accepted by authenticator but web login still fails
|
||||
Console proxy may be calling the setup endpoint instead of the login MFA endpoint.
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
When a release breaks auth:
|
||||
|
||||
1. Remove or relax Cloudflare rules affecting `/login` and `/api/auth/*`
|
||||
2. Re-point domain to last known-good Vercel production deployment
|
||||
3. Roll back `console.svc.plus`
|
||||
4. Only then consider `accounts.svc.plus` rollback
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/app/(auth)/login/LoginForm.tsx`
|
||||
- `src/app/api/auth/login/route.ts`
|
||||
- `src/app/api/auth/mfa/status/route.ts`
|
||||
- `src/app/api/auth/mfa/verify/route.ts`
|
||||
- `src/server/serviceConfig.ts`
|
||||
|
||||
@ -1,42 +1,31 @@
|
||||
# 部署
|
||||
|
||||
## 生产基线
|
||||
该仓库以 Web 前端体验为主,文档需要覆盖产品流程、界面边界与集成触点。
|
||||
|
||||
- 运行拓扑: `Caddy + Docker Compose`
|
||||
- 目标主机: `root@jp-xhttp-contabo.svc.plus`
|
||||
- 域名:
|
||||
- `www.svc.plus`
|
||||
- `console.svc.plus`
|
||||
- 公开首选域名: `www.svc.plus`
|
||||
- 前端独立发布流水线: `.github/workflows/pipeline.yaml`
|
||||
本页用于统一部署前提、支持的拓扑、运维检查项与回滚注意事项。
|
||||
|
||||
## 运行方式
|
||||
## 与当前代码对齐的说明
|
||||
|
||||
前端镜像在 GitHub Actions 中完成构建并推送到镜像仓库,目标主机只负责拉取镜像和启动容器,不在机器上本地构建。
|
||||
- 文档目标仓库: `console.svc.plus`
|
||||
- 仓库类型: `frontend`
|
||||
- 构建与运行依据: package.json (`dashboard`)
|
||||
- 主要实现与运维目录: `src/`, `scripts/`, `tests/`, `config/`, `public/`
|
||||
- `package.json` 脚本快照: `dev`, `prebuild`, `build`, `build:static`, `start`, `lint`
|
||||
|
||||
`yarn prebuild` 现在只生成 console 自己的营销内容工件。`/docs` 与 `/blogs` 不再把 `knowledge/` 或 Markdown 文档打进前端镜像,而是在请求时通过服务端 `docsServiceClient` 从 `docs.svc.plus` 拉取渲染后的内容。
|
||||
## 需要继续归并的现有文档
|
||||
|
||||
当前方案尽量以静态模式运行:
|
||||
|
||||
- Caddy 直接服务 `/_next/static/*` 与 `public/` 里的静态资源,并配合 `frontend_static` 共享卷。
|
||||
- Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS)拷贝到 `frontend_static`。
|
||||
- `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。
|
||||
|
||||
发布由 `.github/workflows/pipeline.yaml` 驱动,CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL` 的 `.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面 DNS 自动化会通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS,使当前生产主机 `jp-xhttp-contabo.svc.plus` 承载更新后的环境。
|
||||
|
||||
这是针对弱 IO 单机主机 `root@jp-xhttp-contabo.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard` 与 `caddy`。
|
||||
|
||||
`docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。
|
||||
|
||||
当前发布合同要求:
|
||||
|
||||
- `CANONICAL_DOMAIN=www.svc.plus`
|
||||
- `SERVED_DOMAINS=www.svc.plus,console.svc.plus`
|
||||
- 流水线必须同时校验两个域名的首页静态资源与 `/api/ping` 版本元数据完全一致
|
||||
- `dashboardUrl`、canonical、structured data 与 sitemap 默认统一输出 `https://www.svc.plus`
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `usage/deployment.md`
|
||||
- `governance/release-process.md`
|
||||
- `development/dev-setup.md`
|
||||
- `getting-started/installation.md`
|
||||
- `getting-started/quickstart.md`
|
||||
- `governance/release-process.md`
|
||||
- `operations/runbooks/README.md`
|
||||
- `operations/runbooks/rag-server.md`
|
||||
- `usage/deployment.md`
|
||||
- `zh/development/dev-setup.md`
|
||||
|
||||
## 本页下一步应补充的内容
|
||||
|
||||
- 先描述当前已落地实现,再补充未来规划,避免只写愿景不写现状。
|
||||
- 术语需要与仓库根 README、构建清单和实际目录保持一致。
|
||||
- 将上方列出的历史 runbook、spec、子系统说明逐步链接并归并到本页。
|
||||
- 每次发布前,依据当前脚本、清单、CI/CD 流程和环境契约重新核对部署步骤。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> English: `../../governance/release-process.md`
|
||||
|
||||
本页用于记录公开控制台在 `www.svc.plus` 与 `console.svc.plus` 下发布版本的说明与变更摘要。
|
||||
本页用于记录 `console.svc.plus` 已发布版本的发布说明与变更摘要。
|
||||
|
||||
## 当前版本
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
- 将 observability 工作区拆分为 tri-view,并优化 panel 助手路由。
|
||||
- 统一导航结构与持久化 AI sidebar 行为。
|
||||
- 登录与注册流程改为使用服务端解析后的 account service URL。
|
||||
- 体验与演示模式不得在公开 UI 或会话载荷中暴露其后端承载账号身份。
|
||||
- 体验账号与演示账号统一收敛到 `sandbox@svc.plus`。
|
||||
- 为集成配置增加基于 vault 的 token 查询能力。
|
||||
|
||||
#### 文档与安装
|
||||
@ -49,7 +49,5 @@
|
||||
|
||||
## 备注
|
||||
|
||||
- GitHub Release:`https://github.com/x-evor/console.svc.plus/releases/tag/v0.2`
|
||||
- 发布校验必须同时验证 `www.svc.plus` 与 `console.svc.plus` 的 `releaseImageRef`、`releaseImageTag`、`releaseCommit` 完全一致。
|
||||
- `www.svc.plus` 是 metadata、sitemap、`dashboardUrl` 与公开分享链接的首选域名。
|
||||
- GitHub Release:`https://github.com/cloud-neutral-toolkit/console.svc.plus/releases/tag/v0.2`
|
||||
- 相关文档:`docs/README.md`、`docs/en/README.md`、`docs/zh/README.md`
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE_NAME } from "./src/lib/authGateway";
|
||||
|
||||
const PUBLIC_EXACT_PATHS = new Set([
|
||||
"/",
|
||||
"/services",
|
||||
"/login",
|
||||
"/register",
|
||||
"/email-verification",
|
||||
"/logout",
|
||||
"/404",
|
||||
"/500",
|
||||
]);
|
||||
|
||||
function isDocsPath(pathname: string): boolean {
|
||||
return pathname === "/docs" || pathname.startsWith("/docs/");
|
||||
}
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_EXACT_PATHS.has(pathname) || isDocsPath(pathname);
|
||||
}
|
||||
|
||||
function buildRedirectTarget(request: NextRequest): string {
|
||||
const query = request.nextUrl.search;
|
||||
return `${request.nextUrl.pathname}${query}`;
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (isPublicPath(pathname)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value?.trim();
|
||||
if (token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
const redirect = buildRedirectTarget(request);
|
||||
if (redirect && redirect !== "/login") {
|
||||
loginUrl.searchParams.set("redirect", redirect);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|gif|ico|jpg|jpeg|js|map|png|svg|txt|webp|woff|woff2|xml)$).*)",
|
||||
],
|
||||
};
|
||||
@ -4,7 +4,6 @@ import { withContentlayer } from "next-contentlayer";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const docsSiteBaseUrl = (process.env.NEXT_PUBLIC_DOCS_BASE_URL || "https://docs.svc.plus").replace(/\/$/, "");
|
||||
|
||||
const nextConfig = {
|
||||
// ===============================
|
||||
@ -86,16 +85,6 @@ const nextConfig = {
|
||||
|
||||
export async function redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/docs',
|
||||
destination: `${docsSiteBaseUrl}/docs`,
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/docs/:path*',
|
||||
destination: `${docsSiteBaseUrl}/docs/:path*`,
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/XStream',
|
||||
destination: '/xstream',
|
||||
|
||||
@ -63,7 +63,6 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-pdf": "^9.1.0",
|
||||
"react-resizable": "^3.0.4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value="${!key-}"
|
||||
if [[ -z "${value}" ]]; then
|
||||
echo "Missing required environment variable: ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env IMAGE_REF
|
||||
require_env CANONICAL_DOMAIN
|
||||
|
||||
BUILD_ARGS_FILE="$(mktemp)"
|
||||
trap 'rm -f "${BUILD_ARGS_FILE}"' EXIT
|
||||
|
||||
"${SCRIPT_DIR}/render-frontend-build-args.sh" --stdout > "${BUILD_ARGS_FILE}"
|
||||
|
||||
build_args=()
|
||||
while IFS= read -r line; do
|
||||
if [[ -z "${line}" ]]; then
|
||||
continue
|
||||
fi
|
||||
build_args+=(--build-arg "${line}")
|
||||
done < "${BUILD_ARGS_FILE}"
|
||||
|
||||
tag_args=(--tag "${IMAGE_REF}")
|
||||
if [[ "${PUSH_LATEST:-false}" == "true" ]]; then
|
||||
require_env IMAGE_LATEST_REF
|
||||
tag_args+=(--tag "${IMAGE_LATEST_REF}")
|
||||
fi
|
||||
|
||||
docker buildx build \
|
||||
--platform "${DOCKER_PLATFORM:-linux/amd64}" \
|
||||
--file "${REPO_ROOT}/Dockerfile" \
|
||||
"${tag_args[@]}" \
|
||||
"${build_args[@]}" \
|
||||
--push \
|
||||
"${REPO_ROOT}"
|
||||
@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_TAG_INPUT="${1-}"
|
||||
IMAGE_TAG="${IMAGE_TAG_INPUT}"
|
||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||
IMAGE_TAG="${GITHUB_SHA:?GITHUB_SHA is required}"
|
||||
fi
|
||||
|
||||
GHCR_NAMESPACE="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
GHCR_REGISTRY="${GHCR_REGISTRY:-ghcr.io}"
|
||||
|
||||
if [[ -z "${GITHUB_OUTPUT-}" ]]; then
|
||||
echo "GITHUB_OUTPUT is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'ghcr_namespace=%s\n' "${GHCR_NAMESPACE}"
|
||||
printf 'image_tag=%s\n' "${IMAGE_TAG}"
|
||||
printf 'image_ref=%s/%s/console:%s\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}" "${IMAGE_TAG}"
|
||||
printf 'image_latest_ref=%s/%s/console:latest\n' "${GHCR_REGISTRY}" "${GHCR_NAMESPACE}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target_host="${TARGET_HOST:?TARGET_HOST is required}"
|
||||
private_key="${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:?SINGLE_NODE_VPS_SSH_PRIVATE_KEY is required}"
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "${private_key}" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${target_host}" >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
DEPLOY_SOURCE_DIR="${REPO_ROOT}/deploy/single-node"
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value="${!key-}"
|
||||
if [[ -z "${value}" ]]; then
|
||||
echo "Missing required environment variable: ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env DEPLOY_HOST
|
||||
require_env DEPLOY_USER
|
||||
require_env DEPLOY_DIR
|
||||
require_env SINGLE_NODE_VPS_SSH_PRIVATE_KEY
|
||||
require_env GHCR_USERNAME
|
||||
require_env GHCR_PASSWORD
|
||||
require_env FRONTEND_IMAGE
|
||||
require_env CANONICAL_DOMAIN
|
||||
require_env SERVED_DOMAINS
|
||||
|
||||
GHCR_REGISTRY="${GHCR_REGISTRY:-ghcr.io}"
|
||||
|
||||
reject_remote_build_configuration() {
|
||||
local compose_file="$1"
|
||||
|
||||
if grep -Eq '^[[:space:]]*(build|dockerfile):' "${compose_file}"; then
|
||||
echo "Deployment package must reference prebuilt images only; compose build directives are forbidden." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORK_DIR}"' EXIT
|
||||
|
||||
RUNTIME_ENV_FILE="${WORK_DIR}/.env.runtime"
|
||||
RELEASE_ARCHIVE="${WORK_DIR}/console-svc-plus-release.tgz"
|
||||
REMOTE_ARCHIVE="/tmp/console-svc-plus-release-${GITHUB_SHA:-manual}.tgz"
|
||||
SSH_KEY_FILE="${WORK_DIR}/deploy.key"
|
||||
KNOWN_HOSTS_FILE="${WORK_DIR}/known_hosts"
|
||||
|
||||
bash "${SCRIPT_DIR}/render-frontend-runtime-env.sh" "${RUNTIME_ENV_FILE}"
|
||||
|
||||
cp "${DEPLOY_SOURCE_DIR}/docker-compose.yml" "${WORK_DIR}/docker-compose.yml"
|
||||
cp "${DEPLOY_SOURCE_DIR}/Caddyfile" "${WORK_DIR}/Caddyfile"
|
||||
|
||||
reject_remote_build_configuration "${WORK_DIR}/docker-compose.yml"
|
||||
|
||||
tar -C "${WORK_DIR}" -czf "${RELEASE_ARCHIVE}" \
|
||||
docker-compose.yml \
|
||||
Caddyfile \
|
||||
.env.runtime
|
||||
|
||||
printf '%s\n' "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY}" > "${SSH_KEY_FILE}"
|
||||
chmod 600 "${SSH_KEY_FILE}"
|
||||
ssh-keyscan -H "${DEPLOY_HOST}" > "${KNOWN_HOSTS_FILE}"
|
||||
|
||||
SSH_BASE=(
|
||||
ssh
|
||||
-i "${SSH_KEY_FILE}"
|
||||
-o StrictHostKeyChecking=yes
|
||||
-o UserKnownHostsFile="${KNOWN_HOSTS_FILE}"
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
)
|
||||
|
||||
SCP_BASE=(
|
||||
scp
|
||||
-i "${SSH_KEY_FILE}"
|
||||
-o StrictHostKeyChecking=yes
|
||||
-o UserKnownHostsFile="${KNOWN_HOSTS_FILE}"
|
||||
)
|
||||
|
||||
printf '%s' "${GHCR_PASSWORD}" | "${SSH_BASE[@]}" \
|
||||
"GHCR_REGISTRY='${GHCR_REGISTRY}' GHCR_USERNAME='${GHCR_USERNAME}' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
require_sudo_prefix() {
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
printf 'sudo -n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Remote host requires root or passwordless sudo to install or manage Docker." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_docker() {
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sudo_prefix
|
||||
sudo_prefix="$(require_sudo_prefix)"
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
${sudo_prefix} apt-get update
|
||||
if ! DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-plugin; then
|
||||
DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-v2
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
${sudo_prefix} systemctl enable --now docker
|
||||
else
|
||||
${sudo_prefix} service docker start
|
||||
fi
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Docker is not installed and this script only knows how to install it on apt-based hosts." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker_runner() {
|
||||
if docker info >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sudo_prefix
|
||||
sudo_prefix="$(require_sudo_prefix)"
|
||||
|
||||
if ${sudo_prefix} docker info >/dev/null 2>&1; then
|
||||
printf '%s' "${sudo_prefix}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Docker is installed but not accessible for the deploy user." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_docker
|
||||
docker_prefix="$(docker_runner)"
|
||||
printf '%s' "${GHCR_PASSWORD}" | ${docker_prefix:+${docker_prefix} }docker login "${GHCR_REGISTRY}" -u "${GHCR_USERNAME}" --password-stdin
|
||||
EOF
|
||||
|
||||
"${SCP_BASE[@]}" "${RELEASE_ARCHIVE}" "${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_ARCHIVE}"
|
||||
|
||||
"${SSH_BASE[@]}" \
|
||||
"DEPLOY_DIR='${DEPLOY_DIR}' REMOTE_ARCHIVE='${REMOTE_ARCHIVE}' PROJECT_NAME='console-svc-plus' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
require_sudo_prefix() {
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
printf 'sudo -n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Remote host requires root or passwordless sudo to install or manage Docker." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_docker() {
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sudo_prefix
|
||||
sudo_prefix="$(require_sudo_prefix)"
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
${sudo_prefix} apt-get update
|
||||
if ! DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-plugin; then
|
||||
DEBIAN_FRONTEND=noninteractive ${sudo_prefix} apt-get install -y docker.io docker-compose-v2
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
${sudo_prefix} systemctl enable --now docker
|
||||
else
|
||||
${sudo_prefix} service docker start
|
||||
fi
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Docker is not installed and this script only knows how to install it on apt-based hosts." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker_runner() {
|
||||
if docker info >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sudo_prefix
|
||||
sudo_prefix="$(require_sudo_prefix)"
|
||||
|
||||
if ${sudo_prefix} docker info >/dev/null 2>&1; then
|
||||
printf '%s' "${sudo_prefix}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Docker is installed but not accessible for the deploy user." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp_dir}" "${REMOTE_ARCHIVE}"' EXIT
|
||||
|
||||
ensure_docker
|
||||
docker_prefix="$(docker_runner)"
|
||||
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
tar -xzf "${REMOTE_ARCHIVE}" -C "${tmp_dir}"
|
||||
|
||||
install -m 0644 "${tmp_dir}/docker-compose.yml" "${DEPLOY_DIR}/docker-compose.yml"
|
||||
install -m 0644 "${tmp_dir}/Caddyfile" "${DEPLOY_DIR}/Caddyfile"
|
||||
install -m 0600 "${tmp_dir}/.env.runtime" "${DEPLOY_DIR}/.env.runtime"
|
||||
|
||||
cd "${DEPLOY_DIR}"
|
||||
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime pull dashboard caddy
|
||||
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime run --rm frontend-assets
|
||||
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime up -d --remove-orphans dashboard caddy
|
||||
${docker_prefix:+${docker_prefix} }docker compose --project-name "${PROJECT_NAME}" --env-file .env.runtime ps
|
||||
EOF
|
||||
@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
shift
|
||||
fi
|
||||
|
||||
TARGET_IP="${1:?usage: ensure-frontend-dns.sh [--dry-run] <target-ip> <domain> [domain...]}"
|
||||
shift
|
||||
|
||||
if [[ "$#" -lt 1 ]]; then
|
||||
echo "usage: ensure-frontend-dns.sh [--dry-run] <target-ip> <domain> [domain...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROXIED="${CLOUDFLARE_PROXIED:-true}"
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value="${!key-}"
|
||||
if [[ -z "${value}" ]]; then
|
||||
echo "Missing required environment variable: ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local expression="$1"
|
||||
python3 -c "import json,sys; data=json.load(sys.stdin); ${expression}"
|
||||
}
|
||||
|
||||
cloudflare_api_get() {
|
||||
local path="$1"
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4${path}"
|
||||
}
|
||||
|
||||
resolve_zone_for_domain() {
|
||||
local domain="$1"
|
||||
local candidate="${domain%.}"
|
||||
local response
|
||||
local zone_id
|
||||
|
||||
if [[ -n "${CLOUDFLARE_DNS_ZONE_TAG-}" ]]; then
|
||||
printf '%s\t%s\n' "${CLOUDFLARE_DNS_ZONE_TAG}" "override"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while [[ "${candidate}" == *.* ]]; do
|
||||
response="$(cloudflare_api_get "/zones?name=${candidate}")"
|
||||
if [[ "$(printf '%s' "${response}" | json_get 'print("true" if data.get("success") else "false")')" != "true" ]]; then
|
||||
echo "Failed to query Cloudflare zones for ${candidate}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zone_id="$(printf '%s' "${response}" | json_get 'result=data.get("result", []); print(result[0]["id"] if result else "")')"
|
||||
if [[ -n "${zone_id}" ]]; then
|
||||
printf '%s\t%s\n' "${zone_id}" "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
candidate="${candidate#*.}"
|
||||
done
|
||||
|
||||
echo "Unable to resolve Cloudflare zone for ${domain}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
dns_payload() {
|
||||
local domain="$1"
|
||||
DOMAIN="${domain}" TARGET_IP="${TARGET_IP}" PROXIED="${PROXIED}" python3 -c \
|
||||
'import json, os; print(json.dumps({"type": "A", "name": os.environ["DOMAIN"], "content": os.environ["TARGET_IP"], "ttl": 1, "proxied": os.environ["PROXIED"].lower() == "true"}))'
|
||||
}
|
||||
|
||||
upsert_record() {
|
||||
local domain="$1"
|
||||
local payload
|
||||
local response
|
||||
local record_id
|
||||
local zone_id
|
||||
local zone_name
|
||||
|
||||
payload="$(dns_payload "${domain}")"
|
||||
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
printf 'dry-run: upsert %s -> %s\n' "${domain}" "${TARGET_IP}"
|
||||
printf '%s\n' "${payload}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r zone_id zone_name <<EOF
|
||||
$(resolve_zone_for_domain "${domain}")
|
||||
EOF
|
||||
|
||||
response="$(cloudflare_api_get "/zones/${zone_id}/dns_records?type=A&name=${domain}")"
|
||||
|
||||
if [[ "$(printf '%s' "${response}" | json_get 'print("true" if data.get("success") else "false")')" != "true" ]]; then
|
||||
echo "Failed to query Cloudflare DNS records for ${domain}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
record_id="$(printf '%s' "${response}" | json_get 'result=data.get("result", []); print(result[0]["id"] if result else "")')"
|
||||
|
||||
if [[ -n "${record_id}" ]]; then
|
||||
response="$(
|
||||
curl -fsS -X PUT \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "${payload}" \
|
||||
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${record_id}"
|
||||
)"
|
||||
else
|
||||
response="$(
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "${payload}" \
|
||||
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records"
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ "$(printf '%s' "${response}" | json_get 'print("true" if data.get("success") else "false")')" != "true" ]]; then
|
||||
echo "Failed to upsert Cloudflare DNS record for ${domain}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'updated: %s -> %s (zone %s)\n' "${domain}" "${TARGET_IP}" "${zone_name}"
|
||||
}
|
||||
|
||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
||||
require_env CLOUDFLARE_DNS_API_TOKEN
|
||||
fi
|
||||
|
||||
for domain in "$@"; do
|
||||
upsert_record "${domain}"
|
||||
done
|
||||
@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ghcr_token="${GHCR_TOKEN:?GHCR_TOKEN is required}"
|
||||
ghcr_username="${GHCR_USERNAME:?GHCR_USERNAME is required}"
|
||||
|
||||
printf '%s' "${ghcr_token}" | docker login ghcr.io -u "${ghcr_username}" --password-stdin
|
||||
@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "frontend build context no longer syncs docs/blog content; using docs.svc.plus at runtime."
|
||||
@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
bash scripts/github-actions/build-and-push-frontend-image.sh
|
||||
|
||||
printf 'image_ref=%s\n' "${IMAGE_REF:?IMAGE_REF is required}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'image_tag=%s\n' "${IMAGE_TAG:?IMAGE_TAG is required}" >> "${GITHUB_OUTPUT}"
|
||||
@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:---github-output}"
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value="${!key-}"
|
||||
if [[ -z "${value}" ]]; then
|
||||
echo "Missing required environment variable: ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
emit_lines() {
|
||||
require_env CANONICAL_DOMAIN
|
||||
|
||||
local canonical_domain="${CANONICAL_DOMAIN}"
|
||||
local release_image_ref="${IMAGE_REF-}"
|
||||
local release_image_tag=""
|
||||
local release_commit=""
|
||||
local release_version=""
|
||||
|
||||
if [[ -n "${release_image_ref}" ]]; then
|
||||
release_image_tag="$(printf '%s' "${release_image_ref}" | sed -E 's#^.*:([^:@]+)$#\1#')"
|
||||
release_version="${release_image_tag}"
|
||||
|
||||
if [[ "${release_image_tag}" =~ ^sha-([0-9a-f]{7,40})$ ]]; then
|
||||
release_commit="${BASH_REMATCH[1]}"
|
||||
elif [[ "${release_image_tag}" =~ ^[0-9a-f]{7,40}$ ]]; then
|
||||
release_commit="${release_image_tag}"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'NODE_BUILDER_IMAGE=%s\n' "${NODE_BUILDER_IMAGE:-node:22-bookworm}"
|
||||
printf 'NODE_RUNTIME_IMAGE=%s\n' "${NODE_RUNTIME_IMAGE:-node:22-slim}"
|
||||
printf 'CONTENTLAYER_BUILD=%s\n' "${CONTENTLAYER_BUILD:-true}"
|
||||
printf 'NEXT_PUBLIC_APP_BASE_URL=%s\n' "${NEXT_PUBLIC_APP_BASE_URL:-https://${canonical_domain}}"
|
||||
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "${NEXT_PUBLIC_SITE_URL:-https://${canonical_domain}}"
|
||||
printf 'NEXT_PUBLIC_LOGIN_URL=%s\n' "${NEXT_PUBLIC_LOGIN_URL:-https://${canonical_domain}/login}"
|
||||
printf 'NEXT_PUBLIC_DOCS_BASE_URL=%s\n' "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${canonical_domain}/docs}"
|
||||
printf 'NEXT_PUBLIC_RUNTIME_ENVIRONMENT=%s\n' "${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:-prod}"
|
||||
printf 'NEXT_PUBLIC_RUNTIME_REGION=%s\n' "${NEXT_PUBLIC_RUNTIME_REGION:-cn}"
|
||||
printf 'NEXT_PUBLIC_GISCUS_REPO=%s\n' "${NEXT_PUBLIC_GISCUS_REPO:-x-evor/console.svc.plus}"
|
||||
printf 'NEXT_PUBLIC_GISCUS_REPO_ID=%s\n' "${NEXT_PUBLIC_GISCUS_REPO_ID-}"
|
||||
printf 'NEXT_PUBLIC_GISCUS_CATEGORY=%s\n' "${NEXT_PUBLIC_GISCUS_CATEGORY:-General}"
|
||||
printf 'NEXT_PUBLIC_GISCUS_CATEGORY_ID=%s\n' "${NEXT_PUBLIC_GISCUS_CATEGORY_ID-}"
|
||||
printf 'NEXT_PUBLIC_PAYPAL_CLIENT_ID=%s\n' "${NEXT_PUBLIC_PAYPAL_CLIENT_ID-}"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO-}"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION-}"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO-}"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION-}"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO-}"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=%s\n' "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION-}"
|
||||
printf 'NEXT_PUBLIC_RELEASE_IMAGE=%s\n' "${release_image_ref}"
|
||||
printf 'NEXT_PUBLIC_RELEASE_TAG=%s\n' "${release_image_tag}"
|
||||
printf 'NEXT_PUBLIC_RELEASE_COMMIT=%s\n' "${release_commit}"
|
||||
printf 'NEXT_PUBLIC_RELEASE_VERSION=%s\n' "${release_version}"
|
||||
}
|
||||
|
||||
if [[ "${MODE}" == "--stdout" ]]; then
|
||||
emit_lines
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${GITHUB_OUTPUT-}" ]]; then
|
||||
echo "GITHUB_OUTPUT is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "build_args<<EOF"
|
||||
emit_lines
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_PATH="${1:?usage: render-frontend-runtime-env.sh <output-path>}"
|
||||
|
||||
mkdir -p "$(dirname "${OUTPUT_PATH}")"
|
||||
: > "${OUTPUT_PATH}"
|
||||
|
||||
append_env() {
|
||||
local key="$1"
|
||||
local value="${2-}"
|
||||
printf '%s=%s\n' "${key}" "${value}" >> "${OUTPUT_PATH}"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value="${!key-}"
|
||||
if [[ -z "${value}" ]]; then
|
||||
echo "Missing required environment variable: ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env FRONTEND_IMAGE
|
||||
require_env CANONICAL_DOMAIN
|
||||
require_env SERVED_DOMAINS
|
||||
|
||||
append_env FRONTEND_IMAGE "${FRONTEND_IMAGE}"
|
||||
append_env CANONICAL_DOMAIN "${CANONICAL_DOMAIN}"
|
||||
append_env SERVED_DOMAINS "${SERVED_DOMAINS}"
|
||||
|
||||
append_env NODE_ENV "production"
|
||||
append_env PORT "${PORT:-3000}"
|
||||
append_env RUNTIME_ENV "${RUNTIME_ENV:-prod}"
|
||||
append_env REGION "${REGION:-cn}"
|
||||
append_env APP_BASE_URL "${APP_BASE_URL:-https://${CANONICAL_DOMAIN}}"
|
||||
append_env NEXT_PUBLIC_APP_BASE_URL "${NEXT_PUBLIC_APP_BASE_URL:-https://${CANONICAL_DOMAIN}}"
|
||||
append_env NEXT_PUBLIC_SITE_URL "${NEXT_PUBLIC_SITE_URL:-https://${CANONICAL_DOMAIN}}"
|
||||
append_env NEXT_PUBLIC_LOGIN_URL "${NEXT_PUBLIC_LOGIN_URL:-https://${CANONICAL_DOMAIN}/login}"
|
||||
append_env NEXT_PUBLIC_DOCS_BASE_URL "${NEXT_PUBLIC_DOCS_BASE_URL:-https://${CANONICAL_DOMAIN}/docs}"
|
||||
append_env SESSION_COOKIE_SECURE "${SESSION_COOKIE_SECURE:-true}"
|
||||
append_env NEXT_PUBLIC_SESSION_COOKIE_SECURE "${NEXT_PUBLIC_SESSION_COOKIE_SECURE:-true}"
|
||||
append_env RUNTIME_HOSTNAME "${RUNTIME_HOSTNAME:-${CANONICAL_DOMAIN}}"
|
||||
append_env NEXT_RUNTIME_HOSTNAME "${NEXT_RUNTIME_HOSTNAME:-${CANONICAL_DOMAIN}}"
|
||||
append_env DEPLOYMENT_HOSTNAME "${DEPLOYMENT_HOSTNAME-}"
|
||||
append_env NEXT_PUBLIC_RUNTIME_ENVIRONMENT "${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:-prod}"
|
||||
append_env NEXT_PUBLIC_RUNTIME_REGION "${NEXT_PUBLIC_RUNTIME_REGION:-cn}"
|
||||
append_env ACCOUNT_SERVICE_URL "${ACCOUNT_SERVICE_URL:-https://accounts.svc.plus}"
|
||||
append_env NEXT_PUBLIC_ACCOUNT_SERVICE_URL "${NEXT_PUBLIC_ACCOUNT_SERVICE_URL:-${ACCOUNT_SERVICE_URL:-https://accounts.svc.plus}}"
|
||||
append_env SERVER_SERVICE_URL "${SERVER_SERVICE_URL:-https://api.svc.plus}"
|
||||
append_env NEXT_PUBLIC_SERVER_SERVICE_URL "${NEXT_PUBLIC_SERVER_SERVICE_URL:-${SERVER_SERVICE_URL:-https://api.svc.plus}}"
|
||||
append_env SERVER_SERVICE_INTERNAL_URL "${SERVER_SERVICE_INTERNAL_URL-}"
|
||||
append_env DOCS_SERVICE_URL "${DOCS_SERVICE_URL:-https://docs.svc.plus}"
|
||||
append_env DOCS_SERVICE_INTERNAL_URL "${DOCS_SERVICE_INTERNAL_URL-}"
|
||||
append_env OPENCLAW_GATEWAY_REMOTE_URL "${OPENCLAW_GATEWAY_REMOTE_URL-}"
|
||||
append_env OPENCLAW_GATEWAY_TOKEN "${OPENCLAW_GATEWAY_TOKEN-}"
|
||||
append_env VAULT_SERVER_URL "${VAULT_SERVER_URL-}"
|
||||
append_env VAULT_NAMESPACE "${VAULT_NAMESPACE-}"
|
||||
append_env VAULT_TOKEN "${VAULT_TOKEN-}"
|
||||
append_env APISIX_AI_GATEWAY_URL "${APISIX_AI_GATEWAY_URL-}"
|
||||
append_env AI_GATEWAY_ACCESS_TOKEN "${AI_GATEWAY_ACCESS_TOKEN-}"
|
||||
append_env INTERNAL_SERVICE_TOKEN "${INTERNAL_SERVICE_TOKEN-}"
|
||||
append_env CLOUDFLARE_API_TOKEN "${CLOUDFLARE_API_TOKEN-}"
|
||||
append_env CLOUDFLARE_ACCOUNT_ID "${CLOUDFLARE_ACCOUNT_ID-}"
|
||||
append_env CLOUDFLARE_WEB_ANALYTICS_SITE_TAG "${CLOUDFLARE_WEB_ANALYTICS_SITE_TAG-}"
|
||||
append_env CLOUDFLARE_ZONE_TAG "${CLOUDFLARE_ZONE_TAG-}"
|
||||
append_env ROOT_EMAIL_WHITELIST "${ROOT_EMAIL_WHITELIST:-admin@svc.plus}"
|
||||
append_env NEXT_PUBLIC_PAYPAL_CLIENT_ID "${NEXT_PUBLIC_PAYPAL_CLIENT_ID-}"
|
||||
append_env NEXT_PUBLIC_GISCUS_REPO "${NEXT_PUBLIC_GISCUS_REPO:-x-evor/console.svc.plus}"
|
||||
append_env NEXT_PUBLIC_GISCUS_REPO_ID "${NEXT_PUBLIC_GISCUS_REPO_ID-}"
|
||||
append_env NEXT_PUBLIC_GISCUS_CATEGORY "${NEXT_PUBLIC_GISCUS_CATEGORY:-General}"
|
||||
append_env NEXT_PUBLIC_GISCUS_CATEGORY_ID "${NEXT_PUBLIC_GISCUS_CATEGORY_ID-}"
|
||||
append_env NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO-}"
|
||||
append_env NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION "${NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION-}"
|
||||
append_env NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO-}"
|
||||
append_env NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION "${NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION-}"
|
||||
append_env NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO-}"
|
||||
append_env NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION "${NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION-}"
|
||||
@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ref="${REF:?REF is required}"
|
||||
|
||||
if [[ "${ref}" == "refs/heads/main" ]]; then
|
||||
echo "push_latest=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "push_latest=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
event_name="${EVENT_NAME:?EVENT_NAME is required}"
|
||||
input_target_host="${INPUT_TARGET_HOST:-}"
|
||||
input_run_apply="${INPUT_RUN_APPLY:-}"
|
||||
|
||||
if [[ "${event_name}" == "workflow_dispatch" ]]; then
|
||||
target_host="${input_target_host}"
|
||||
run_apply="${input_run_apply}"
|
||||
else
|
||||
target_host="jp-xhttp-contabo.svc.plus"
|
||||
run_apply="true"
|
||||
fi
|
||||
|
||||
printf 'target_host=%s\n' "${target_host}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'run_apply=%s\n' "${run_apply}" >> "${GITHUB_OUTPUT}"
|
||||
@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target_host="${TARGET_HOST:?TARGET_HOST is required}"
|
||||
run_apply="${RUN_APPLY:?RUN_APPLY is required}"
|
||||
cloudflare_zone_id="${CLOUDFLARE_ZONE_TAG:?CLOUDFLARE_ZONE_TAG is required}"
|
||||
cloudflare_dns_token="${CLOUDFLARE_DNS_API_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
|
||||
|
||||
if [[ -z "${cloudflare_dns_token}" ]]; then
|
||||
echo "CLOUDFLARE_DNS_API_TOKEN or CLOUDFLARE_API_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ansible_args=(
|
||||
-i inventory.ini
|
||||
update_cloudflare_svc_plus_dns.yml
|
||||
-e "{\"cloudflare_dns_source_hosts\":[\"${target_host}\"]}"
|
||||
-e "cloudflare_dns_zone_id=${cloudflare_zone_id}"
|
||||
-e "CLOUDFLARE_DNS_API_TOKEN=${cloudflare_dns_token}"
|
||||
)
|
||||
|
||||
if [[ "${run_apply}" != "true" ]]; then
|
||||
ansible_args=(-C "${ansible_args[@]}")
|
||||
fi
|
||||
|
||||
ansible-playbook "${ansible_args[@]}"
|
||||
@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target_host="${TARGET_HOST:?TARGET_HOST is required}"
|
||||
run_apply="${RUN_APPLY:?RUN_APPLY is required}"
|
||||
frontend_image="${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}"
|
||||
|
||||
ansible_args=(
|
||||
-i inventory.ini
|
||||
deploy_console_svc_plus.yml
|
||||
-D
|
||||
-l "${target_host}"
|
||||
-e "FRONTEND_IMAGE=${frontend_image}"
|
||||
-e "GHCR_USERNAME=${GHCR_USERNAME:?GHCR_USERNAME is required}"
|
||||
-e "GHCR_PASSWORD=${GHCR_PASSWORD:?GHCR_PASSWORD is required}"
|
||||
-e "INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required}"
|
||||
-e "ACCOUNT_SERVICE_URL=${ACCOUNT_SERVICE_URL:?ACCOUNT_SERVICE_URL is required}"
|
||||
-e "CANONICAL_DOMAIN=${CANONICAL_DOMAIN:?CANONICAL_DOMAIN is required}"
|
||||
-e "SERVED_DOMAINS=${SERVED_DOMAINS:?SERVED_DOMAINS is required}"
|
||||
-e "NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${NEXT_PUBLIC_RUNTIME_ENVIRONMENT:?NEXT_PUBLIC_RUNTIME_ENVIRONMENT is required}"
|
||||
-e "NEXT_PUBLIC_RUNTIME_REGION=${NEXT_PUBLIC_RUNTIME_REGION:?NEXT_PUBLIC_RUNTIME_REGION is required}"
|
||||
-e "CLOUDFLARE_ZONE_TAG=${CLOUDFLARE_ZONE_TAG:?CLOUDFLARE_ZONE_TAG is required}"
|
||||
-e "CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=${CLOUDFLARE_WEB_ANALYTICS_SITE_TAG:?CLOUDFLARE_WEB_ANALYTICS_SITE_TAG is required}"
|
||||
-e "CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required}"
|
||||
-e "CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
|
||||
-e "CLOUDFLARE_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN:?CLOUDFLARE_DNS_API_TOKEN is required}"
|
||||
)
|
||||
|
||||
if [[ "${run_apply}" != "true" ]]; then
|
||||
ansible_args=(-C "${ansible_args[@]}")
|
||||
fi
|
||||
|
||||
ansible-playbook "${ansible_args[@]}"
|
||||
@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker buildx create --name console-builder --use >/dev/null 2>&1 || docker buildx use console-builder
|
||||
docker buildx inspect --bootstrap
|
||||
@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target_host="${TARGET_HOST:?TARGET_HOST is required}"
|
||||
canonical_domain="${CANONICAL_DOMAIN:?CANONICAL_DOMAIN is required}"
|
||||
served_domains="${SERVED_DOMAINS:?SERVED_DOMAINS is required}"
|
||||
expected_image_ref="${EXPECTED_FRONTEND_IMAGE:?EXPECTED_FRONTEND_IMAGE is required}"
|
||||
request_base_url="${REQUEST_BASE_URL:-http://127.0.0.1:3000}"
|
||||
|
||||
remote_args=(
|
||||
"$(printf '%q' "${canonical_domain}")"
|
||||
"$(printf '%q' "${served_domains}")"
|
||||
"$(printf '%q' "${expected_image_ref}")"
|
||||
"$(printf '%q' "${request_base_url}")"
|
||||
)
|
||||
|
||||
ssh -o BatchMode=yes "root@${target_host}" "bash -s -- ${remote_args[*]}" \
|
||||
< scripts/github-actions/verify-frontend-release.sh
|
||||
@ -1,221 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CANONICAL_DOMAIN="${1:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
|
||||
SERVED_DOMAINS="${2:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
|
||||
EXPECTED_IMAGE_REF="${3:?usage: verify-frontend-release.sh <canonical-domain> <served-domains> <expected-image-ref> [request-base-url]}"
|
||||
REQUEST_BASE_URL="${4:-https://${CANONICAL_DOMAIN}}"
|
||||
|
||||
curl_headers=(
|
||||
-H 'user-agent: Mozilla/5.0 (compatible; console-release-validator/1.0; +https://www.svc.plus)'
|
||||
-H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
-H 'accept-language: en-US,en;q=0.9'
|
||||
)
|
||||
|
||||
trim() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
parse_image_ref() {
|
||||
local image_ref="$1"
|
||||
|
||||
IMAGE_REF="${image_ref}" python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
image_ref = os.environ["IMAGE_REF"].strip()
|
||||
match = re.search(r":([^:@]+)$", image_ref)
|
||||
tag = match.group(1) if match else ""
|
||||
commit = ""
|
||||
version = tag
|
||||
|
||||
if re.fullmatch(r"[0-9a-f]{7,40}", tag, flags=re.IGNORECASE):
|
||||
commit = tag
|
||||
else:
|
||||
prefixed_match = re.fullmatch(r"sha-([0-9a-f]{7,40})", tag, flags=re.IGNORECASE)
|
||||
if prefixed_match:
|
||||
commit = prefixed_match.group(1)
|
||||
|
||||
if not image_ref or not tag or not commit or not version:
|
||||
sys.exit(1)
|
||||
|
||||
print(image_ref)
|
||||
print(tag)
|
||||
print(commit)
|
||||
print(version)
|
||||
PY
|
||||
}
|
||||
|
||||
parse_homepage_release_metadata() {
|
||||
local homepage_html="$1"
|
||||
HOMEPAGE_HTML="${homepage_html}" python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
html = os.environ["HOMEPAGE_HTML"]
|
||||
|
||||
def extract_meta(name: str) -> str:
|
||||
pattern = rf'<meta[^>]+name=["\']{re.escape(name)}["\'][^>]+content=["\']([^"\']*)["\']'
|
||||
match = re.search(pattern, html, flags=re.IGNORECASE)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
print(extract_meta("svc-plus-release-image"))
|
||||
print(extract_meta("svc-plus-release-tag"))
|
||||
print(extract_meta("svc-plus-release-commit"))
|
||||
print(extract_meta("svc-plus-release-version"))
|
||||
PY
|
||||
}
|
||||
|
||||
require_http_200() {
|
||||
local url="$1"
|
||||
shift
|
||||
|
||||
local http_code
|
||||
http_code="$(curl -sS -o /dev/null -w '%{http_code}' "$@" "${url}")"
|
||||
if [[ "${http_code}" != "200" ]]; then
|
||||
echo "Expected HTTP 200 from ${url}, got ${http_code}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_domain() {
|
||||
local domain="$1"
|
||||
local request_base_url="${REQUEST_BASE_URL%/}"
|
||||
local request_headers=("${curl_headers[@]}" -H "host: ${domain}")
|
||||
local homepage_html asset_path release_metadata
|
||||
local actual_image_ref actual_image_tag actual_release_commit actual_release_version
|
||||
local release_lines
|
||||
|
||||
require_http_200 "${request_base_url}" "${request_headers[@]}"
|
||||
printf 'verified homepage for %s: 200\n' "${domain}" >&2
|
||||
|
||||
homepage_html="$(curl -fsSL "${request_headers[@]}" "${request_base_url}")"
|
||||
asset_path="$(printf '%s' "${homepage_html}" | grep -Eo '/_next/static/[^"'"'"' ]+\.(css|js)' | head -n 1)"
|
||||
if [[ -z "${asset_path}" ]]; then
|
||||
echo "Could not find a _next/static asset on ${domain} via ${request_base_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_http_200 "${request_base_url}${asset_path}" "${request_headers[@]}"
|
||||
printf 'verified static asset for %s: %s%s\n' "${domain}" "${request_base_url}" "${asset_path}" >&2
|
||||
|
||||
release_metadata="$(parse_homepage_release_metadata "${homepage_html}")"
|
||||
|
||||
mapfile -t release_lines <<< "${release_metadata}"
|
||||
actual_image_ref="${release_lines[0]-}"
|
||||
actual_image_tag="${release_lines[1]-}"
|
||||
actual_release_commit="${release_lines[2]-}"
|
||||
actual_release_version="${release_lines[3]-}"
|
||||
|
||||
if [[ -z "${actual_image_ref}" || -z "${actual_image_tag}" || -z "${actual_release_commit}" || -z "${actual_release_version}" ]]; then
|
||||
echo "Homepage release metadata is incomplete for ${domain}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${actual_release_commit}" =~ ^[0-9a-f]{7,40}$ ]]; then
|
||||
echo "Homepage release commit must contain a commit id for ${domain}, got: ${actual_release_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${actual_release_version}" != "${actual_image_tag}" ]]; then
|
||||
echo "Homepage release version mismatch for ${domain}: expected ${actual_image_tag}, got ${actual_release_version}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
|
||||
echo "Homepage release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'verified homepage release image for %s: %s\n' "${domain}" "${actual_image_ref}" >&2
|
||||
printf 'verified homepage release commit for %s: %s\n' "${domain}" "${actual_release_commit}" >&2
|
||||
printf 'verified homepage release version for %s: %s\n' "${domain}" "${actual_release_version}" >&2
|
||||
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "${domain}" "${actual_image_ref}" "${actual_image_tag}" "${actual_release_commit}" "${actual_release_version}"
|
||||
}
|
||||
|
||||
mapfile -t expected_release_lines < <(parse_image_ref "${EXPECTED_IMAGE_REF}")
|
||||
EXPECTED_RELEASE_IMAGE_REF="${expected_release_lines[0]-}"
|
||||
EXPECTED_RELEASE_IMAGE_TAG="${expected_release_lines[1]-}"
|
||||
EXPECTED_RELEASE_COMMIT="${expected_release_lines[2]-}"
|
||||
EXPECTED_RELEASE_VERSION="${expected_release_lines[3]-}"
|
||||
|
||||
if [[ -z "${EXPECTED_RELEASE_IMAGE_REF}" || -z "${EXPECTED_RELEASE_IMAGE_TAG}" || -z "${EXPECTED_RELEASE_COMMIT}" || -z "${EXPECTED_RELEASE_VERSION}" ]]; then
|
||||
echo "Expected image ref is invalid: ${EXPECTED_IMAGE_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t served_domains < <(
|
||||
printf '%s' "${SERVED_DOMAINS}" | tr ',' '\n' | while IFS= read -r domain; do
|
||||
domain="$(trim "${domain}")"
|
||||
if [[ -n "${domain}" ]]; then
|
||||
printf '%s\n' "${domain}"
|
||||
fi
|
||||
done
|
||||
)
|
||||
|
||||
if [[ "${#served_domains[@]}" -eq 0 ]]; then
|
||||
echo "No served domains were provided." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
canonical_found=false
|
||||
declare -a verification_rows=()
|
||||
|
||||
for domain in "${served_domains[@]}"; do
|
||||
if [[ "${domain}" == "${CANONICAL_DOMAIN}" ]]; then
|
||||
canonical_found=true
|
||||
fi
|
||||
verification_rows+=("$(verify_domain "${domain}")")
|
||||
done
|
||||
|
||||
if [[ "${canonical_found}" != "true" ]]; then
|
||||
echo "Canonical domain ${CANONICAL_DOMAIN} must be included in served domains: ${SERVED_DOMAINS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reference_image_ref=""
|
||||
reference_image_tag=""
|
||||
reference_release_commit=""
|
||||
reference_release_version=""
|
||||
|
||||
for row in "${verification_rows[@]}"; do
|
||||
IFS=$'\t' read -r domain actual_image_ref actual_image_tag actual_release_commit actual_release_version <<< "${row}"
|
||||
|
||||
if [[ "${actual_image_ref}" != "${EXPECTED_RELEASE_IMAGE_REF}" ]]; then
|
||||
echo "Release image mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_REF}, got ${actual_image_ref}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${actual_image_tag}" != "${EXPECTED_RELEASE_IMAGE_TAG}" ]]; then
|
||||
echo "Release tag mismatch for ${domain}: expected ${EXPECTED_RELEASE_IMAGE_TAG}, got ${actual_image_tag}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${actual_release_commit}" != "${EXPECTED_RELEASE_COMMIT}" ]]; then
|
||||
echo "Release commit mismatch for ${domain}: expected ${EXPECTED_RELEASE_COMMIT}, got ${actual_release_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${actual_release_version}" != "${EXPECTED_RELEASE_VERSION}" ]]; then
|
||||
echo "Release version mismatch for ${domain}: expected ${EXPECTED_RELEASE_VERSION}, got ${actual_release_version}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${reference_image_ref}" ]]; then
|
||||
reference_image_ref="${actual_image_ref}"
|
||||
reference_image_tag="${actual_image_tag}"
|
||||
reference_release_commit="${actual_release_commit}"
|
||||
reference_release_version="${actual_release_version}"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "${actual_image_ref}" != "${reference_image_ref}" || "${actual_image_tag}" != "${reference_image_tag}" || "${actual_release_commit}" != "${reference_release_commit}" || "${actual_release_version}" != "${reference_release_version}" ]]; then
|
||||
echo "Release metadata drift detected across served domains." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
@ -11,14 +11,24 @@ echo "======================================"
|
||||
echo "Starting prebuild process..."
|
||||
echo "======================================"
|
||||
|
||||
# Step 1: Generate local marketing content artifacts
|
||||
# Step 1: Sync documentation from service repositories
|
||||
echo ""
|
||||
echo "[1/2] Generating marketing content..."
|
||||
echo "[1/4] Syncing documentation content..."
|
||||
bash scripts/sync-doc-content.sh
|
||||
|
||||
# Step 2: Sync blog content
|
||||
echo ""
|
||||
echo "[2/4] Syncing blog content..."
|
||||
bash scripts/sync-blog-content.sh
|
||||
|
||||
# Step 3: Generate static content (homepage, products)
|
||||
echo ""
|
||||
echo "[3/4] Generating static content..."
|
||||
npx tsx scripts/generate-content.ts
|
||||
|
||||
# Step 2: Build contentlayer artifacts used by non-doc pages
|
||||
# Step 4: Build contentlayer
|
||||
echo ""
|
||||
echo "[2/2] Building contentlayer..."
|
||||
echo "[4/4] Building contentlayer..."
|
||||
node scripts/build-contentlayer.mjs
|
||||
|
||||
echo ""
|
||||
|
||||
@ -8,7 +8,7 @@ Usage:
|
||||
|
||||
Examples:
|
||||
# Remote install:
|
||||
# curl -fsSL "https://raw.githubusercontent.com/x-evor/<repo>/main/scripts/setup.sh?$(date +%s)" | bash -s -- <repo>
|
||||
# curl -fsSL "https://raw.githubusercontent.com/cloud-neutral-toolkit/<repo>/main/scripts/setup.sh?$(date +%s)" | bash -s -- <repo>
|
||||
#
|
||||
# Local:
|
||||
# bash scripts/setup.sh <repo>
|
||||
@ -50,7 +50,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ -z "${REPO_URL}" ]]; then
|
||||
REPO_URL="https://github.com/x-evor/${NAME}.git"
|
||||
REPO_URL="https://github.com/cloud-neutral-toolkit/${NAME}.git"
|
||||
fi
|
||||
|
||||
need_cmd git
|
||||
|
||||
@ -1,3 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "blog content sync has moved to docs.svc.plus; script retained as a no-op."
|
||||
|
||||
CONTENT_DIR="src/content/blogs"
|
||||
REPO_URL="https://github.com/cloud-neutral-workshop/knowledge.git"
|
||||
|
||||
# Ensure we're in the project root
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -d "${CONTENT_DIR}/.git" ]; then
|
||||
echo "Updating existing git repo in ${CONTENT_DIR}..."
|
||||
git -C "${CONTENT_DIR}" fetch --depth=1 origin main
|
||||
git -C "${CONTENT_DIR}" reset --hard origin/main
|
||||
git -C "${CONTENT_DIR}" clean -fdx
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Syncing content from ${REPO_URL} to ${CONTENT_DIR}..."
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
git clone --depth=1 "${REPO_URL}" "${TMP_DIR}/repo"
|
||||
|
||||
mkdir -p "${CONTENT_DIR}"
|
||||
|
||||
# Remove existing content but keep the directory
|
||||
# Find and delete all files and directories inside CONTENT_DIR, but ignore errors if empty
|
||||
find "${CONTENT_DIR}" -mindepth 1 -delete 2>/dev/null || true
|
||||
|
||||
# Copy content from repo to content dir, excluding .git
|
||||
tar -C "${TMP_DIR}/repo" --exclude='.git' -cf - . | tar -C "${CONTENT_DIR}" -xf -
|
||||
|
||||
echo "Content synced successfully."
|
||||
|
||||
@ -1,3 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "docs content sync has moved to docs.svc.plus; script retained as a no-op."
|
||||
|
||||
# Sync documentation from multiple service repositories into the application
|
||||
# This script pulls docs from each service repo and organizes them into src/content/doc/
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
DOCS_DIR="${REPO_ROOT}/src/content/doc"
|
||||
TMP_DIR=$(mktemp -d)
|
||||
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
echo "==> Syncing service documentation to ${DOCS_DIR}"
|
||||
|
||||
# Define service repositories and their target directories
|
||||
declare -A SERVICES=(
|
||||
["https://github.com/cloud-neutral-toolkit/console.svc.plus.git"]="01-console"
|
||||
["https://github.com/cloud-neutral-toolkit/accounts.svc.plus.git"]="02-accounts"
|
||||
["https://github.com/cloud-neutral-toolkit/rag-server.svc.plus.git"]="03-rag-server"
|
||||
["https://github.com/cloud-neutral-toolkit/postgresql.svc.plus.git"]="04-postgresql"
|
||||
)
|
||||
|
||||
# Ensure docs directory exists
|
||||
mkdir -p "${DOCS_DIR}"
|
||||
|
||||
# Clean up existing subdirectories (but keep index.md if we aren't regenerating it immediately, though we will)
|
||||
# Actually, let's clean everything except .git and maybe custom files if any
|
||||
find "${DOCS_DIR}" -mindepth 1 -maxdepth 1 -type d -not -name ".git" -exec rm -rf {} +
|
||||
|
||||
# Sync each service
|
||||
for repo_url in "${!SERVICES[@]}"; do
|
||||
target_dir="${SERVICES[$repo_url]}"
|
||||
service_name=$(basename "${repo_url}" .git)
|
||||
|
||||
echo ""
|
||||
echo "==> Processing ${service_name} -> docs/${target_dir}"
|
||||
|
||||
# Clone the repository
|
||||
clone_dir="${TMP_DIR}/${service_name}"
|
||||
echo " Cloning ${repo_url}..."
|
||||
git clone --depth=1 --single-branch --branch main "${repo_url}" "${clone_dir}" 2>/dev/null || {
|
||||
echo " Warning: Failed to clone ${repo_url}, skipping..."
|
||||
continue
|
||||
}
|
||||
|
||||
# Check if docs directory exists in the service repo
|
||||
if [ ! -d "${clone_dir}/docs" ]; then
|
||||
echo " Warning: No docs/ directory found in ${service_name}, skipping..."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create target directory
|
||||
target_path="${DOCS_DIR}/${target_dir}"
|
||||
mkdir -p "${target_path}"
|
||||
|
||||
# Copy documentation files
|
||||
echo " Copying documentation files..."
|
||||
cp -r "${clone_dir}/docs/"* "${target_path}/" 2>/dev/null || {
|
||||
echo " Warning: Failed to copy docs from ${service_name}"
|
||||
continue
|
||||
}
|
||||
|
||||
# Add collection metadata if index.md exists
|
||||
if [ -f "${target_path}/index.md" ]; then
|
||||
# Check if frontmatter already has collection field
|
||||
if ! grep -q "^collection:" "${target_path}/index.md"; then
|
||||
# Add collection metadata to frontmatter
|
||||
temp_file=$(mktemp)
|
||||
awk -v collection="${target_dir}" '
|
||||
BEGIN { in_frontmatter=0; added=0 }
|
||||
/^---$/ {
|
||||
in_frontmatter++
|
||||
print
|
||||
if (in_frontmatter == 1) {
|
||||
print "collection: " collection
|
||||
print "collectionLabel: " toupper(substr(collection, 4, 1)) substr(collection, 5)
|
||||
added=1
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "${target_path}/index.md" > "${temp_file}"
|
||||
mv "${temp_file}" "${target_path}/index.md"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " ✓ Successfully synced ${service_name}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "==> Generating src/content/doc/index.md..."
|
||||
|
||||
# Generate the main index.md file
|
||||
cat > "${DOCS_DIR}/index.md" << 'EOF'
|
||||
---
|
||||
title: Cloud-Neutral Toolkit Documentation
|
||||
description: Comprehensive documentation for all Cloud-Neutral Toolkit services
|
||||
---
|
||||
|
||||
# Cloud-Neutral Toolkit Documentation
|
||||
|
||||
Welcome to the **Cloud-Neutral Toolkit** documentation. This comprehensive guide covers all services in the toolkit, helping you build, deploy, and manage cloud-native applications across any vendor.
|
||||
|
||||
## 🚀 Services
|
||||
|
||||
EOF
|
||||
|
||||
# Add service sections dynamically
|
||||
declare -A SERVICE_TITLES=(
|
||||
["01-console"]="Console Service"
|
||||
["02-accounts"]="Accounts & Identity Service"
|
||||
["03-rag-server"]="RAG Server (AI/ML)"
|
||||
["04-postgresql"]="PostgreSQL Service"
|
||||
)
|
||||
|
||||
declare -A SERVICE_DESCRIPTIONS=(
|
||||
["01-console"]="The main dashboard and control plane for managing your cloud-neutral infrastructure."
|
||||
["02-accounts"]="Centralized authentication, authorization, and identity management with OIDC support."
|
||||
["03-rag-server"]="Retrieval-Augmented Generation service for AI-powered features and intelligent assistance."
|
||||
["04-postgresql"]="Managed PostgreSQL database service with cloud-neutral deployment options."
|
||||
)
|
||||
|
||||
for target_dir in $(echo "${SERVICES[@]}" | tr ' ' '\n' | sort); do
|
||||
if [ -d "${DOCS_DIR}/${target_dir}" ]; then
|
||||
service_title="${SERVICE_TITLES[$target_dir]}"
|
||||
service_desc="${SERVICE_DESCRIPTIONS[$target_dir]}"
|
||||
|
||||
cat >> "${DOCS_DIR}/index.md" << EOF
|
||||
### ${service_title}
|
||||
|
||||
${service_desc}
|
||||
|
||||
**[View ${service_title} Documentation →](/docs/${target_dir}/index)**
|
||||
|
||||
EOF
|
||||
fi
|
||||
done
|
||||
|
||||
# Add footer
|
||||
cat >> "${DOCS_DIR}/index.md" << 'EOF'
|
||||
|
||||
## 📚 Quick Links
|
||||
|
||||
- **[Getting Started](/docs/01-console/index)** - Begin with the Console Service
|
||||
- **[Architecture Overview](/docs/01-console/architecture)** - Understand the system design
|
||||
- **[API Reference](/docs/02-accounts/api)** - Explore the APIs
|
||||
|
||||
## 🔗 Resources
|
||||
|
||||
- [GitHub Organization](https://github.com/cloud-neutral-toolkit)
|
||||
- [Community Forum](https://github.com/orgs/cloud-neutral-toolkit/discussions)
|
||||
- [Issue Tracker](https://github.com/cloud-neutral-toolkit/console.svc.plus/issues)
|
||||
|
||||
---
|
||||
|
||||
*Last updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")*
|
||||
EOF
|
||||
|
||||
echo " ✓ Generated index.md"
|
||||
|
||||
echo ""
|
||||
echo "==> Documentation sync complete!"
|
||||
|
||||
@ -3,7 +3,7 @@ name: git-history-secret-remediation
|
||||
description: Use when a user asks to detect secrets in git commit history, clean tracked sensitive data, rewrite history with git-filter-repo, or verify cleanup with gitleaks. Covers gitleaks detect -v, replacement mapping, path removal, ref inventory, history rewrites, force-push planning, and post-cleanup coordination.
|
||||
license: Internal use only
|
||||
metadata:
|
||||
owner: x-evor
|
||||
owner: cloud-neutral-toolkit
|
||||
distribution: clawhub-compatible
|
||||
package-format: .skill
|
||||
---
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
---
|
||||
name: release-traceability
|
||||
description: Default reference for release-traceability work in this repository. Use when changing CI/CD, image tags, deploy contracts, /api/ping, validate, or any build-to-deploy-to-verify release path.
|
||||
---
|
||||
|
||||
# Release Traceability
|
||||
|
||||
## Default Rule
|
||||
|
||||
When working on release flow changes in this repository, treat this skill as the first reference.
|
||||
|
||||
## Scope
|
||||
|
||||
Use for:
|
||||
|
||||
- CI/CD orchestration
|
||||
- Image tag and image reference generation
|
||||
- Deploy contract changes
|
||||
- `/api/ping` release metadata
|
||||
- `validate` release verification
|
||||
|
||||
## Required Invariants
|
||||
|
||||
- Build output must carry a traceable commit-based version.
|
||||
- Deploy must consume the published image only.
|
||||
- Target hosts must not rebuild images.
|
||||
- `/api/ping` must expose the active release identity.
|
||||
- `validate` must compare build output, runtime output, and deployed image metadata.
|
||||
|
||||
## Working Rule
|
||||
|
||||
Prefer the simplest implementation that keeps the release chain auditable from commit to build artifact to runtime verification.
|
||||
@ -1,62 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { createUpstreamProxyHandler } from '@lib/apiProxy'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_PREFIX = '/api/account'
|
||||
|
||||
function createHandler() {
|
||||
const upstreamBaseUrl = getAccountServiceBaseUrl()
|
||||
return createUpstreamProxyHandler({
|
||||
upstreamBaseUrl,
|
||||
upstreamPathPrefix: ACCOUNT_PREFIX,
|
||||
getAdditionalHeaders: async (request) => {
|
||||
if (request.headers.get('authorization')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const session = await getAccountSession(request)
|
||||
if (!session.token) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
authorization: `Bearer ${session.token}`,
|
||||
'x-account-session': session.token,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handler = createHandler()
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function POST(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function PUT(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function PATCH(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function DELETE(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function HEAD(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
|
||||
export function OPTIONS(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { createUpstreamProxyHandler } from '@lib/apiProxy'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ADMIN_COLLECTOR_PREFIX = '/api/admin/collector'
|
||||
|
||||
const handler = createUpstreamProxyHandler({
|
||||
upstreamBaseUrl: getAccountServiceBaseUrl(),
|
||||
upstreamPathPrefix: ADMIN_COLLECTOR_PREFIX,
|
||||
getAdditionalHeaders: async (request) => {
|
||||
const session = await getAccountSession(request)
|
||||
if (!session.token) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
authorization: `Bearer ${session.token}`,
|
||||
'x-account-session': session.token,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
120
src/app/api/admin/sandbox/[...segments]/route.ts
Normal file
120
src/app/api/admin/sandbox/[...segments]/route.ts
Normal file
@ -0,0 +1,120 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
import {
|
||||
getAccountSession,
|
||||
userHasPermission,
|
||||
userHasRole,
|
||||
userHasRoleOrPermission,
|
||||
} from "@server/account/session";
|
||||
import type { AccountUserRole } from "@server/account/session";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
|
||||
const WRITE_ROLES: AccountUserRole[] = ["admin"];
|
||||
const READ_PERMISSIONS = ["admin.settings.read"];
|
||||
const WRITE_PERMISSIONS = ["admin.settings.write"];
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
async function proxyRequest(request: NextRequest) {
|
||||
const session = await getAccountSession(request);
|
||||
const user = session.user;
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "unauthenticated" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { pathname, search } = new URL(request.url);
|
||||
// Map /api/admin/sandbox/... to backend /admin/sandbox/...
|
||||
const segments = pathname.replace(/^\/api\/admin\/sandbox/, "");
|
||||
const targetUrl = `${ACCOUNT_API_BASE}/admin/sandbox${segments}${search}`;
|
||||
|
||||
const method = request.method;
|
||||
const isWrite = method !== "GET" && method !== "HEAD";
|
||||
|
||||
if (isWrite) {
|
||||
if (
|
||||
!(
|
||||
(await userHasRole(user, WRITE_ROLES)) ||
|
||||
(await userHasPermission(user, WRITE_PERMISSIONS))
|
||||
)
|
||||
) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
|
||||
let body: string | undefined;
|
||||
if (isWrite) {
|
||||
body = await request.text();
|
||||
const contentType =
|
||||
request.headers.get("content-type") ?? "application/json";
|
||||
headers.set("Content-Type", contentType);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "invalid_response" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: err.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyRequest(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return proxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return proxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return proxyRequest(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return proxyRequest(request);
|
||||
}
|
||||
71
src/app/api/admin/sandbox/bind/route.ts
Normal file
71
src/app/api/admin/sandbox/bind/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const WRITE_PERMISSIONS = ['admin.settings.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
})
|
||||
|
||||
const body = await request.text()
|
||||
const contentType = request.headers.get('content-type') ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy sandbox bind', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
64
src/app/api/admin/sandbox/binding/route.ts
Normal file
64
src/app/api/admin/sandbox/binding/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const READ_PERMISSIONS = ['admin.settings.read']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: READ_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy sandbox binding', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { createUpstreamProxyHandler } from '@lib/apiProxy'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ADMIN_SCHEDULER_PREFIX = '/api/admin/scheduler'
|
||||
|
||||
const handler = createUpstreamProxyHandler({
|
||||
upstreamBaseUrl: getAccountServiceBaseUrl(),
|
||||
upstreamPathPrefix: ADMIN_SCHEDULER_PREFIX,
|
||||
getAdditionalHeaders: async (request) => {
|
||||
const session = await getAccountSession(request)
|
||||
if (!session.token) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
authorization: `Bearer ${session.token}`,
|
||||
'x-account-session': session.token,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import { createUpstreamProxyHandler } from '@lib/apiProxy'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import { getAccountServiceBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ADMIN_TRAFFIC_PREFIX = '/api/admin/traffic'
|
||||
|
||||
const handler = createUpstreamProxyHandler({
|
||||
upstreamBaseUrl: getAccountServiceBaseUrl(),
|
||||
upstreamPathPrefix: ADMIN_TRAFFIC_PREFIX,
|
||||
getAdditionalHeaders: async (request) => {
|
||||
const session = await getAccountSession(request)
|
||||
if (!session.token) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
authorization: `Bearer ${session.token}`,
|
||||
'x-account-session': session.token,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
return handler(request)
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe("/api/auth/login", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.ACCOUNT_SERVICE_URL;
|
||||
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
it("fails fast when account service points back to console", async () => {
|
||||
process.env.ACCOUNT_SERVICE_URL = "https://console.svc.plus";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const request = new NextRequest("https://console.svc.plus/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
host: "console.svc.plus",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "admin@svc.plus",
|
||||
password: "Secret123!",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: "account_service_misconfigured",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves upstream 404 responses", async () => {
|
||||
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "user_not_found" }), {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const request = new NextRequest("https://console.svc.plus/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
host: "console.svc.plus",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "admin@svc.plus",
|
||||
password: "Secret123!",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: "user_not_found",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://accounts.svc.plus/api/auth/login",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -9,10 +9,7 @@ import {
|
||||
deriveMaxAgeFromExpires,
|
||||
MFA_COOKIE_NAME,
|
||||
} from "@lib/authGateway";
|
||||
import {
|
||||
getAccountServiceApiBaseUrl,
|
||||
isSelfReferentialServiceTarget,
|
||||
} from "@server/serviceConfig";
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
|
||||
@ -69,22 +66,6 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isSelfReferentialServiceTarget(
|
||||
ACCOUNT_API_BASE,
|
||||
request.headers.get("host"),
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "account_service_misconfigured",
|
||||
needMfa: false,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const loginBody: Record<string, string> = { email, password };
|
||||
if (totpCode) {
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const cookiesMock = vi.hoisted(() => vi.fn());
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: cookiesMock,
|
||||
}));
|
||||
|
||||
describe("/api/auth/mfa/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
cookiesMock.mockReset();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.ACCOUNT_SERVICE_URL;
|
||||
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
it("fails fast when account service points back to console", async () => {
|
||||
process.env.ACCOUNT_SERVICE_URL = "https://console.svc.plus";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const request = new NextRequest(
|
||||
"https://console.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
|
||||
{
|
||||
headers: {
|
||||
host: "console.svc.plus",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: "account_service_misconfigured",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards identifier and session token to accounts", async () => {
|
||||
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
|
||||
cookiesMock.mockResolvedValue({
|
||||
get(name: string) {
|
||||
if (name === "xc_session") {
|
||||
return { value: "session-token" };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ mfa: { totpEnabled: true } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const request = new NextRequest(
|
||||
"https://console.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
|
||||
{
|
||||
headers: {
|
||||
host: "console.svc.plus",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const response = await GET(request);
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
mfa: { totpEnabled: true },
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://accounts.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(init.headers).toMatchObject({
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -2,23 +2,11 @@ import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
|
||||
import {
|
||||
getAccountServiceApiBaseUrl,
|
||||
isSelfReferentialServiceTarget,
|
||||
} from '@server/serviceConfig'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (isSelfReferentialServiceTarget(ACCOUNT_API_BASE, request.headers.get('host'))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'account_service_misconfigured',
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
|
||||
const storedMfaToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const cookiesMock = vi.hoisted(() => vi.fn());
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: cookiesMock,
|
||||
}));
|
||||
|
||||
describe("/api/auth/session", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
cookiesMock.mockReset();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
it("drops guest sessions instead of exposing them as authenticated users", async () => {
|
||||
cookiesMock.mockResolvedValue({
|
||||
get(name: string) {
|
||||
if (name === "xc_session") {
|
||||
return { value: "guest-session-token" };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
user: {
|
||||
id: "guest-1",
|
||||
email: "guest@svc.plus",
|
||||
role: "guest",
|
||||
username: "guest",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const request = new NextRequest("https://console.svc.plus/api/auth/session", {
|
||||
headers: {
|
||||
host: "console.svc.plus",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ user: null });
|
||||
});
|
||||
});
|
||||
@ -2,14 +2,23 @@ import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
|
||||
import { resolvePublicUserEmail } from "@lib/publicUserIdentity";
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
import {
|
||||
getAccountServiceApiBaseUrl,
|
||||
getAccountServiceBaseUrl,
|
||||
} from "@server/serviceConfig";
|
||||
import {
|
||||
buildInternalServiceHeaders,
|
||||
isServiceTokenConfigured,
|
||||
} from "@server/internalServiceAuth";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
const ACCOUNT_BASE = getAccountServiceBaseUrl();
|
||||
|
||||
type AccountUser = {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
proxyUuid?: string;
|
||||
proxyUuidExpiresAt?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email: string;
|
||||
@ -39,15 +48,20 @@ type SessionResponse = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type AuthenticatedRole = "user" | "operator" | "admin";
|
||||
type SandboxGuestResponse = {
|
||||
email?: string;
|
||||
proxyUuid?: string;
|
||||
proxyUuidExpiresAt?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function normalizeRole(role: unknown): AuthenticatedRole | null {
|
||||
function normalizeRole(role: unknown): string {
|
||||
if (typeof role !== "string") {
|
||||
return null;
|
||||
return "user";
|
||||
}
|
||||
const normalized = role.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
return "user";
|
||||
}
|
||||
if (normalized === "root" || normalized === "super_admin") {
|
||||
return "admin";
|
||||
@ -55,14 +69,7 @@ function normalizeRole(role: unknown): AuthenticatedRole | null {
|
||||
if (normalized === "readonly" || normalized === "read_only") {
|
||||
return "user";
|
||||
}
|
||||
if (
|
||||
normalized === "user" ||
|
||||
normalized === "operator" ||
|
||||
normalized === "admin"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function fetchSession(token: string, requestHost?: string | null) {
|
||||
@ -87,10 +94,69 @@ async function fetchSession(token: string, requestHost?: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSandboxGuest(): Promise<AccountUser | null> {
|
||||
if (!isServiceTokenConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_BASE}/api/internal/sandbox/guest`, {
|
||||
method: "GET",
|
||||
headers: buildInternalServiceHeaders({
|
||||
Accept: "application/json",
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = (await response
|
||||
.json()
|
||||
.catch(() => null)) as SandboxGuestResponse | null;
|
||||
const proxyUuid =
|
||||
typeof payload?.proxyUuid === "string" ? payload.proxyUuid.trim() : "";
|
||||
if (!proxyUuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proxyUuidExpiresAt =
|
||||
typeof payload?.proxyUuidExpiresAt === "string" &&
|
||||
payload.proxyUuidExpiresAt.trim().length > 0
|
||||
? payload.proxyUuidExpiresAt.trim()
|
||||
: undefined;
|
||||
|
||||
// Shape this as a pseudo-session user for the Guest/Demo experience.
|
||||
return {
|
||||
id: proxyUuid,
|
||||
uuid: proxyUuid,
|
||||
proxyUuid,
|
||||
proxyUuidExpiresAt,
|
||||
name: "Guest user",
|
||||
username: "guest",
|
||||
email: "sandbox@svc.plus",
|
||||
role: "guest",
|
||||
groups: ["guest", "sandbox"],
|
||||
permissions: ["read"],
|
||||
readOnly: true,
|
||||
tenantId: "guest-sandbox",
|
||||
tenants: [{ id: "guest-sandbox", name: "Guest Sandbox", role: "guest" }],
|
||||
mfaEnabled: false,
|
||||
mfaPending: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Sandbox guest session proxy failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
void request;
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ user: null });
|
||||
const sandboxGuest = await fetchSandboxGuest();
|
||||
return NextResponse.json({ user: sandboxGuest });
|
||||
}
|
||||
|
||||
const requestHost = request.headers.get("host");
|
||||
@ -120,11 +186,6 @@ export async function GET(request: NextRequest) {
|
||||
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled;
|
||||
|
||||
const normalizedRole = normalizeRole(rawUser.role);
|
||||
if (!normalizedRole) {
|
||||
const response = NextResponse.json({ user: null });
|
||||
clearSessionCookie(response, requestHost ?? undefined);
|
||||
return response;
|
||||
}
|
||||
const rawRole =
|
||||
typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : "";
|
||||
const normalizedGroups = Array.isArray(rawUser.groups)
|
||||
@ -154,7 +215,19 @@ export async function GET(request: NextRequest) {
|
||||
Boolean(rawUser.readOnly) ||
|
||||
normalizedGroups.some((group) => group.toLowerCase() === "readonly role") ||
|
||||
rawRole === "readonly" ||
|
||||
rawRole === "read_only";
|
||||
rawRole === "read_only" ||
|
||||
String(rawUser.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "sandbox@svc.plus";
|
||||
const normalizedProxyUuid =
|
||||
typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0
|
||||
? rawUser.proxyUuid.trim()
|
||||
: undefined;
|
||||
const normalizedProxyUuidExpiresAt =
|
||||
typeof rawUser.proxyUuidExpiresAt === "string" &&
|
||||
rawUser.proxyUuidExpiresAt.trim().length > 0
|
||||
? rawUser.proxyUuidExpiresAt.trim()
|
||||
: undefined;
|
||||
|
||||
const normalizedTenantId =
|
||||
typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0
|
||||
@ -216,15 +289,10 @@ export async function GET(request: NextRequest) {
|
||||
const normalizedUser = identifier
|
||||
? { ...rawUser, id: identifier, uuid: identifier }
|
||||
: rawUser;
|
||||
const publicEmail = resolvePublicUserEmail({
|
||||
email: normalizedUser.email,
|
||||
role: normalizedRole,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
...normalizedUser,
|
||||
email: publicEmail,
|
||||
mfaEnabled: derivedMfaEnabled,
|
||||
mfaPending: derivedMfaPending,
|
||||
mfa: normalizedMfa,
|
||||
@ -232,6 +300,8 @@ export async function GET(request: NextRequest) {
|
||||
groups: normalizedGroups,
|
||||
permissions: normalizedPermissions,
|
||||
readOnly: normalizedReadOnly,
|
||||
proxyUuid: normalizedProxyUuid,
|
||||
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
|
||||
tenantId: normalizedTenantId,
|
||||
tenants: normalizedTenants,
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getLatestBlogPosts } from "@/lib/docsServiceClient";
|
||||
import { getBlogPosts } from "@/lib/blogContent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -24,7 +24,7 @@ export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseLimit(searchParams.get("limit"));
|
||||
|
||||
const posts = await getLatestBlogPosts(limit);
|
||||
const posts = await getBlogPosts();
|
||||
const latestPosts = posts.slice(0, limit).map((post) => ({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
|
||||
@ -2,43 +2,9 @@ import { NextResponse } from 'next/server'
|
||||
|
||||
import { loadRuntimeConfig } from '@server/runtime-loader'
|
||||
|
||||
type TReleaseImageMetadata = {
|
||||
releaseImageRef: string | null
|
||||
releaseImageTag: string | null
|
||||
releaseCommit: string | null
|
||||
}
|
||||
|
||||
function resolveReleaseCommit(releaseImageTag: string | null): string | null {
|
||||
if (!releaseImageTag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTag = releaseImageTag.trim()
|
||||
const prefixedShaMatch = normalizedTag.match(/^sha-([0-9a-f]{7,40})$/i)
|
||||
if (prefixedShaMatch) {
|
||||
return prefixedShaMatch[1] ?? null
|
||||
}
|
||||
|
||||
return /^[0-9a-f]{7,40}$/i.test(normalizedTag) ? normalizedTag : null
|
||||
}
|
||||
|
||||
function resolveReleaseImageMetadata(frontendImage: string | undefined): TReleaseImageMetadata {
|
||||
const releaseImageRef = frontendImage?.trim() || null
|
||||
const releaseImageTagMatch = releaseImageRef?.match(/:([^:@]+)$/)
|
||||
const releaseImageTag = releaseImageTagMatch?.[1] ?? null
|
||||
const releaseCommit = resolveReleaseCommit(releaseImageTag)
|
||||
|
||||
return {
|
||||
releaseImageRef,
|
||||
releaseImageTag,
|
||||
releaseCommit,
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const hostnameHeader = request.headers.get('host') ?? undefined
|
||||
const runtimeConfig = loadRuntimeConfig({ hostname: hostnameHeader })
|
||||
const releaseMetadata = resolveReleaseImageMetadata(process.env.FRONTEND_IMAGE)
|
||||
|
||||
const payload = {
|
||||
status: 'ok' as const,
|
||||
@ -48,7 +14,6 @@ export async function GET(request: Request) {
|
||||
authUrl: runtimeConfig.authUrl,
|
||||
dashboardUrl: runtimeConfig.dashboardUrl,
|
||||
logLevel: runtimeConfig.logLevel,
|
||||
...releaseMetadata,
|
||||
}
|
||||
|
||||
console.info('[runtime-config] /api/ping resolved config snippet', payload)
|
||||
|
||||
97
src/app/api/sandbox/assume/revert/route.ts
Normal file
97
src/app/api/sandbox/assume/revert/route.ts
Normal file
@ -0,0 +1,97 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { applySessionCookie } from "@lib/authGateway";
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
|
||||
const ROOT_BACKUP_COOKIE = "xc_session_root";
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
function secureCookies(): boolean {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return true;
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
|
||||
return baseUrl.toLowerCase().startsWith("https://");
|
||||
}
|
||||
|
||||
async function verifyRootToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${ACCOUNT_API_BASE}/session`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return false;
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as any;
|
||||
const email =
|
||||
typeof payload?.user?.email === "string"
|
||||
? payload.user.email.trim().toLowerCase()
|
||||
: "";
|
||||
return email === "admin@svc.plus";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const rootToken =
|
||||
request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? "";
|
||||
if (!rootToken) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "not_assuming" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyRootToken(rootToken))) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "root_token_invalid" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.)
|
||||
try {
|
||||
await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${rootToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to audit assume revert", error);
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
applySessionCookie(
|
||||
response,
|
||||
rootToken,
|
||||
undefined,
|
||||
request.headers.get("host") ?? undefined,
|
||||
);
|
||||
response.cookies.set({
|
||||
name: ROOT_BACKUP_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
secure: secureCookies(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
119
src/app/api/sandbox/assume/route.ts
Normal file
119
src/app/api/sandbox/assume/route.ts
Normal file
@ -0,0 +1,119 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
|
||||
import { evaluateAccountAdminAccess } from "@server/account/adminAccess";
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
import { getAccountSession } from "@server/account/session";
|
||||
import type { AccountUserRole } from "@server/account/session";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ["admin"];
|
||||
const WRITE_PERMISSIONS = ["admin.settings.write"];
|
||||
|
||||
const ROOT_BACKUP_COOKIE = "xc_session_root";
|
||||
const SANDBOX_EMAIL = "sandbox@svc.plus";
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
function secureCookies(): boolean {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return true;
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
|
||||
return baseUrl.toLowerCase().startsWith("https://");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAccountSession(request);
|
||||
const user = session.user;
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "unauthenticated" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
});
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: access.reason ?? "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: SANDBOX_EMAIL }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const contentType = upstream.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
const text = await upstream.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "upstream_non_json",
|
||||
upstreamStatus: upstream.status,
|
||||
upstreamBody: text.slice(0, 2048),
|
||||
} as any,
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await upstream.json().catch(() => null)) as any;
|
||||
if (!payload || typeof payload.token !== "string") {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "invalid_response" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL });
|
||||
|
||||
// Backup current root session token only if it's NOT already an assumed session.
|
||||
// Check if the current user is NOT the sandbox user.
|
||||
if (user.email.toLowerCase() !== SANDBOX_EMAIL) {
|
||||
response.cookies.set({
|
||||
name: ROOT_BACKUP_COOKIE,
|
||||
value: session.token,
|
||||
httpOnly: true,
|
||||
secure: secureCookies(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
// Switch main session to sandbox token.
|
||||
applySessionCookie(
|
||||
response,
|
||||
payload.token,
|
||||
deriveMaxAgeFromExpires(payload.expiresAt),
|
||||
request.headers.get("host") ?? undefined,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to assume sandbox", error);
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "upstream_unreachable" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/app/api/sandbox/assume/status/route.ts
Normal file
11
src/app/api/sandbox/assume/status/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const ROOT_BACKUP_COOKIE = 'xc_session_root'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const isAssuming = Boolean(request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim())
|
||||
return NextResponse.json({ isAssuming, target: isAssuming ? 'sandbox@svc.plus' : '' })
|
||||
}
|
||||
|
||||
56
src/app/api/sandbox/binding/route.ts
Normal file
56
src/app/api/sandbox/binding/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import { buildInternalServiceHeaders, isServiceTokenConfigured } from '@server/internalServiceAuth'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const canUseInternalToken = isServiceTokenConfigured()
|
||||
if (!session.token && !canUseInternalToken) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = session.token
|
||||
? new Headers({
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
})
|
||||
: buildInternalServiceHeaders({
|
||||
Accept: 'application/json',
|
||||
})
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy sandbox binding (public)', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DEFAULT_BRIDGE_SERVER_URL = "https://xworkmate-bridge.svc.plus";
|
||||
|
||||
function bridgeServerUrl(): string {
|
||||
return (
|
||||
process.env.BRIDGE_SERVER_URL?.trim().replace(/\/+$/, "") ||
|
||||
DEFAULT_BRIDGE_SERVER_URL
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeAuthToken(): string {
|
||||
return process.env.BRIDGE_AUTH_TOKEN?.trim() ?? "";
|
||||
}
|
||||
|
||||
function jsonError(message: string, status = 500): Response {
|
||||
return Response.json({ error: { message } }, { status });
|
||||
}
|
||||
|
||||
function bridgeHeaders(): HeadersInit {
|
||||
const token = bridgeAuthToken();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const action = request.nextUrl.searchParams.get("action") ?? "ping";
|
||||
if (action !== "ping") {
|
||||
return jsonError("Unsupported xworkmate bridge action.", 400);
|
||||
}
|
||||
|
||||
const response = await fetch(`${bridgeServerUrl()}/api/ping`, {
|
||||
cache: "no-store",
|
||||
headers: bridgeHeaders(),
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid JSON body.", 400);
|
||||
}
|
||||
|
||||
const response = await fetch(`${bridgeServerUrl()}/acp/rpc`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: bridgeHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -6,7 +6,8 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import BrandCTA from "@components/BrandCTA";
|
||||
import { PublicPageShell } from "@/components/public/PublicPageShell";
|
||||
import { getBlogPost } from "@lib/docsServiceClient";
|
||||
import { getBlogPostBySlug } from "@lib/blogContent";
|
||||
import { renderMarkdownContent } from "@server/render-markdown";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string | string[] }>;
|
||||
@ -37,12 +38,7 @@ export async function generateMetadata({
|
||||
const slugPath = Array.isArray(slugParam.slug)
|
||||
? slugParam.slug.join("/")
|
||||
: slugParam.slug;
|
||||
let post;
|
||||
try {
|
||||
post = await getBlogPost(slugPath);
|
||||
} catch {
|
||||
post = undefined;
|
||||
}
|
||||
const post = await getBlogPostBySlug(slugPath);
|
||||
|
||||
if (!post) {
|
||||
return { title: "Blog Post | Cloud-Neutral" };
|
||||
@ -59,19 +55,15 @@ export default async function BlogPostPage({ params }: PageProps) {
|
||||
const slugPath = Array.isArray(slugParam.slug)
|
||||
? slugParam.slug.join("/")
|
||||
: slugParam.slug;
|
||||
let post;
|
||||
try {
|
||||
post = await getBlogPost(slugPath);
|
||||
} catch {
|
||||
post = undefined;
|
||||
}
|
||||
const post = await getBlogPostBySlug(slugPath);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const html = renderMarkdownContent(post.content);
|
||||
const language: "zh" | "en" = /[\u4e00-\u9fff]/.test(
|
||||
`${post.title} ${post.excerpt} ${post.plaintext ?? ""}`,
|
||||
`${post.title} ${post.content}`,
|
||||
)
|
||||
? "zh"
|
||||
: "en";
|
||||
@ -131,7 +123,7 @@ export default async function BlogPostPage({ params }: PageProps) {
|
||||
<section className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:p-8">
|
||||
<article
|
||||
className="public-doc-prose"
|
||||
dangerouslySetInnerHTML={{ __html: post.html }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = "error";
|
||||
export const revalidate = false;
|
||||
|
||||
import type { Metadata } from "next";
|
||||
@ -6,7 +6,8 @@ import { Suspense } from "react";
|
||||
|
||||
import BlogList from "@components/blog/BlogList";
|
||||
import { PublicPageShell } from "@/components/public/PublicPageShell";
|
||||
import { getBlogList } from "@lib/docsServiceClient";
|
||||
import type { BlogCategory, BlogPostSummary } from "@lib/blogContent";
|
||||
import { getBlogCategories, getBlogPosts } from "@lib/blogContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog | Cloud-Neutral",
|
||||
@ -15,16 +16,10 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const listing = await getBlogList({ page: 1, pageSize: 200 });
|
||||
const categories = listing.categories;
|
||||
const postsWithoutContent = listing.posts.map(
|
||||
({
|
||||
html: _html,
|
||||
plaintext: _plaintext,
|
||||
sourcePath: _sourcePath,
|
||||
language: _language,
|
||||
...post
|
||||
}) => post,
|
||||
const posts = await getBlogPosts();
|
||||
const categories: BlogCategory[] = await getBlogCategories();
|
||||
const postsWithoutContent: BlogPostSummary[] = posts.map(
|
||||
({ content: _content, ...post }) => post,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const dynamic = "error";
|
||||
export const revalidate = false;
|
||||
|
||||
import type { Metadata } from "next";
|
||||
@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import DocArticle from "@/components/doc/DocArticle";
|
||||
import DocMetaPanel from "@/components/doc/DocMetaPanel";
|
||||
import { PublicPageIntro } from "@/components/public/PublicPageShell";
|
||||
import { isFeatureEnabled } from "@lib/featureToggles";
|
||||
@ -41,6 +42,14 @@ function DocsBreadcrumbs({
|
||||
);
|
||||
}
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
if (!isFeatureEnabled("appModules", "/docs")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getDocVersionParams();
|
||||
};
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export async function generateMetadata({
|
||||
@ -100,10 +109,7 @@ export default async function DocVersionPage({
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1rem] border border-slate-900/8 bg-white/90 p-5 shadow-[var(--shadow-soft)] lg:p-6">
|
||||
<article
|
||||
className="public-doc-prose"
|
||||
dangerouslySetInnerHTML={{ __html: version.html }}
|
||||
/>
|
||||
<DocArticle content={version.content} />
|
||||
</section>
|
||||
|
||||
<Feedback />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user