diff --git a/ops/project2/backend/README.md b/ops/project2/backend/README.md new file mode 100644 index 0000000..176b904 --- /dev/null +++ b/ops/project2/backend/README.md @@ -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/`: 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 +``` diff --git a/ops/project2/backend/apache.conf b/ops/project2/backend/apache.conf new file mode 100644 index 0000000..137c029 --- /dev/null +++ b/ops/project2/backend/apache.conf @@ -0,0 +1,2 @@ +ProxyPass /project2/api/graphql http://127.0.0.1:3001/ +ProxyPassReverse /project2/api/graphql http://127.0.0.1:3001/ diff --git a/ops/project2/backend/backend.env.example b/ops/project2/backend/backend.env.example new file mode 100644 index 0000000..71d1bf6 --- /dev/null +++ b/ops/project2/backend/backend.env.example @@ -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 diff --git a/ops/project2/backend/deploy.sh b/ops/project2/backend/deploy.sh new file mode 100755 index 0000000..fffa300 --- /dev/null +++ b/ops/project2/backend/deploy.sh @@ -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}." diff --git a/ops/project2/backend/install.sh b/ops/project2/backend/install.sh new file mode 100755 index 0000000..f66a9cc --- /dev/null +++ b/ops/project2/backend/install.sh @@ -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.' diff --git a/ops/project2/backend/t07-project2-api.service b/ops/project2/backend/t07-project2-api.service new file mode 100644 index 0000000..55bf842 --- /dev/null +++ b/ops/project2/backend/t07-project2-api.service @@ -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 diff --git a/ops/project2/backend/t07-project2-backend-deploy.service b/ops/project2/backend/t07-project2-backend-deploy.service new file mode 100644 index 0000000..182324e --- /dev/null +++ b/ops/project2/backend/t07-project2-backend-deploy.service @@ -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=