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
59 changes: 59 additions & 0 deletions ops/project2/backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Project 2 GraphQL service

These files document the persistent GraphQL service on the T07 VM. They do not
contain database credentials and are not executed directly from a repository
checkout. An administrator installs reviewed, root-owned copies.

The deployment service uses the existing read-only repository mirror and
`t07p2deploy` account. It checks out `origin/main`, runs the backend typecheck,
tests and build, installs production dependencies in a commit-specific release
directory, and atomically updates the backend `current` symlink. It does not
run migrations or read the database credential.

The API runs separately as `t07p2api`. Only this account can read
`/etc/t07-project2-api/backend.env`. It listens on `127.0.0.1:3001`, so it is not
exposed directly to the network. Apache proxies only
`/project2/api/graphql` to the local API.

Server-side paths:

- `/etc/t07-project2-api/backend.env`: root-owned runtime configuration
- `/var/lib/t07-project2/backend/releases/<commit>`: verified API releases
- `/var/lib/t07-project2/backend/current`: selected API release
- `/var/lib/t07-project2/backend/previous`: previous API release
- `/usr/local/sbin/t07-project2-backend-deploy`: root-owned deployment script
- `/etc/systemd/system/t07-project2-api.service`: persistent API service

The installer creates the service account and installs root-owned copies, but
does not create the secret environment file or start the API:

```bash
sudo /usr/bin/bash ops/project2/backend/install.sh
```

An administrator then installs `/etc/t07-project2-api/backend.env` from a private
source with owner `root`, group `t07p2api` and mode `0640`. After #53 or its
equivalent has reached `main`, prepare the release and start the service:

```bash
sudo systemctl start t07-project2-backend-deploy.service
sudo systemctl enable --now t07-project2-api.service
```

Verify locally on the VM before testing through Apache:

```bash
curl --fail --silent --show-error \
--header 'content-type: application/json' \
--data '{"query":"{ health databaseStatus }"}' \
http://127.0.0.1:3001/
```

Then verify the public same-origin path:

```bash
curl --fail --silent --show-error \
--header 'content-type: application/json' \
--data '{"query":"{ venues(limit: 1) { totalCount items { id name } } }"}' \
http://localhost/project2/api/graphql
```
2 changes: 2 additions & 0 deletions ops/project2/backend/apache.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ProxyPass /project2/api/graphql http://127.0.0.1:3001/
ProxyPassReverse /project2/api/graphql http://127.0.0.1:3001/
5 changes: 5 additions & 0 deletions ops/project2/backend/backend.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Install the real file as /etc/t07-project2-api/backend.env with mode 0640.
# Never commit the real database password.
DATABASE_URL=postgresql://tapfinder_app:REPLACE_WITH_PASSWORD@127.0.0.1:5432/tapfinder_dev
HOST=127.0.0.1
PORT=3001
84 changes: 84 additions & 0 deletions ops/project2/backend/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/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 BACKEND_ROOT="${STATE_ROOT}/backend"
readonly RELEASES_DIR="${BACKEND_ROOT}/releases"
readonly CURRENT_LINK="${BACKEND_ROOT}/current"
readonly PREVIOUS_LINK="${BACKEND_ROOT}/previous"
readonly REMOTE_URL='git@git.ntnu.no:IT2810-H26/T07-Project-2.git'
readonly NODE_ROOT='/opt/node-v26.8.1'

exec 9>"${STATE_ROOT}/backend-deploy.lock"
/usr/bin/flock -n 9 || {
echo 'A Project 2 backend 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}/backend.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}/server"
"${NODE_ROOT}/bin/npm" ci
"${NODE_ROOT}/bin/npm" run typecheck
"${NODE_ROOT}/bin/npm" run test:data
"${NODE_ROOT}/bin/npm" run build

/usr/bin/mkdir "${release_stage}"
/usr/bin/cp -a dist package.json package-lock.json "${release_stage}/"

cd "${release_stage}"
"${NODE_ROOT}/bin/npm" ci --omit=dev --ignore-scripts
/usr/bin/printf '%s\n' "${commit_sha}" >.commit
/usr/bin/touch .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="${BACKEND_ROOT}/.previous-$$"
/usr/bin/ln -s "releases/${old_release##*/}" "${previous_stage}"
/usr/bin/mv -Tf "${previous_stage}" "${PREVIOUS_LINK}"
fi

current_stage="${BACKEND_ROOT}/.current-$$"
/usr/bin/ln -s "releases/${commit_sha}" "${current_stage}"
/usr/bin/mv -Tf "${current_stage}" "${CURRENT_LINK}"

echo "Prepared backend origin/main at ${commit_sha}."
51 changes: 51 additions & 0 deletions ops/project2/backend/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash

set -euo pipefail

if [[ "${EUID}" -ne 0 ]]; then
echo 'Run this installer as root.' >&2
exit 1
fi

readonly SOURCE_DIR=$(/usr/bin/dirname "$0")

if ! /usr/bin/getent passwd t07p2deploy >/dev/null; then
echo 'The existing t07p2deploy account is required.' >&2
exit 1
fi

if ! /usr/bin/getent passwd t07p2api >/dev/null; then
/usr/sbin/useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin \
t07p2api
fi

/usr/bin/install -d -o t07p2deploy -g t07p2deploy -m 0755 \
/var/lib/t07-project2/backend \
/var/lib/t07-project2/backend/releases
/usr/bin/install -d -o root -g t07p2api -m 0750 /etc/t07-project2-api

# The API account may traverse the state root to read the selected release, but
# cannot list it or access the root-owned credential directory under /etc.
/usr/bin/chmod 0751 /var/lib/t07-project2

/usr/bin/install -o root -g root -m 0755 \
"${SOURCE_DIR}/deploy.sh" \
/usr/local/sbin/t07-project2-backend-deploy
/usr/bin/install -o root -g root -m 0644 \
"${SOURCE_DIR}/t07-project2-backend-deploy.service" \
/etc/systemd/system/t07-project2-backend-deploy.service
/usr/bin/install -o root -g root -m 0644 \
"${SOURCE_DIR}/t07-project2-api.service" \
/etc/systemd/system/t07-project2-api.service
/usr/bin/install -o root -g root -m 0644 \
"${SOURCE_DIR}/apache.conf" \
/etc/apache2/conf-available/t07-project2-api.conf

/usr/sbin/a2enmod proxy proxy_http
/usr/sbin/a2enconf t07-project2-api
/usr/sbin/apache2ctl configtest
/usr/bin/systemctl daemon-reload
/usr/bin/systemctl reload apache2

echo 'Project 2 backend files are installed but the API is not started.'
echo 'Install /etc/t07-project2-api/backend.env before preparing and starting a release.'
34 changes: 34 additions & 0 deletions ops/project2/backend/t07-project2-api.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[Unit]
Description=T07 Project 2 GraphQL API
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=t07p2api
Group=t07p2api
WorkingDirectory=/var/lib/t07-project2/backend/current
EnvironmentFile=/etc/t07-project2-api/backend.env
Environment=NODE_ENV=production
Environment=PATH=/opt/node-v26.8.1/bin:/usr/bin:/bin
ExecStart=/opt/node-v26.8.1/bin/node dist/index.js
Restart=on-failure
RestartSec=5s
UMask=0027
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target
30 changes: 30 additions & 0 deletions ops/project2/backend/t07-project2-backend-deploy.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[Unit]
Description=Prepare verified T07 Project 2 backend release
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-backend-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
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
CapabilityBoundingSet=