refactor: V2: split the core model into machines, applications, and identities - #515
Open
passcod wants to merge 83 commits into
Open
refactor: V2: split the core model into machines, applications, and identities#515passcod wants to merge 83 commits into
passcod wants to merge 83 commits into
Conversation
The rename swept crates/ but not private-web/e2e/, so every Playwright test failed in its fixture: the seed's TRUNCATE list and inserts still named servers, server_names and server_certificates. Also fixes two SPA call sites that were left sending the old key after the issues endpoints' args became application_id. Typecheck did not catch either — the useApi/useApiAction argument types are not bound to the generated schema — so both only showed up under Playwright. The seed's TRUNCATE now also clears machines, which is a parent of applications and so is not reached by truncating applications alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the 7776 words in the EFF large wordlist are themselves hyphenated (drop-down, felt-tip, t-shirt, yo-yo), so a 4-word hyphen-separated passphrase hit one about one time in five hundred and read as five words. An operator transcribing disband-retrace-drop-down-bodacious cannot tell which four were meant, and the round-trip test asserting four words failed at that rate. Skip hyphenated words when sampling, which costs about a thousandth of a bit, and cover it with enough draws to make a regression certain rather than likely. The sampler is now built once: constructing the list parses 7776 words out of a static string and taking a sampler clones them again, which was costing ~1ms per ticket and made the new test take 20s instead of 35ms. Pre-existing; not caused by this card's changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A machine was a table nobody wrote: no update, no handlers, and nothing in Rust ever setting registered_at or device_id. Scouting the two steps meant to come next found both blocked on that, so it lands first. Application.machine_id is now a real field, so every struct-literal insert names its machine and the compiler enforces it. Machine::update owns the group write. It propagates the new group onto the applications on the machine, re-evaluates their open issues on an ungrouped-to-grouped transition, and recomputes both groups' cached effective version. A trigger would do the column and none of the three consequences, which is why this is a model method. MachineUpdate carries no device_id or registered_at: an identity is bound by enrolment, not by editing a form. Machine::mark_registered does that, and COALESCEs registered_at so a re-enrolment does not restart the clock a backup deadline counts from. The operator create flow creates the machine first and hangs the application off it. Still 1:1 — a second workload on a box arrives by report. Removed NewServer and its conversion: dead since the crate split, in neither OpenAPI spec, and the only thing that would have needed a machine invented for it. TEN_MINUTES went with its last non-test user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds /api/machines: list, get (with the applications on the box), create, update, archive. Enrolment now marks the machine as well as the application; the application is still marked because enrolment stays keyed by application until that flow moves outright. Drops the application_default_machine() scaffolding. Omitting a machine is now a NOT NULL violation, which is what it should always have been: the hazard was a caller that should attach to an existing machine silently getting a second one, wrong for exactly the two-workload host this card serves. That cost 137 raw-SQL fixture rewrites across 40 files. The uniform shape is a data-modifying CTE, which keeps it one statement (sql_query cannot run two) and preserves bind numbering. Where the application had an explicit id the machine reuses it, the same 1:1 the backfill produced; otherwise the machine is minted anonymously and selected from the CTE. Fixture machines carry no group even where their application does. Nothing reads machines.group_id on those paths so it is inert, but it is a disagreement a future machine-scoped assertion would trip over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two triggers keep the denormalisation honest: a machine's group change propagates to the applications on it, and an application's own group write is corrected back to its machine's. Existing rows brought into agreement, a no-op on anything the 1:1 backfill produced. The triggers complement Machine::update rather than replacing it. The model method does the three things a column write cannot — re-evaluating open issues for anything that gains a group, and recomputing both groups' cached effective version — while the triggers cover every other writer. The application update endpoint keeps its contract and changes meaning: a group change is applied to the machine, which propagates back down. Moving "the server" to a group moves the box. No frontend change needed. BEFORE INSERT was wrong and is deliberately absent. A data-modifying CTE's rows are not visible to the rest of the same statement, so creating the machine and the application together had the trigger find no machine and blank the group it was just handed — silently, because the foreign key checks on a later snapshot. The reason is in the migration so nobody adds INSERT back. An empty changeset is no longer an error in Application::update: a group-only edit leaves nothing to write. That turns update on a missing server from a 500 into a 404, the 500 having been an accident of diesel refusing the empty changeset before anything checked existence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A many-to-many between devices and servers, trigger-maintained off every status insert. The identity-to-machine link is a single column on the machine, so it has nothing left to model. Dropped in the order Postgres will not enforce: trigger, then function, then table. A PL/pgSQL body is text, not a parsed dependency, so dropping the table first succeeds and leaves the trigger to fail on the next status push — every status push, for every reporter. statuses is partitioned and the trigger lived on the parent, so one DROP covers every partition and any created later. The whole slice goes with it: the model reader, the /api/devices/get_past_server_associations endpoint and its OpenAPI path, the generated TS, the "Past server associations" panel, the device-merge fix-up, and the seeder's truncate entry. The staleness anchor moves to machines.registered_at, joined in rather than fetched separately. Anchored on the box because the box is what gets backed up: anchoring on an application's registration would restart a machine's deadline every time a workload was added to it. Both cases now have tests. Behaviour change, not a like-for-like swap: min_first_seen was effectively "first status this application ever pushed", and enrolment precedes first push, so the anchor shifts earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CheckSubject names the 18 checks that describe the box rather than the workload on it, and ingest grades and files each check at its own grain from one unified payload. Whole names, never prefixes: caddy_version is the box's while caddy_certs is the workload's, and ips is the box's addresses while ips_errors is a Tamanu error stream. Anything unrecognised is the application's. All five scouted traps were real: - raise_machine_event_with_state now takes the source. Its group and canopy-wide siblings assume canopy because they file what canopy determines for itself; a machine's checks come from alertd, and recording them under canopy would break per-source silences, the check_severities a push answers with, source staleness, and same-source recovery. - The debug assertion rejecting non-canopy filings outside application scope now admits a machine's. - silenced_health_checks_for_server covers the machine grain, so a silence on a machine check reaches the agent instead of holding only on canopy's side. - enqueue_incident_reeval keys on the application, so the machine path evaluates its incident inline. - Recovery bookkeeping is per grain. One shared previously-active set would make a check that moves grain read as unmentioned on the grain it left, closing and reopening it every push. The machine filing path carries device_id, so a machine issue records which reporter filed it. The detail-field split is now its own plan step: every figure read goes through server_reported_detail, and osName/osVersion/munin/bestoolVersion are machine-subject but read from the application's row, so the table split and the figure reads have to move together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
server_reported_detail becomes application_reported_detail, and machine_reported_detail joins it. Existing rows are split in place: the box's fields move to its machine and come out of the application's body, so each fact is stored once. A host running two workloads records its platform once rather than once per workload. Reads did not change, which is what made this tractable. for_server returns an application's own detail merged with its machine's, so every figure consumer sees the view it saw before; the storage is what moved. The plan's worry — that every figure read goes through one table, with osName/osVersion/munin/ bestoolVersion machine-subject but read from the application's row — is answered by merging on read rather than by moving the readers. version stays with the application and has no machine counterpart: a version is what the workload runs. The agent's own version is a detail field like any other and goes to the box. The field list lives beside the check-subject list in commons_types::subject, since both answer the same question; the module is renamed from check_subject to say so. One regression introduced and fixed: merging on read made for_server error for an application that no longer exists, where it used to return nothing. A deleted application has no detail rather than being an error. An existing test caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.