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
64 changes: 62 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions deploy/apache-ufound-it.conf
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions deploy/sudoers-ufound-it
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions deploy/ufound-it-backend.service
Original file line number Diff line number Diff line change
@@ -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
86 changes: 80 additions & 6 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.