diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml new file mode 100644 index 0000000..5a4776b --- /dev/null +++ b/.github/workflows/deploy-main.yml @@ -0,0 +1,58 @@ +name: Validate and deploy frontend + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: project2-frontend-production + cancel-in-progress: false + +jobs: + validate-and-deploy: + name: Validate and deploy main + if: github.ref == 'refs/heads/main' + runs-on: [self-hosted, linux, x64, t07-project-2] + timeout-minutes: 20 + + defaults: + run: + working-directory: client + + steps: + - name: Check out main commit + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Run Oxlint + run: npm run lint + + - name: Run tests + run: npm run test + + - name: Build frontend + run: npm run build + + - name: Deploy verified main + working-directory: . + run: sudo -n /usr/bin/systemctl start t07-project2-deploy.service + + - name: Verify deployed commit + working-directory: . + run: test "$(cat /var/www/t07-project2/current/.commit)" = "$GITHUB_SHA" diff --git a/.github/workflows/frontend-quality.yml b/.github/workflows/frontend-quality.yml new file mode 100644 index 0000000..092a3b1 --- /dev/null +++ b/.github/workflows/frontend-quality.yml @@ -0,0 +1,45 @@ +name: Frontend quality gate + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + quality-checks: + name: Frontend checks + runs-on: [self-hosted, linux, x64, t07-project-2] + timeout-minutes: 15 + + defaults: + run: + working-directory: client + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Run Oxlint + run: npm run lint + + - name: Run tests + run: npm run test + + - name: Build frontend + run: npm run build diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..60bb1e6 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26.8.1 diff --git a/README.md b/README.md index f7a0a90..a588ca1 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,17 @@ Backend ligger i `server/` og bruker Node.js, TypeScript, GraphQL og PostgreSQL. Se [server/README.md](server/README.md) for npm-oppsett, VM-konfigurasjon, lokal testing, databasebruker og migrasjoner. PostgreSQL og den planlagte backendtjenesten kjører direkte på VM-en; Docker brukes ikke. + +## Frontend-deploy + +Etter at en Pull Request er godkjent og merget til `main`, kjører GitHub +Actions installasjon, Prettier, Oxlint, Vitest og Vite-build på nytt. Bare en +vellykket kjøring kan be den avgrensede deploytjenesten på T07-VM-en om å +publisere den verifiserte frontenden under: + + + +Deployen bruker separate release-mapper og bytter aktiv release atomisk. +Forrige fungerende release beholdes for kontrollert rollback. Prosjekt 2 har +egen runner, deploybruker og deploynøkkel og deler ikke skriverettigheter med +Prosjekt 1. Se [ops/project2/README.md](ops/project2/README.md) for detaljer. diff --git a/client/index.html b/client/index.html index ea3d200..3707b14 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + Tapsailor diff --git a/ops/project2/README.md b/ops/project2/README.md new file mode 100644 index 0000000..c5ecbf2 --- /dev/null +++ b/ops/project2/README.md @@ -0,0 +1,47 @@ +# Project 2 frontend deployment + +The frontend is deployed only after a push to `main`. GitHub Actions repeats +dependency installation, formatting, linting, tests and the Vite production +build before it requests deployment. + +The repository-scoped runner uses the dedicated, non-login `t07p2runner` +account. It cannot write to the production directories or read the deploy key. +Its only elevated permission is starting the fixed +`t07-project2-deploy.service`, which accepts no arguments. + +The root-owned service runs as the separate `t07p2deploy` account. It fetches +`origin/main` through a repository-scoped, read-only deploy key, repeats the +quality checks and build in a temporary directory, and publishes only a +completed build. + +Server-side files: + +- `/etc/t07-project2/deploy_key`: repository-scoped, read-only SSH deploy key +- `/etc/t07-project2/known_hosts`: pinned SSH host key for `git.ntnu.no` +- `/var/lib/t07-project2-runner/actions-runner`: private CI runner installation +- `/var/lib/t07-project2/repository.git`: trusted repository mirror +- `/var/www/t07-project2/releases/`: completed releases +- `/var/www/t07-project2/current`: active release +- `/var/www/t07-project2/previous`: previous working release +- `/var/www/html/project2`: stable Apache symlink to `current` + +The active symlink changes only after all checks and the build succeed. A +failed build therefore leaves the current site untouched. The installed +scripts, systemd unit and sudo rule are root-owned copies, so a pull request +cannot change the active deploy mechanism. + +## Rollback + +An administrator with VM access can swap the active and previous releases: + +```bash +sudo /usr/local/sbin/t07-project2-rollback +``` + +The rollback command is deliberately unavailable to the CI runner. + +## Boundaries + +This setup deploys only the static frontend under `/project2`. GraphQL, +PostgreSQL, migrations, backend secrets and the future backend service are not +part of this deployment. diff --git a/ops/project2/actions.runner._services.t07-project-2-vm.service b/ops/project2/actions.runner._services.t07-project-2-vm.service new file mode 100644 index 0000000..7730165 --- /dev/null +++ b/ops/project2/actions.runner._services.t07-project-2-vm.service @@ -0,0 +1,20 @@ +[Unit] +Description=GitHub Actions Runner (_services.t07-project-2-vm) +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/var/lib/t07-project2-runner/actions-runner/bin/runsvc.sh +User=t07p2runner +Group=t07p2runner +WorkingDirectory=/var/lib/t07-project2-runner/actions-runner +KillMode=process +KillSignal=SIGTERM +TimeoutStopSec=5min +UMask=0077 +PrivateTmp=true +ProtectHome=true +ProtectSystem=full + +[Install] +WantedBy=multi-user.target diff --git a/ops/project2/deploy.sh b/ops/project2/deploy.sh new file mode 100755 index 0000000..cd4a470 --- /dev/null +++ b/ops/project2/deploy.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly STATE_ROOT='/var/lib/t07-project2' +readonly MIRROR_DIR="${STATE_ROOT}/repository.git" +readonly BUILD_ROOT="${STATE_ROOT}/builds" +readonly REMOTE_URL='git@git.ntnu.no:IT2810-H26/T07-Project-2.git' +readonly WEB_ROOT='/var/www/t07-project2' +readonly RELEASES_DIR="${WEB_ROOT}/releases" +readonly CURRENT_LINK="${WEB_ROOT}/current" +readonly PREVIOUS_LINK="${WEB_ROOT}/previous" +readonly NODE_ROOT='/opt/node-v26.8.1' + +exec 9>"${STATE_ROOT}/deploy.lock" +/usr/bin/flock -n 9 || { + echo 'A project2 deployment is already running.' >&2 + exit 1 +} + +if [[ ! -d "${MIRROR_DIR}" ]]; then + /usr/bin/git clone --quiet --mirror "${REMOTE_URL}" "${MIRROR_DIR}" +fi + +/usr/bin/git --git-dir="${MIRROR_DIR}" fetch --quiet --prune origin \ + '+refs/heads/main:refs/remotes/origin/main' + +commit_sha=$(/usr/bin/git --git-dir="${MIRROR_DIR}" rev-parse \ + --verify 'refs/remotes/origin/main^{commit}') + +if [[ ! "${commit_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Could not resolve a valid origin/main commit.' >&2 + exit 1 +fi + +release_dir="${RELEASES_DIR}/${commit_sha}" + +if [[ ! -f "${release_dir}/.release-complete" ]]; then + build_dir=$(/usr/bin/mktemp -d "${BUILD_ROOT}/build.XXXXXXXX") + release_stage="${RELEASES_DIR}/.staging-${commit_sha}-$$" + + cleanup() { + /usr/bin/rm -rf -- "${build_dir}" "${release_stage}" + } + trap cleanup EXIT + + deploy_index="${build_dir}/.deploy-index" + GIT_INDEX_FILE="${deploy_index}" \ + /usr/bin/git --git-dir="${MIRROR_DIR}" --work-tree="${build_dir}" \ + read-tree "${commit_sha}" + GIT_INDEX_FILE="${deploy_index}" \ + /usr/bin/git --git-dir="${MIRROR_DIR}" --work-tree="${build_dir}" \ + checkout-index --all --force + /usr/bin/rm -f -- "${deploy_index}" + + cd "${build_dir}/client" + "${NODE_ROOT}/bin/npm" ci + "${NODE_ROOT}/bin/npm" run format:check + "${NODE_ROOT}/bin/npm" run lint + "${NODE_ROOT}/bin/npm" run test + "${NODE_ROOT}/bin/npm" run build + + /usr/bin/mkdir "${release_stage}" + /usr/bin/cp -a dist/. "${release_stage}/" + /usr/bin/printf '%s\n' "${commit_sha}" >"${release_stage}/.commit" + /usr/bin/touch "${release_stage}/.release-complete" + /usr/bin/mv "${release_stage}" "${release_dir}" +fi + +old_release=$(/usr/bin/readlink -f "${CURRENT_LINK}" 2>/dev/null || true) + +if [[ -n "${old_release}" && "${old_release}" != "${release_dir}" ]]; then + previous_stage="${WEB_ROOT}/.previous-$$" + /usr/bin/ln -s "releases/${old_release##*/}" "${previous_stage}" + /usr/bin/mv -Tf "${previous_stage}" "${PREVIOUS_LINK}" +fi + +current_stage="${WEB_ROOT}/.current-$$" +/usr/bin/ln -s "releases/${commit_sha}" "${current_stage}" +/usr/bin/mv -Tf "${current_stage}" "${CURRENT_LINK}" + +echo "Deployed origin/main at ${commit_sha}." diff --git a/ops/project2/rollback.sh b/ops/project2/rollback.sh new file mode 100755 index 0000000..b85f5f2 --- /dev/null +++ b/ops/project2/rollback.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly STATE_ROOT='/var/lib/t07-project2' +readonly WEB_ROOT='/var/www/t07-project2' +readonly RELEASES_DIR="${WEB_ROOT}/releases" +readonly CURRENT_LINK="${WEB_ROOT}/current" +readonly PREVIOUS_LINK="${WEB_ROOT}/previous" + +exec 9>"${STATE_ROOT}/deploy.lock" +/usr/bin/flock -n 9 || { + echo 'A project2 deployment or rollback is already running.' >&2 + exit 1 +} + +current_release=$(/usr/bin/readlink -f "${CURRENT_LINK}") +previous_release=$(/usr/bin/readlink -f "${PREVIOUS_LINK}") + +for release in "${current_release}" "${previous_release}"; do + if [[ "${release}" != "${RELEASES_DIR}/"* ]] || + [[ ! -f "${release}/.release-complete" ]]; then + echo 'Current or previous release is invalid.' >&2 + exit 1 + fi +done + +new_previous="${WEB_ROOT}/.previous-rollback-$$" +new_current="${WEB_ROOT}/.current-rollback-$$" + +/usr/bin/ln -s "releases/${current_release##*/}" "${new_previous}" +/usr/bin/ln -s "releases/${previous_release##*/}" "${new_current}" +/usr/bin/mv -Tf "${new_previous}" "${PREVIOUS_LINK}" +/usr/bin/mv -Tf "${new_current}" "${CURRENT_LINK}" + +echo "Rolled back to ${previous_release##*/}." diff --git a/ops/project2/sudoers b/ops/project2/sudoers new file mode 100644 index 0000000..219debb --- /dev/null +++ b/ops/project2/sudoers @@ -0,0 +1,3 @@ +# The Project 2 CI runner may only request deployment of remote main. +# The fixed service accepts no branch, path or command arguments. +t07p2runner ALL=(root) NOPASSWD: /usr/bin/systemctl start t07-project2-deploy.service diff --git a/ops/project2/t07-project2-deploy.service b/ops/project2/t07-project2-deploy.service new file mode 100644 index 0000000..96ceeff --- /dev/null +++ b/ops/project2/t07-project2-deploy.service @@ -0,0 +1,33 @@ +[Unit] +Description=Deploy verified T07 Project 2 frontend to Apache +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=t07p2deploy +Group=t07p2deploy +Environment=HOME=/var/lib/t07-project2/home +Environment=PATH=/opt/node-v26.8.1/bin:/usr/bin:/bin +Environment=NPM_CONFIG_CACHE=/var/lib/t07-project2/npm-cache +Environment="GIT_SSH_COMMAND=/usr/bin/ssh -i /etc/t07-project2/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/t07-project2/known_hosts" +ExecStart=/usr/local/sbin/t07-project2-deploy +UMask=0022 +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectClock=true +ProtectControlGroups=true +ProtectHome=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +ReadWritePaths=/var/lib/t07-project2 /var/www/t07-project2 +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictSUIDSGID=true +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target