Skip to content

Commit d6f8317

Browse files
Initial public deployment of AcknowlEDG []
1 parent 034385b commit d6f8317

5 files changed

Lines changed: 380 additions & 8 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM
2+
# Exceptions.
3+
# See https://edgcpp.org/LICENSE.txt for license information.
4+
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
#
6+
# Build and deploy AcknowlEDG over SSH. Two independent jobs:
7+
#
8+
# client builds the Vue client and rsyncs the static files into the web
9+
# root. Nothing is restarted; browsers pick up the new files once
10+
# their caches expire.
11+
# server builds the server wheel, uploads it, and runs
12+
# dev_tools/services/acknowledg/server/deploy-remote.sh on the host,
13+
# which installs it and restarts the systemd unit only when the
14+
# wheel's contents differ from what is already deployed.
15+
#
16+
# Host, paths, and credentials come from secrets in the `acknowledg`
17+
# environment (see dev_tools/services/acknowledg/README.md). The SSH setup
18+
# writes them into a `Host acknowledg` alias so they never appear on a command
19+
# line.
20+
21+
name: AcknowlEDG Deploy
22+
23+
on:
24+
push:
25+
branches:
26+
- main
27+
paths:
28+
- dev_tools/services/acknowledg/**
29+
- dev_tools/pylibs/edgacknowledg/**
30+
- .github/workflows/acknowledg-deploy.yml
31+
workflow_dispatch:
32+
33+
concurrency:
34+
group: acknowledg-deploy
35+
cancel-in-progress: false
36+
37+
permissions:
38+
contents: read
39+
40+
env:
41+
ACKNOWLEDG_DIR: dev_tools/services/acknowledg
42+
43+
jobs:
44+
client:
45+
name: Deploy client
46+
if: github.ref == 'refs/heads/main'
47+
runs-on: ubuntu-latest
48+
timeout-minutes: 15
49+
environment: acknowledg
50+
steps:
51+
- name: Check out repository
52+
uses: actions/checkout@v7
53+
54+
- name: Set up Node
55+
uses: actions/setup-node@v7
56+
with:
57+
node-version: '22'
58+
cache: npm
59+
cache-dependency-path: ${{ env.ACKNOWLEDG_DIR }}/client/package-lock.json
60+
61+
- name: Build client
62+
working-directory: ${{ env.ACKNOWLEDG_DIR }}/client
63+
env:
64+
VITE_EDG_ACKNOWLEDG_API_HOST: ${{ secrets.ACKNOWLEDG_API_HOST }}
65+
run: |
66+
# The client opens `${VITE_EDG_ACKNOWLEDG_API_HOST}/review`; without
67+
# a scheme the browser resolves that relative to the page instead.
68+
case "${VITE_EDG_ACKNOWLEDG_API_HOST}" in
69+
ws://?*|wss://?*) ;;
70+
*)
71+
echo '::error::ACKNOWLEDG_API_HOST must be a ws:// or wss:// URL'
72+
exit 1
73+
;;
74+
esac
75+
npm ci
76+
npm run build
77+
78+
- name: Configure SSH
79+
env:
80+
SSH_HOST: ${{ secrets.ACKNOWLEDG_SSH_HOST }}
81+
SSH_USER: ${{ secrets.ACKNOWLEDG_SSH_USER }}
82+
SSH_PORT: ${{ secrets.ACKNOWLEDG_SSH_PORT || '22' }}
83+
SSH_PRIVATE_KEY: ${{ secrets.ACKNOWLEDG_SSH_PRIVATE_KEY }}
84+
SSH_KNOWN_HOSTS: ${{ secrets.ACKNOWLEDG_SSH_KNOWN_HOSTS }}
85+
run: &configure-ssh |
86+
install -d -m 700 ~/.ssh
87+
install -m 600 /dev/null ~/.ssh/deploy_key
88+
printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/deploy_key
89+
printf '%s\n' "${SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
90+
cat > ~/.ssh/config <<EOF
91+
Host acknowledg
92+
HostName ${SSH_HOST}
93+
User ${SSH_USER}
94+
Port ${SSH_PORT}
95+
IdentityFile ~/.ssh/deploy_key
96+
IdentitiesOnly yes
97+
StrictHostKeyChecking yes
98+
BatchMode yes
99+
EOF
100+
chmod 600 ~/.ssh/config ~/.ssh/known_hosts
101+
102+
- name: Upload client files
103+
working-directory: ${{ env.ACKNOWLEDG_DIR }}/client
104+
env:
105+
CLIENT_PATH: ${{ secrets.ACKNOWLEDG_CLIENT_PATH }}
106+
run: |
107+
# --delay-updates swaps the new files in at the end of the transfer
108+
# and --delete-after removes stale hashed assets only once the new
109+
# index.html no longer references them.
110+
rsync --recursive --links --compress --checksum --itemize-changes \
111+
--delay-updates --delete-after \
112+
dist/ "acknowledg:${CLIENT_PATH}/"
113+
114+
server:
115+
name: Deploy server
116+
if: github.ref == 'refs/heads/main'
117+
runs-on: ubuntu-latest
118+
timeout-minutes: 15
119+
environment: acknowledg
120+
steps:
121+
- name: Check out repository
122+
uses: actions/checkout@v7
123+
124+
- name: Set up uv
125+
uses: astral-sh/setup-uv@v10.3.0
126+
127+
- name: Build server wheel
128+
id: build
129+
working-directory: ${{ env.ACKNOWLEDG_DIR }}/server
130+
run: |
131+
uv build --wheel --out-dir dist
132+
wheel=$(ls dist/*.whl)
133+
# Fingerprint the wheel by its RECORD, which lists a hash of every
134+
# file in it; the zip itself isn't reproducible (timestamps). WHEEL
135+
# only names the build backend's version, so leave it out to avoid
136+
# restarts when nothing we ship has changed.
137+
fingerprint=$(unzip -p "${wheel}" '*.dist-info/RECORD' \
138+
| grep -v -E '\.dist-info/(WHEEL|RECORD),' \
139+
| sort | sha256sum | cut -d' ' -f1)
140+
echo "Built $(basename "${wheel}") with fingerprint ${fingerprint}"
141+
echo "wheel=${wheel}" >> "${GITHUB_OUTPUT}"
142+
echo "fingerprint=${fingerprint}" >> "${GITHUB_OUTPUT}"
143+
144+
- name: Configure SSH
145+
env:
146+
SSH_HOST: ${{ secrets.ACKNOWLEDG_SSH_HOST }}
147+
SSH_USER: ${{ secrets.ACKNOWLEDG_SSH_USER }}
148+
SSH_PORT: ${{ secrets.ACKNOWLEDG_SSH_PORT || '22' }}
149+
SSH_PRIVATE_KEY: ${{ secrets.ACKNOWLEDG_SSH_PRIVATE_KEY }}
150+
SSH_KNOWN_HOSTS: ${{ secrets.ACKNOWLEDG_SSH_KNOWN_HOSTS }}
151+
run: *configure-ssh
152+
153+
- name: Install server
154+
working-directory: ${{ env.ACKNOWLEDG_DIR }}/server
155+
env:
156+
SERVER_PATH: ${{ secrets.ACKNOWLEDG_SERVER_PATH }}
157+
SERVICE_NAME: ${{ secrets.ACKNOWLEDG_SERVICE_NAME }}
158+
WHEEL: ${{ steps.build.outputs.wheel }}
159+
FINGERPRINT: ${{ steps.build.outputs.fingerprint }}
160+
run: |
161+
wheel_name=$(basename "${WHEEL}")
162+
ssh acknowledg "mkdir -p $(printf '%q' "${SERVER_PATH}/incoming")"
163+
rsync --checksum "${WHEEL}" "acknowledg:${SERVER_PATH}/incoming/"
164+
ssh acknowledg "bash -s -- $(printf '%q ' \
165+
"${SERVER_PATH}" "${SERVICE_NAME}" "${wheel_name}" \
166+
"${FINGERPRINT}")" < deploy-remote.sh

‎dev_tools/services/acknowledg/PROTOCOL.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ server's current `r`).
4242

4343
`<uuid>` is the shared review id. `<slug>` selects one section of that kind.
4444

45-
When `EDG_ACKNOWLEDG_API_KEY` is set, `/source`, `/edit`, and `/batch` require
45+
When the server has an API key configured (a systemd `api-key` credential, or
46+
else `EDG_ACKNOWLEDG_API_KEY`), `/source`, `/edit`, and `/batch` require
4647
header `X-AcknowlEDG-API-Key`. The server accepts the WebSocket, then closes
47-
with code **3000** if the key is missing or wrong. When the env var is unset,
48+
with code **3000** if the key is missing or wrong. When no key is configured,
4849
authentication is skipped (local development).
4950

5051
## Roles and occupancy

‎dev_tools/services/acknowledg/README.md‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ when the CLI starts, so reconnects keep the original time) shown in the
2323
viewer's local timezone.
2424

2525
Write-capable `/source`, `/edit`, and `/batch` routes can be protected with a
26-
shared API key via the `EDG_ACKNOWLEDG_API_KEY` environment variable.
26+
shared API key via the `EDG_ACKNOWLEDG_API_KEY` environment variable or, for
27+
deployed servers, a systemd credential (see [Server](#server)).
2728
`edg-acknowledg-cli` multiplexes many sections over `/batch` from a JSON
2829
specification; `edgy-review` and `edg-bench-review` remain available as
2930
task-focused CLIs that use the direct `/source` and `/edit` endpoints.
@@ -88,4 +89,106 @@ a reverse proxy. For Apache, this looks something like the following (with
8889
</VirtualHost>
8990
```
9091

92+
Hypercorn itself is best run as a systemd service. A unit along these lines
93+
(saved as e.g. `/etc/systemd/system/acknowledg-api.service`, with `<USER>` and
94+
`<SERVER PATH>` replaced, where `<SERVER PATH>/venv` has the server wheel
95+
installed) keeps it running and restarts it on failure:
96+
97+
```
98+
[Unit]
99+
Description=AcknowlEDG API Server
100+
After=network-online.target
101+
Wants=network-online.target
102+
103+
[Service]
104+
Type=exec
105+
User=<USER>
106+
WorkingDirectory=<SERVER PATH>/wdir
107+
# Optional API key protecting the write routes (see below).
108+
LoadCredentialEncrypted=api-key:/etc/credstore.encrypted/acknowledg-api.api-key
109+
ExecStart=<SERVER PATH>/venv/bin/hypercorn --bind 127.0.0.1:2727 acknowledg_server:app
110+
Restart=on-failure
111+
RestartSec=5
112+
113+
# Review state is held in memory, so the server needs no writable paths.
114+
NoNewPrivileges=yes
115+
PrivateTmp=yes
116+
ProtectSystem=strict
117+
ProtectHome=read-only
118+
119+
[Install]
120+
WantedBy=multi-user.target
121+
```
122+
123+
The API key is optional: without one, the write routes accept any client, which
124+
is only appropriate when the API host is reachable solely by trusted users.
125+
When a key is wanted, supply it as a [systemd credential][credentials] rather
126+
than an environment variable: systemd decrypts it at service start into a
127+
private directory readable only by the service, and the server reads it from
128+
`$CREDENTIALS_DIRECTORY/api-key` (falling back to `EDG_ACKNOWLEDG_API_KEY` when
129+
no credential is present). On disk the key is encrypted with the host's key
130+
(and TPM2, when available), so the file is useless if copied elsewhere, and
131+
neither the deploy user nor the service user can read it. Create it once as
132+
root (requires systemd 250 or later):
133+
134+
```
135+
mkdir -p /etc/credstore.encrypted
136+
systemd-ask-password -n 'AcknowlEDG API key:' \
137+
| systemd-creds encrypt --name=api-key - \
138+
/etc/credstore.encrypted/acknowledg-api.api-key
139+
```
140+
141+
As written, `LoadCredentialEncrypted=` makes systemd refuse to start the
142+
service if the credential file is missing, so a host meant to require a key
143+
fails loudly rather than quietly running without authentication. To run without
144+
a key, omit the `LoadCredentialEncrypted=` line. To rotate the key, re-run the
145+
command above and restart the service. On hosts with systemd 247–249, use
146+
`LoadCredential=` with a plain file instead (`root:root`, mode `0600`), which
147+
keeps the same isolation from the service and deploy users but stores the key
148+
unencrypted.
149+
150+
[credentials]: https://systemd.io/CREDENTIALS/
151+
152+
Enable the service with `systemctl daemon-reload` and `systemctl enable --now
153+
acknowledg-api`. Review sessions live only in the server's memory, so
154+
restarting the service drops any open reviews.
155+
91156
Locally, `server/run-server.sh` starts Hypercorn on `127.0.0.1:2727`.
157+
158+
### Automated Deployment
159+
160+
`.github/workflows/acknowledg-deploy.yml` builds and deploys both halves over
161+
SSH on pushes to `main` that touch AcknowlEDG. The client is rsynced into the
162+
web root (no restart needed). The server wheel is uploaded and handed to
163+
`server/deploy-remote.sh`, which installs it and restarts the systemd unit
164+
**only** when the wheel's contents differ from the last successful deploy
165+
(tracked in `<server path>/deployed.fingerprint`). The last few deployed
166+
wheels are kept under `<server path>/wheels/` for manual rollback.
167+
168+
The workflow reads these secrets from the `acknowledg` GitHub environment:
169+
170+
| Secret | Purpose |
171+
| --- | --- |
172+
| `ACKNOWLEDG_SSH_HOST` | Host name or IP address |
173+
| `ACKNOWLEDG_SSH_USER` | Deploy user |
174+
| `ACKNOWLEDG_SSH_PORT` | SSH port (optional; defaults to 22) |
175+
| `ACKNOWLEDG_SSH_PRIVATE_KEY` | Private key for the deploy user |
176+
| `ACKNOWLEDG_SSH_KNOWN_HOSTS` | `ssh-keyscan` output for the host |
177+
| `ACKNOWLEDG_CLIENT_PATH` | Web root for the client files |
178+
| `ACKNOWLEDG_SERVER_PATH` | Server directory containing `venv/` |
179+
| `ACKNOWLEDG_SERVICE_NAME` | systemd unit running the server (e.g. `acknowledg-api`) |
180+
| `ACKNOWLEDG_API_HOST` | API URL baked into the client (`VITE_EDG_ACKNOWLEDG_API_HOST`), including the scheme, e.g. `wss://acknowledg-api.example.com` |
181+
182+
The host must already be set up; the workflow does not manage the unit file:
183+
184+
- The deploy user's `authorized_keys` holds the public key, `rsync` is
185+
installed, and the user owns the client and server paths.
186+
- `<server path>/venv` is a Python 3.11+ venv, and the unit's `ExecStart`
187+
runs Hypercorn from it (see the example unit under [Server](#server)).
188+
- The deploy user may restart the unit without a password, e.g. in
189+
`/etc/sudoers.d/acknowledg`. sudo matches arguments exactly, so the unit
190+
name here must be spelled as in `ACKNOWLEDG_SERVICE_NAME`:
191+
192+
```
193+
<user> ALL=(root) NOPASSWD: /usr/bin/systemctl restart acknowledg-api
194+
```

‎dev_tools/services/acknowledg/server/acknowledg_server/__init__.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
55

66
import asyncio
7+
import hmac
78
import os
89
import uuid
910

10-
from functools import wraps
11+
from functools import lru_cache, wraps
1112
from http import HTTPStatus
1213
from typing import (
1314
Any, AsyncGenerator, Awaitable, Callable, Dict, Optional, TypeVar
@@ -145,7 +146,23 @@ class NoTestEditSocket(Exception):
145146
class NoBenchEditSocket(Exception):
146147
pass
147148

149+
API_KEY_CREDENTIAL = 'api-key'
150+
151+
@lru_cache(maxsize = None)
148152
def _configured_api_key() -> Optional[str]:
153+
'''Return the API key protecting write-capable routes, if any.
154+
155+
A systemd credential named ``api-key`` (``LoadCredential=`` or
156+
``LoadCredentialEncrypted=``) takes precedence, so deployments can keep the
157+
key out of the process environment. Otherwise ``EDG_ACKNOWLEDG_API_KEY`` is
158+
used. The key is read once and cached.
159+
'''
160+
credentials_dir = os.environ.get('CREDENTIALS_DIRECTORY')
161+
if credentials_dir is not None:
162+
path = os.path.join(credentials_dir, API_KEY_CREDENTIAL)
163+
if os.path.isfile(path):
164+
with open(path, encoding = 'utf-8') as credential:
165+
return credential.read().rstrip('\r\n')
149166
return os.environ.get('EDG_ACKNOWLEDG_API_KEY')
150167

151168
def _extract_request_api_key() -> Optional[str]:
@@ -154,9 +171,9 @@ def _extract_request_api_key() -> Optional[str]:
154171
def require_api_key(func: F) -> F:
155172
'''Authenticate write-capable websocket routes with a shared API key.
156173
157-
When ``EDG_ACKNOWLEDG_API_KEY`` is unset, authentication is skipped (local
158-
development). Otherwise the key must be supplied via the
159-
``X-AcknowlEDG-API-Key`` header.
174+
When no key is configured (see ``_configured_api_key``), authentication is
175+
skipped (local development). Otherwise the key must be supplied via the
176+
``X-AcknowlEDG-API-Key`` header; it is compared in constant time.
160177
161178
The websocket is accepted before authentication so it can be closed with an
162179
application close code on failure.
@@ -167,7 +184,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
167184
expected = _configured_api_key()
168185
if expected is not None:
169186
provided = _extract_request_api_key()
170-
if provided != expected:
187+
if provided is None or not hmac.compare_digest(
188+
provided.encode('utf-8'), expected.encode('utf-8')):
171189
await websocket.close(WS_CLOSE_UNAUTHORIZED)
172190
abort(HTTPStatus.UNAUTHORIZED)
173191
return await func(*args, **kwargs)

0 commit comments

Comments
 (0)