Skip to content

Commit 040800b

Browse files
committed
Run the three scripts nobody had run, and rewrite the handoff around it
A fourth pass, which was not a reading. The two year retention on door_events could not run at all: the append-only trigger refused its own specification's delete, so the table would have grown forever and three readings of the code had not noticed. door_events now carries two triggers rather than one, and the window lives in the trigger, which makes it the authority on what may go. audit_log is unchanged and still refuses everything. scripts/backup.sh joined $PWD to a BACKUP_DIR that was already absolute and wrote the caddy archive into this repository. Both defects were found by typing the command. The nightly job, the restore, and migrate across three files turned out to be sound, and are now proved rather than assumed: planted rows on both sides of every line the nightly draws, a dump taken and restored into a scratch database, and three migrations applied in order then skipped on a second run. Postgres notices no longer print as a raw object on every deploy. HANDOFF section 6 is rewritten. Thirty nine defects across four passes is too long a list to be read, so it is now the patterns that kept coming back, the four things that were proved not to be defects, and the list of what has still never been audited. The blow by blow is in the commits.
1 parent 8d44896 commit 040800b

6 files changed

Lines changed: 207 additions & 192 deletions

File tree

HANDOFF.md

Lines changed: 149 additions & 186 deletions
Large diffs are not rendered by default.

api/src/audit.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,34 @@ describe('the record', () => {
5858
assert.equal((await sql`select id from audit_log`).length, 1)
5959
})
6060

61-
test('door_events refuses update and delete, and names itself rather than audit_log', async () => {
61+
test('door_events refuses a change, and names itself rather than audit_log', async () => {
6262
await sql`insert into door_events (controller_id, kind) values ('openaccess', 'entry')`
6363

6464
await assert.rejects(
65-
() => sql`delete from door_events`,
65+
() => sql`update door_events set kind = 'denied'`,
6666
(error: Error) => error.message.includes('door_events is append only'),
6767
)
6868
})
6969

70+
test('door_events keeps two years, and the nightly job can take what is past it', async () => {
71+
await sql`
72+
insert into door_events (controller_id, kind, at) values
73+
('openaccess', 'entry', now() - interval '3 years'),
74+
('openaccess', 'entry', now() - interval '1 year')`
75+
76+
// The retention in scripts/nightly.sql is the only thing that removes a
77+
// door event. A trigger refusing every delete makes it impossible to run
78+
// and the table grow forever, which is what it did until somebody ran it.
79+
await sql`delete from door_events where at < now() - interval '2 years'`
80+
assert.equal((await sql`select id from door_events`).length, 1)
81+
82+
// And nothing else can, whatever it asks for.
83+
await assert.rejects(
84+
() => sql`delete from door_events`,
85+
(error: Error) => error.message.includes('this table keeps two years'),
86+
)
87+
})
88+
7089
test('every privileged route leaves exactly one row behind', async () => {
7190
const admin = await makeMember({ roles: ['admin', 'accountant', 'instructor'] })
7291
const subject = await makeMember()

api/src/db.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import postgres from 'postgres'
22

33
import { config } from './config.ts'
4+
import { log } from './log.ts'
45

56
/**
67
* Columns are snake_case in SQL and camelCase in JavaScript, translated here so
@@ -12,6 +13,9 @@ import { config } from './config.ts'
1213
*/
1314
export const sql = postgres(config.databaseUrl, {
1415
transform: { column: { to: postgres.fromCamel, from: postgres.toCamel } },
16+
// Postgres notices print as a raw object on stderr otherwise, which in a log
17+
// of one JSON line per event reads as something having gone wrong.
18+
onnotice: (notice) => log({ evt: 'db_notice', message: notice.message ?? String(notice) }),
1519
})
1620

1721
export type Sql = typeof sql

migrations/001_init.sql

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,33 @@ begin
157157
end;
158158
$$ language plpgsql;
159159

160-
create trigger audit_log_append_only before update or delete on audit_log
160+
-- audit_log is kept forever, so nothing may change it and nothing may remove it.
161+
create trigger audit_log_append_only before update or delete on audit_log
161162
for each row execute function refuse_change();
162-
create trigger door_events_append_only before update or delete on door_events
163+
164+
-- door_events is kept for two years, so it needs one thing audit_log does not:
165+
-- the nightly job has to be able to remove what is past that. A trigger that
166+
-- refuses every delete makes the retention in scripts/nightly.sql impossible to
167+
-- run and the table grow forever, which is what it did until somebody ran it.
168+
--
169+
-- The window lives here rather than in the job, so this is the authority on
170+
-- what may go. A job asking for a shorter one meets this and says so, rather
171+
-- than quietly taking more than it should.
172+
create function refuse_early_delete() returns trigger as $$
173+
begin
174+
raise exception
175+
'door event % is from % and this table keeps two years, so it was left alone. Only the nightly retention removes door events.',
176+
OLD.id, OLD.at::date;
177+
end;
178+
$$ language plpgsql;
179+
180+
create trigger door_events_no_change before update on door_events
163181
for each row execute function refuse_change();
164182

183+
create trigger door_events_retention before delete on door_events
184+
for each row when (OLD.at > now() - interval '2 years')
185+
execute function refuse_early_delete();
186+
165187
-- 4. The door ---------------------------------------------------------------
166188

167189
-- Current state is upserted, never appended. The legacy system wrote a status

scripts/backup.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ set -eu
1313
stamp=$(date -u +%Y%m%dT%H%M%SZ)
1414
out=${BACKUP_DIR:-backups}
1515
mkdir -p "$out"
16+
# Resolved, because the docker mount below needs an absolute path and joining
17+
# $PWD to an already absolute BACKUP_DIR writes the archive somewhere nobody
18+
# asked for, inside this repository.
19+
here=$(cd "$out" && pwd)
1620

1721
docker compose exec -T db pg_dump -U hsl -Fc hsl > "$out/hsl-$stamp.dump"
1822
echo "wrote $out/hsl-$stamp.dump"
1923

2024
# Caddy's volume holds the TLS certificate and the ACME account key. Losing it
2125
# costs a new certificate rather than data, and it is cheap to keep.
22-
docker run --rm -v hsl-web_caddy:/data -v "$PWD/$out":/out alpine \
26+
docker run --rm -v hsl-web_caddy:/data -v "$here":/out alpine \
2327
tar czf "/out/caddy-$stamp.tar.gz" -C /data . 2>/dev/null || true
2428

2529
find "$out" -name 'hsl-*.dump' -mtime +30 -delete

scripts/migrate.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ if (url === undefined || url === '') {
1919
}
2020

2121
const directory = new URL('../migrations/', import.meta.url)
22-
const sql = postgres(url)
22+
// `create table if not exists` below raises a notice every run after the
23+
// first, and postgres.js prints one as a raw object, which on a deploy reads as
24+
// something having gone wrong.
25+
const sql = postgres(url, { onnotice: () => undefined })
2326

2427
await sql`
2528
create table if not exists schema_migrations (

0 commit comments

Comments
 (0)