Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/deploy-main.yml
Original file line number Diff line number Diff line change
@@ -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"
45 changes: 45 additions & 0 deletions .github/workflows/frontend-quality.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26.8.1
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<http://it2810-07.idi.ntnu.no/project2>

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.
2 changes: 1 addition & 1 deletion client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="nb">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tapsailor</title>
</head>
Expand Down
47 changes: 47 additions & 0 deletions ops/project2/README.md
Original file line number Diff line number Diff line change
@@ -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/<commit>`: 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.
20 changes: 20 additions & 0 deletions ops/project2/actions.runner._services.t07-project-2-vm.service
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions ops/project2/deploy.sh
Original file line number Diff line number Diff line change
@@ -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}."
36 changes: 36 additions & 0 deletions ops/project2/rollback.sh
Original file line number Diff line number Diff line change
@@ -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##*/}."
3 changes: 3 additions & 0 deletions ops/project2/sudoers
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions ops/project2/t07-project2-deploy.service
Original file line number Diff line number Diff line change
@@ -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