diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f92dcf8..58ad237 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,25 @@ on: push: branches: [main] -# Cancel outdated runs on the same branch to save runner time and energy. +# Cancel outdated pull request runs to save runner time and energy. Runs on +# main are never cancelled, so a deploy is not stopped halfway; a newer +# merge waits for the running one instead. concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Apache serves this folder at /project2 (see docs/ci-cd.md). + WEB_DIR: /var/www/html/project2 + # The backend runs from here, outside the runner's work folder, which is + # wiped by the next checkout. + APP_DIR: /srv/ufound-it + SERVICE: ufound-it-backend jobs: + # Deploying is part of this job, not a separate one, because a new job + # starts with a fresh checkout and would have to build again. The job + # keeps its name since branch protection requires a check named "checks". checks: runs-on: self-hosted steps: @@ -29,3 +42,50 @@ jobs: - run: npm run format:check - run: npm test - run: npm run build + + # Only pushes to main deploy. Pull requests stop after the checks. + - name: Deploy frontend + if: github.event_name == 'push' + run: | + find "$WEB_DIR" -mindepth 1 -delete + cp -r frontend/dist/. "$WEB_DIR"/ + + - name: Deploy backend + if: github.event_name == 'push' + run: | + # npm ci needs the workspace layout, so both package.json files are + # copied even though only the backend runs here. backend/.env is + # created once by hand on the VM and is left alone. + mkdir -p "$APP_DIR/backend" "$APP_DIR/frontend" + cp package.json package-lock.json "$APP_DIR"/ + cp backend/package.json "$APP_DIR/backend/" + cp frontend/package.json "$APP_DIR/frontend/" + rm -rf "$APP_DIR/backend/dist" + cp -r backend/dist "$APP_DIR/backend/" + (cd "$APP_DIR" && npm ci --omit=dev --workspace backend) + + # Node is only installed in the runner's tool cache, so the service + # gets its own copy of the same version CI tested with. Renaming + # over the old binary works while the service is still running it. + cp "$(command -v node)" "$APP_DIR/node.new" + mv -f "$APP_DIR/node.new" "$APP_DIR/node" + + sudo -n systemctl restart "$SERVICE" + + - name: Check the deployed site + if: github.event_name == 'push' + run: | + # The backend needs a moment to start, so retry before failing. + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsS http://localhost/project2/graphql \ + -H 'content-type: application/json' \ + --data '{"query":"{ health }"}' | grep -q '"ok"'; then + curl -fsS -o /dev/null http://localhost/project2/ + echo "Deployed and responding." + exit 0 + fi + sleep 2 + done + echo "The backend did not answer through Apache." >&2 + sudo -n systemctl status "$SERVICE" --no-pager || true + exit 1 diff --git a/AGENTS.md b/AGENTS.md index c3e01a7..6f61439 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,10 +131,10 @@ Never: ## Deployment -- **VM address:** TODO (`http://it2810-XX.idi.ntnu.no/project2`) +- **VM address:** http://it2810-25.idi.ntnu.no/project2/ - **Backend port:** 3001 - **GraphQL endpoint:** The frontend calls `/project2/graphql` on its own origin. Apache must proxy it to `http://localhost:3001/graphql`, like the Vite dev proxy does locally. `VITE_GRAPHQL_URL` overrides the URL at build time if needed. -- **How to deploy:** TODO +- **How to deploy:** Automatic. CI deploys every push to `main` that passes the checks. The frontend goes to `/var/www/html/project2`, the backend runs from `/srv/ufound-it` as the systemd service `ufound-it-backend`. One-time VM setup is in `docs/ci-cd.md`. ## AI usage documentation diff --git a/deploy/apache-ufound-it.conf b/deploy/apache-ufound-it.conf new file mode 100644 index 0000000..cf07d17 --- /dev/null +++ b/deploy/apache-ufound-it.conf @@ -0,0 +1,5 @@ +# Forwards the frontend's GraphQL calls to the backend, like the Vite dev +# proxy does locally. The frontend itself is served as plain files from +# /var/www/html/project2. Installed once by hand, see docs/ci-cd.md. +ProxyPass /project2/graphql http://localhost:3001/graphql +ProxyPassReverse /project2/graphql http://localhost:3001/graphql diff --git a/deploy/sudoers-ufound-it b/deploy/sudoers-ufound-it new file mode 100644 index 0000000..efd3441 --- /dev/null +++ b/deploy/sudoers-ufound-it @@ -0,0 +1,3 @@ +# Lets the GitHub Actions runner restart the backend and nothing else. +# Install with `sudo visudo -f /etc/sudoers.d/ufound-it`, see docs/ci-cd.md. +asgeija ALL=(root) NOPASSWD: /usr/bin/systemctl restart ufound-it-backend, /usr/bin/systemctl status ufound-it-backend --no-pager diff --git a/deploy/ufound-it-backend.service b/deploy/ufound-it-backend.service new file mode 100644 index 0000000..d433fe2 --- /dev/null +++ b/deploy/ufound-it-backend.service @@ -0,0 +1,21 @@ +# systemd service for the GraphQL backend on the VM. Installed once by hand, +# see docs/ci-cd.md. CI updates the files in /srv/ufound-it and restarts +# this service after every merge to main. + +[Unit] +Description=UFOund it GraphQL backend +After=network.target mysql.service + +[Service] +# The account the GitHub Actions runner runs as, since CI writes the files. +User=asgeija +WorkingDirectory=/srv/ufound-it/backend +# Same command as `npm run start -w backend`, with the Node binary CI copied. +ExecStart=/srv/ufound-it/node --env-file-if-exists=.env dist/index.js +Environment=NODE_ENV=production +Environment=PORT=3001 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 5d42c58..b1c3dd0 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1,13 +1,13 @@ # CI/CD -This page explains how our continuous integration (CI) works. +This page explains how our continuous integration (CI) and deployment (CD) work. ## Summary - **CI is automated.** A GitHub Actions workflow checks every pull request and every push to `main`. -- **CD is not automated yet.** Deploying to the VM is a manual step for now. We might add - automated CD later. +- **CD is automated.** Every push to `main` that passes the checks is deployed + to the VM at http://it2810-25.idi.ntnu.no/project2/. ## Continuous integration @@ -71,7 +71,81 @@ If CI fails, open the failed run from the pull request page, read the log of the failed step, and fix it on your branch. Pushing a new commit starts a new run, and also dismisses existing approvals. -## Continuous delivery +## Continuous deployment -Nothing is deployed automatically. After a merge to `main`, someone has to -update the VM by hand for now. +After the checks pass on a push to `main`, the same job deploys to the VM. +Pull requests stop after the checks. It is the same job because a new job +would start from a fresh checkout and have to build again, and the job keeps +the name `checks` because branch protection requires a check with that name. + +| Step | What it does | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Deploy frontend | Replaces the contents of `/var/www/html/project2` with `frontend/dist`. Apache serves it at `/project2`. | +| Deploy backend | Copies `backend/dist` to `/srv/ufound-it`, installs the backend's production dependencies there, copies the Node binary CI used, and restarts the service. | +| Check the deployed site | Calls the `health` query through Apache and loads the start page. The run fails if the backend does not answer within about 20 seconds. | + +The backend runs from `/srv/ufound-it` and not from the runner's work folder, +because the next CI run, including one for a pull request, wipes that folder. +Runs on `main` are never cancelled, so a deploy is not stopped halfway. A +second merge waits for the first run to finish. + +### One-time setup on the VM + +The workflow needs a few things that only an account with `sudo` can set up. +This is done once. The files are in [`deploy/`](../deploy). The commands +assume the runner runs as `asgeija`; change the user in the service and +sudoers files if it does not. + +1. **Folders the runner can write to.** + + ```bash + sudo mkdir -p /var/www/html/project2 /srv/ufound-it/backend + sudo chown -R asgeija: /var/www/html/project2 /srv/ufound-it + ``` + +2. **The backend's database settings.** Create `/srv/ufound-it/backend/.env` + from `backend/.env.example` with the real password. CI never touches this + file, and it must never be committed. + + ```bash + sudo -u asgeija nano /srv/ufound-it/backend/.env + chmod 600 /srv/ufound-it/backend/.env + ``` + +3. **The backend service.** + + ```bash + sudo cp deploy/ufound-it-backend.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable ufound-it-backend + ``` + + It starts on the first deploy, once CI has put the files in place. + +4. **Permission for the runner to restart it,** and nothing else. + + ```bash + sudo visudo -f /etc/sudoers.d/ufound-it + ``` + + Paste the line from `deploy/sudoers-ufound-it`. `visudo` checks the syntax + before saving, so a typo cannot lock anyone out of `sudo`. + +5. **The Apache proxy for `/project2/graphql`.** + + ```bash + sudo a2enmod proxy proxy_http + sudo cp deploy/apache-ufound-it.conf /etc/apache2/conf-available/ufound-it.conf + sudo a2enconf ufound-it + sudo systemctl reload apache2 + ``` + +### If a deploy fails + +- **`sudo: a password is required`** means step 4 is missing or the command in + the workflow no longer matches the sudoers line exactly. +- **The site check fails.** Read the service status at the end of the log, or + run `journalctl -u ufound-it-backend -n 50` on the VM. A wrong or missing + `.env` is the usual cause. +- **Rolling back.** Revert the merge commit on `main` with a pull request. The + revert is deployed like any other merge.