diff --git a/docs/adr/0022-ausrollen-pull-push-m4.md b/docs/adr/0022-ausrollen-pull-push-m4.md new file mode 100644 index 0000000..64aafa8 --- /dev/null +++ b/docs/adr/0022-ausrollen-pull-push-m4.md @@ -0,0 +1,49 @@ +# 0022 – Ausrollen: Betriebsmodi, Push, Änderungsübersicht, Diff, Rollback, Live-Status (M4) + +- **Status:** Akzeptiert +- **Datum:** 2026-10-13 + +## Kontext + +M3 rollt jede neue Revision sofort auf alle Agents aus. M4 verlangt laut Brief Betriebsmodi pro Agent, „Jetzt ausrollen“ aus dem UI, Entwürfe, eine Diff-Ansicht, den Deployment-Status live und Rollback über das UI. Grundlage sind ADR 0007, 0009 und 0021. + +## Entscheidung + +### Betriebsmodi und Soll-Revision + +- `auto_pull` (Standard): Soll-Revision ist immer die neueste Revision; neue Revisionen werden sofort gepusht (wie M3). +- `manual`: Soll-Revision ist die zuletzt ausdrücklich für diesen Agent ausgerollte Revision (`agents.target_revision`, anfangs 0). Ohne Push ändert sich nichts. +- Umschalten über `PATCH /agents/{id}` (`mode`, Recht `settings.admin`). Der Server teilt dem verbundenen Agent den neuen Modus über ein erneutes `Welcome` mit (Stream wird dazu neu aufgebaut: der Hub trennt die Verbindung, der Agent verbindet sich sofort neu). + +### Push („Jetzt ausrollen“) + +- `POST /revisions/{n}/deploy` (Recht `deploy`) mit optionaler Agent-Liste (leer = alle nicht gesperrten Agents). Für jeden Agent legt der Server ein Deployment im Zustand `pending` mit eigener ID an, setzt bei `manual`-Agents `target_revision = n` und sendet `ApplyRevisionCommand(n, deployment_id)` über den Stream, falls verbunden. +- Ältere Revisionen als die neueste dürfen nicht auf `auto_pull`-Agents gepusht werden (sie würden beim nächsten Abruf wieder auf die neueste wechseln): Fehler `rollback_required`; dafür gibt es den Rollback. +- **Nicht verbundene Agents** erhalten offene Push-Aufträge beim nächsten Verbindungsaufbau (der Server sendet die offenen `pending`-Deployments erneut) bzw. beim Polling: `GetDesiredStateResponse.deployment_id` ist gesetzt, solange ein Push offen ist; der Agent behandelt die Soll-Revision dann als Push, auch im Modus `manual`. +- `ReportDeployment` mit dieser ID schließt das Deployment ab (Ergebnis wie bisher). Berichte ohne ID (Pulls) werden wie in M3 als neue Zeile gespeichert. + +### Änderungsübersicht („Entwürfe“) + +- IPAM-Änderungen wirken sofort in der Datenbank; ausgerollt wird erst mit einer Revision. Eine eigene `pending_change`-Tabelle ist deshalb unnötig: Die offenen Änderungen ergeben sich aus dem Audit-Log seit der neuesten Revision, der Unterschied in der Konfiguration aus dem Vergleich „jetzt erzeugt“ gegen „neueste Revision“ (erzeugt mit deren Nummer, damit die SOA-Seriennummer keinen Unterschied vortäuscht). +- `GET /config/pending` liefert beides; die Oberfläche zeigt „X Änderungen warten auf Ausrollen“ in der Kopfzeile. + +### Diff + +- `GET /revisions/{n}/diff?against=m` (Standard m = n−1) liefert pro Datei Status (`added`, `removed`, `modified`) und einen Unified-Diff (Paket `server/internal/textdiff`, Myers-Algorithmus, 3 Kontextzeilen). Unveränderte Dateien werden ausgelassen. + +### Rollback + +- `POST /revisions/{n}/rollback` erzeugt eine **neue** Revision mit dem Inhalt von n (ADR 0009). Damit die SOA-Seriennummer trotzdem steigt, wird nicht der alte Text kopiert, sondern die gespeicherte **Generator-Eingabe** von n (`config_revisions.source`, JSON, ab Migration 00004) mit der neuen Nummer erneut gerendert. Revisionen ohne gespeicherte Eingabe (vor M4) können nicht zurückgerollt werden (`rollback_unavailable`). +- Danach gilt die neue Revision wie jede andere: `auto_pull`-Agents erhalten sie sofort, `manual`-Agents nach einem Push. + +### Live-Status + +- `GET /events` liefert einen **Server-Sent-Events**-Strom für angemeldete Benutzer (Recht `ipam.read`). Ereignisse enthalten nur Kennungen, keine Daten: `agent` (`id`), `deployment` (`id`, `agent_id`, `revision`), `revision` (`number`), `pending`. Die Oberfläche lädt daraufhin die betroffenen Abfragen neu und fällt bei Verbindungsabbruch auf Polling zurück. Alle 25 s ein Kommentar als Keepalive. +- SSE statt WebSocket: nur Server→Browser nötig, funktioniert mit Sitzungscookie, Proxys und `EventSource` ohne Zusatzbibliothek; der Endpunkt nutzt die bestehende Sitzungsprüfung. +- Quelle ist ein prozessinterner Ereignisbus (`server/internal/events`), den Dienst und Agent-API bei Änderungen benachrichtigen. Mehrere Server-Instanzen sind nicht vorgesehen (ADR 0006). + +## Konsequenzen + +- Revisionen bleiben unveränderlich, Rollbacks sind nachvollziehbar und die Seriennummer steigt immer. +- Die Oberfläche braucht kein Polling mehr für Agents und Deployments; Polling bleibt als Rückfall. +- Ein offener Push für einen dauerhaft abgeschalteten Agent bleibt `pending`, bis der Agent sich meldet, gelöscht wird oder ein neuer Push ihn ersetzt (ältere offene Deployments desselben Agents werden dabei als `superseded` abgeschlossen). diff --git a/docs/adr/README.md b/docs/adr/README.md index 6721ea3..1970cd3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Jede Grundsatzentscheidung wird hier als kurze ADR festgehalten. Format siehe [0 | [0019](0019-openapi-vertragstests.md) | OpenAPI: Vertragstests statt generierter Server-Stubs | Akzeptiert | | [0020](0020-authentifizierung-sitzungen.md) | Authentifizierung, Sitzungen und Rollen | Akzeptiert | | [0021](0021-agent-strecke-m3.md) | Agent-Strecke: Revisionen, PKI, Ablauf im Agent (M3) | Akzeptiert | +| [0022](0022-ausrollen-pull-push-m4.md) | Ausrollen: Betriebsmodi, Push, Änderungsübersicht, Diff, Rollback, Live-Status (M4) | Akzeptiert | diff --git a/proto/ddi/agent/v1/agent.proto b/proto/ddi/agent/v1/agent.proto index c63dd3a..d232b29 100644 --- a/proto/ddi/agent/v1/agent.proto +++ b/proto/ddi/agent/v1/agent.proto @@ -192,6 +192,10 @@ message GetDesiredStateResponse { uint64 desired_revision = 1; OperatingMode mode = 2; google.protobuf.Duration pull_interval = 3; + // Set while an explicit push of desired_revision is open (not yet + // reported). The agent then applies desired_revision as a push with this + // deployment ID, also in manual mode (docs/adr/0022-ausrollen-pull-push-m4.md). + string deployment_id = 4; } message FetchBundleRequest { diff --git a/proto/gen/ddi/agent/v1/agent.pb.go b/proto/gen/ddi/agent/v1/agent.pb.go index ba3a469..51c235c 100644 --- a/proto/gen/ddi/agent/v1/agent.pb.go +++ b/proto/gen/ddi/agent/v1/agent.pb.go @@ -1416,8 +1416,12 @@ type GetDesiredStateResponse struct { DesiredRevision uint64 `protobuf:"varint,1,opt,name=desired_revision,json=desiredRevision,proto3" json:"desired_revision,omitempty"` Mode OperatingMode `protobuf:"varint,2,opt,name=mode,proto3,enum=ddi.agent.v1.OperatingMode" json:"mode,omitempty"` PullInterval *durationpb.Duration `protobuf:"bytes,3,opt,name=pull_interval,json=pullInterval,proto3" json:"pull_interval,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Set while an explicit push of desired_revision is open (not yet + // reported). The agent then applies desired_revision as a push with this + // deployment ID, also in manual mode (docs/adr/0022-ausrollen-pull-push-m4.md). + DeploymentId string `protobuf:"bytes,4,opt,name=deployment_id,json=deploymentId,proto3" json:"deployment_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetDesiredStateResponse) Reset() { @@ -1471,6 +1475,13 @@ func (x *GetDesiredStateResponse) GetPullInterval() *durationpb.Duration { return nil } +func (x *GetDesiredStateResponse) GetDeploymentId() string { + if x != nil { + return x.DeploymentId + } + return "" +} + type FetchBundleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Revision uint64 `protobuf:"varint,1,opt,name=revision,proto3" json:"revision,omitempty"` @@ -2053,11 +2064,12 @@ const file_ddi_agent_v1_agent_proto_rawDesc = "" + "\baccepted\x18\x02 \x01(\bR\baccepted\x12\x18\n" + "\amessage\x18\x03 \x01(\tR\amessage\"C\n" + "\x16GetDesiredStateRequest\x12)\n" + - "\x10applied_revision\x18\x01 \x01(\x04R\x0fappliedRevision\"\xb5\x01\n" + + "\x10applied_revision\x18\x01 \x01(\x04R\x0fappliedRevision\"\xda\x01\n" + "\x17GetDesiredStateResponse\x12)\n" + "\x10desired_revision\x18\x01 \x01(\x04R\x0fdesiredRevision\x12/\n" + "\x04mode\x18\x02 \x01(\x0e2\x1b.ddi.agent.v1.OperatingModeR\x04mode\x12>\n" + - "\rpull_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\fpullInterval\"0\n" + + "\rpull_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\fpullInterval\x12#\n" + + "\rdeployment_id\x18\x04 \x01(\tR\fdeploymentId\"0\n" + "\x12FetchBundleRequest\x12\x1a\n" + "\brevision\x18\x01 \x01(\x04R\brevision\"\x85\x01\n" + "\x13FetchBundleResponse\x12\x1a\n" + diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 121d721..6dbf824 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -504,6 +504,40 @@ paths: schema: { $ref: "#/components/schemas/ConfigPreview" } default: { $ref: "#/components/responses/Problem" } + /config/pending: + get: + tags: [config] + operationId: getPendingChanges + description: | + Changes since the latest revision: audit entries of the data the + generator uses and the difference between the configuration + generated now and the latest revision (generated with the latest + revision's number, so the SOA serial does not count as a change). + responses: + "200": + description: Pending changes. + content: + application/json: + schema: { $ref: "#/components/schemas/PendingChanges" } + default: { $ref: "#/components/responses/Problem" } + + /events: + get: + tags: [system] + operationId: streamEvents + description: | + Server-Sent Events for live updates (ADR 0022). Events carry IDs + only: "agent" {"id"}, "deployment" {"id","agent_id","revision"}, + "revision" {"number"}, "pending" {}. A comment line is sent every + 25 seconds as keepalive. The stream ends with the session. + responses: + "200": + description: Event stream. + content: + text/event-stream: + schema: { type: string } + default: { $ref: "#/components/responses/Problem" } + /agents: get: tags: [agents] @@ -534,6 +568,29 @@ paths: application/json: schema: { $ref: "#/components/schemas/Agent" } default: { $ref: "#/components/responses/Problem" } + patch: + tags: [agents] + operationId: updateAgent + description: | + Changes the operating mode (ADR 0022). A connected agent is + reconnected so that it receives the new mode. + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [mode] + properties: + mode: { $ref: "#/components/schemas/AgentMode" } + responses: + "200": + description: The updated agent. + content: + application/json: + schema: { $ref: "#/components/schemas/Agent" } + default: { $ref: "#/components/responses/Problem" } delete: tags: [agents] operationId: deleteAgent @@ -674,6 +731,81 @@ paths: schema: { $ref: "#/components/schemas/Revision" } default: { $ref: "#/components/responses/Problem" } + /revisions/{number}/deploy: + parameters: + - { name: number, in: path, required: true, schema: { type: integer, minimum: 1 } } + post: + tags: [revisions] + operationId: deployRevision + description: | + Pushes the revision to agents now (ADR 0022). Without agent_ids, all + agents that are not revoked are targeted. Agents in manual mode get + the revision as their target; agents in auto_pull mode accept only + the latest revision (otherwise 422 rollback_required). Agents that + are not connected receive the push when they reconnect or poll. + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + agent_ids: { type: array, items: { type: string } } + responses: + "200": + description: One pending deployment per agent. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [items] + properties: + items: { type: array, items: { $ref: "#/components/schemas/DeployTarget" } } + default: { $ref: "#/components/responses/Problem" } + + /revisions/{number}/rollback: + parameters: + - { name: number, in: path, required: true, schema: { type: integer, minimum: 1 } } + post: + tags: [revisions] + operationId: rollbackRevision + description: | + Creates a new revision with the content of the given one, rendered + again with the new number so that SOA serials keep increasing + (ADR 0009, 0022). Revisions created before M4 have no stored + generator input: 409 rollback_unavailable. + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + comment: { type: string, maxLength: 500 } + responses: + "201": + description: The new revision. + content: + application/json: + schema: { $ref: "#/components/schemas/Revision" } + default: { $ref: "#/components/responses/Problem" } + + /revisions/{number}/diff: + parameters: + - { name: number, in: path, required: true, schema: { type: integer, minimum: 1 } } + - { name: against, in: query, schema: { type: integer, minimum: 0 }, description: "Revision to compare with; default number - 1, 0 = empty." } + get: + tags: [revisions] + operationId: diffRevision + responses: + "200": + description: Changed files with unified diffs. + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionDiff" } + default: { $ref: "#/components/responses/Problem" } + /deployments: get: tags: [revisions] @@ -1559,7 +1691,7 @@ components: type: object additionalProperties: false required: [id, role, hostname, agent_version, service_version, connected, mode, enrolled_at, cert_expires_at, - applied_revision, last_good_revision, desired_revision, health] + applied_revision, last_good_revision, desired_revision, target_revision, health] properties: id: { type: string } role: { $ref: "#/components/schemas/AgentRole" } @@ -1575,6 +1707,7 @@ components: applied_revision: { type: integer, description: "Active revision on the agent, 0 = none." } last_good_revision: { type: integer } desired_revision: { type: integer, description: "Revision the agent should run, 0 = none." } + target_revision: { type: integer, description: "Last revision pushed explicitly (manual mode), 0 = none." } health: { $ref: "#/components/schemas/AgentHealth" } revoked_at: { type: string, format: date-time } last_deployment: { $ref: "#/components/schemas/Deployment" } @@ -1633,18 +1766,22 @@ components: Deployment: type: object additionalProperties: false - required: [id, agent_id, agent_hostname, revision, trigger, outcome, message, active_revision, started_at, finished_at] + required: [id, agent_id, agent_hostname, revision, trigger, outcome, message, active_revision, started_at] properties: id: { type: integer } agent_id: { type: string } agent_hostname: { type: string } revision: { type: integer } trigger: { type: string, enum: [pull, push] } - outcome: { type: string, enum: [succeeded, rejected, rolled_back, failed] } + outcome: + type: string + enum: [pending, succeeded, rejected, rolled_back, failed, superseded] + description: "pending: pushed, no report yet; superseded: replaced by a newer push before the agent reported." message: { type: string, description: "Details from the agent (English), e.g. checker output." } active_revision: { type: integer } started_at: { type: string, format: date-time } - finished_at: { type: string, format: date-time } + finished_at: { type: string, format: date-time, description: "Absent while pending." } + requested_by_name: { type: string, description: "User who pushed (push deployments created by the server)." } LeaseState: type: string @@ -1667,6 +1804,52 @@ components: agent_id: { type: string } reported_at: { type: string, format: date-time } + DeployTarget: + type: object + additionalProperties: false + required: [agent_id, agent_hostname, deployment_id, sent] + properties: + agent_id: { type: string } + agent_hostname: { type: string } + deployment_id: { type: integer } + sent: { type: boolean, description: "Delivered over an open stream now; otherwise on reconnect or poll." } + + FileDiff: + type: object + additionalProperties: false + required: [role, path, status, diff, inserted, deleted] + properties: + role: { $ref: "#/components/schemas/AgentRole" } + path: { type: string } + status: { type: string, enum: [added, removed, modified] } + diff: { type: string, description: "Unified diff hunks (no file header)." } + inserted: { type: integer } + deleted: { type: integer } + + RevisionDiff: + type: object + additionalProperties: false + required: [number, against, files] + properties: + number: { type: integer } + against: { type: integer } + files: { type: array, items: { $ref: "#/components/schemas/FileDiff" } } + + PendingChanges: + type: object + additionalProperties: false + required: [latest_revision, changed, files, changes] + properties: + latest_revision: { type: integer, description: "0 = no revision yet." } + changed: { type: boolean, description: "The generated configuration differs from the latest revision." } + files: { type: array, items: { $ref: "#/components/schemas/FileDiff" } } + changes: + type: array + description: Audit entries since the latest revision that affect the configuration, newest first (at most 200). + items: { $ref: "#/components/schemas/AuditEntry" } + dns_error: { $ref: "#/components/schemas/GeneratorProblem" } + dhcp_error: { $ref: "#/components/schemas/GeneratorProblem" } + SavedFilter: type: object additionalProperties: false diff --git a/server/internal/textdiff/textdiff.go b/server/internal/textdiff/textdiff.go new file mode 100644 index 0000000..6a790a1 --- /dev/null +++ b/server/internal/textdiff/textdiff.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary + +// Package textdiff computes line diffs of generated configuration files and +// renders them in unified format (as `diff -u`). It uses the Myers algorithm +// after trimming the common prefix and suffix; generated files usually differ +// in a few lines only (SOA serial, changed records). +package textdiff + +import ( + "fmt" + "strings" +) + +// Op is the kind of a diff line. +type Op byte + +// Line operations, rendered as the first character of a unified diff line. +const ( + Equal Op = ' ' + Delete Op = '-' + Insert Op = '+' +) + +// Line is one line of a diff. +type Line struct { + Op Op + Text string +} + +// maxEdits bounds time and memory (the trace keeps one copy of V per edit, +// about 16 MB at this limit) for pathological inputs: beyond it, the remaining +// middle part is reported as deleted and inserted in full (still correct, +// just not minimal). +const maxEdits = 1000 + +// Lines diffs a and b line by line. Lines are split at "\n"; a missing final +// newline is not reported separately. +func Lines(a, b string) []Line { + return diff(split(a), split(b)) +} + +func split(s string) []string { + if s == "" { + return nil + } + return strings.Split(strings.TrimSuffix(s, "\n"), "\n") +} + +func diff(a, b []string) []Line { + // Common prefix and suffix. + pre := 0 + for pre < len(a) && pre < len(b) && a[pre] == b[pre] { + pre++ + } + suf := 0 + for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] { + suf++ + } + out := make([]Line, 0, len(a)+len(b)) + for _, s := range a[:pre] { + out = append(out, Line{Equal, s}) + } + out = append(out, myers(a[pre:len(a)-suf], b[pre:len(b)-suf])...) + for _, s := range a[len(a)-suf:] { + out = append(out, Line{Equal, s}) + } + return out +} + +// myers returns a minimal edit script (or a full replacement when the edit +// distance exceeds maxEdits). +func myers(a, b []string) []Line { + n, m := len(a), len(b) + if n == 0 || m == 0 { + return replace(a, b) + } + maxD := n + m + if maxD > maxEdits { + maxD = maxEdits + } + off := maxD + 1 + v := make([]int, 2*maxD+3) + var trace [][]int + for d := 0; d <= maxD; d++ { + snapshot := make([]int, len(v)) + copy(snapshot, v) + trace = append(trace, snapshot) + for k := -d; k <= d; k += 2 { + var x int + if k == -d || (k != d && v[off+k-1] < v[off+k+1]) { + x = v[off+k+1] // down: insertion + } else { + x = v[off+k-1] + 1 // right: deletion + } + y := x - k + for x < n && y < m && a[x] == b[y] { + x++ + y++ + } + v[off+k] = x + if x >= n && y >= m { + return backtrack(a, b, trace, off, d) + } + } + } + return replace(a, b) +} + +func backtrack(a, b []string, trace [][]int, off, dEnd int) []Line { + x, y := len(a), len(b) + var rev []Line + for d := dEnd; d > 0; d-- { + v := trace[d] + k := x - y + var prevK int + if k == -d || (k != d && v[off+k-1] < v[off+k+1]) { + prevK = k + 1 + } else { + prevK = k - 1 + } + prevX := v[off+prevK] + prevY := prevX - prevK + for x > prevX && y > prevY { + x-- + y-- + rev = append(rev, Line{Equal, a[x]}) + } + if x == prevX { + y-- + rev = append(rev, Line{Insert, b[y]}) + } else { + x-- + rev = append(rev, Line{Delete, a[x]}) + } + } + for x > 0 && y > 0 { + x-- + y-- + rev = append(rev, Line{Equal, a[x]}) + } + out := make([]Line, len(rev)) + for i, l := range rev { + out[len(rev)-1-i] = l + } + return out +} + +func replace(a, b []string) []Line { + out := make([]Line, 0, len(a)+len(b)) + for _, s := range a { + out = append(out, Line{Delete, s}) + } + for _, s := range b { + out = append(out, Line{Insert, s}) + } + return out +} + +// Stats counts inserted and deleted lines. +func Stats(lines []Line) (inserted, deleted int) { + for _, l := range lines { + switch l.Op { + case Insert: + inserted++ + case Delete: + deleted++ + } + } + return inserted, deleted +} + +// Unified renders the diff of a and b with the given number of context lines +// as hunks ("@@ -l,s +l,s @@" headers, no file header). Equal inputs yield +// "". +func Unified(a, b string, context int) string { + lines := Lines(a, b) + var sb strings.Builder + // Positions (1-based line numbers) before each diff line. + type pos struct{ a, b int } + at := make([]pos, len(lines)+1) + pa, pb := 1, 1 + for i, l := range lines { + at[i] = pos{pa, pb} + if l.Op != Insert { + pa++ + } + if l.Op != Delete { + pb++ + } + } + at[len(lines)] = pos{pa, pb} + + for i := 0; i < len(lines); { + if lines[i].Op == Equal { + i++ + continue + } + // Hunk: from the first change minus context to the last change of + // a run whose gaps are at most 2*context, plus context. + start := max(0, i-context) + end := i + for j := i; j < len(lines); j++ { + if lines[j].Op != Equal { + end = j + continue + } + if j-end > 2*context { + break + } + } + stop := min(len(lines), end+1+context) + var na, nb int + for _, l := range lines[start:stop] { + if l.Op != Insert { + na++ + } + if l.Op != Delete { + nb++ + } + } + fmt.Fprintf(&sb, "@@ -%s +%s @@\n", rangeStr(at[start].a, na), rangeStr(at[start].b, nb)) + for _, l := range lines[start:stop] { + sb.WriteByte(byte(l.Op)) + sb.WriteString(l.Text) + sb.WriteByte('\n') + } + i = stop + } + return sb.String() +} + +// rangeStr formats a hunk range like diff(1): an empty range starts one line +// earlier. +func rangeStr(start, count int) string { + if count == 0 { + start-- + } + if count == 1 { + return fmt.Sprint(start) + } + return fmt.Sprintf("%d,%d", start, count) +} diff --git a/server/internal/textdiff/textdiff_test.go b/server/internal/textdiff/textdiff_test.go new file mode 100644 index 0000000..e90805b --- /dev/null +++ b/server/internal/textdiff/textdiff_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary + +package textdiff + +import ( + "strings" + "testing" +) + +// apply rebuilds both sides from a diff. +func apply(lines []Line) (a, b []string) { + for _, l := range lines { + if l.Op != Insert { + a = append(a, l.Text) + } + if l.Op != Delete { + b = append(b, l.Text) + } + } + return a, b +} + +func TestLinesReconstructsBothSides(t *testing.T) { + cases := [][2]string{ + {"", ""}, + {"", "a\nb\n"}, + {"a\nb\n", ""}, + {"a\nb\nc\n", "a\nb\nc\n"}, + {"a\nb\nc\n", "a\nx\nc\n"}, + {"a\nb\nc\nd\ne\n", "b\nc\nf\nd\n"}, + {"x\ny\nz", "x\nq\ny\nz"}, + } + for _, c := range cases { + lines := Lines(c[0], c[1]) + a, b := apply(lines) + if strings.Join(a, "\n") != strings.Join(split(c[0]), "\n") || + strings.Join(b, "\n") != strings.Join(split(c[1]), "\n") { + t.Errorf("%q -> %q: diff %+v does not reconstruct the inputs", c[0], c[1], lines) + } + } +} + +func TestLinesIsMinimal(t *testing.T) { + ins, del := Stats(Lines("a\nb\nc\nd\ne\nf\n", "a\nb\nX\nd\ne\nf\nY\n")) + if ins != 2 || del != 1 { + t.Errorf("inserted %d, deleted %d; want 2 and 1", ins, del) + } + ins, del = Stats(Lines("a\nb\nc\nd\n", "b\nc\nd\na\n")) + if ins != 1 || del != 1 { + t.Errorf("rotation: inserted %d, deleted %d; want 1 and 1", ins, del) + } +} + +func TestUnified(t *testing.T) { + a := "$TTL 3600\n@ IN SOA ns1 hostmaster 1 3600 600 1209600 300\n@ IN NS ns1\nns1 IN A 192.0.2.53\n" + + "www IN A 192.0.2.10\nz1 IN A 192.0.2.101\nz2 IN A 192.0.2.102\nz3 IN A 192.0.2.103\nz4 IN A 192.0.2.104\n" + + "z5 IN A 192.0.2.105\nz6 IN A 192.0.2.106\nz7 IN A 192.0.2.107\n" + b := strings.Replace(a, "hostmaster 1 ", "hostmaster 2 ", 1) + b = strings.Replace(b, "z7 IN A 192.0.2.107\n", "z7 IN A 192.0.2.107\nmail IN A 192.0.2.20\n", 1) + got := Unified(a, b, 3) + want := "@@ -1,5 +1,5 @@\n" + + " $TTL 3600\n" + + "-@ IN SOA ns1 hostmaster 1 3600 600 1209600 300\n" + + "+@ IN SOA ns1 hostmaster 2 3600 600 1209600 300\n" + + " @ IN NS ns1\n" + + " ns1 IN A 192.0.2.53\n" + + " www IN A 192.0.2.10\n" + + "@@ -10,3 +10,4 @@\n" + + " z5 IN A 192.0.2.105\n" + + " z6 IN A 192.0.2.106\n" + + " z7 IN A 192.0.2.107\n" + + "+mail IN A 192.0.2.20\n" + if got != want { + t.Errorf("Unified:\n%s\nwant:\n%s", got, want) + } + if Unified(a, a, 3) != "" { + t.Error("equal inputs produced a diff") + } + if got := Unified("", "x\n", 3); got != "@@ -0,0 +1 @@\n+x\n" { + t.Errorf("new file: %q", got) + } + if got := Unified("x\n", "", 3); got != "@@ -1 +0,0 @@\n-x\n" { + t.Errorf("removed file: %q", got) + } + // Changes close together share one hunk. + if got := Unified("1\n2\n3\n4\n5\n", "1\nX\n3\nY\n5\n", 1); strings.Count(got, "@@ -") != 1 { + t.Errorf("close changes split into several hunks:\n%s", got) + } +} + +func TestLargeInputFallsBack(t *testing.T) { + var a, b strings.Builder + for i := 0; i < 3*maxEdits; i++ { + a.WriteString("a\n") + b.WriteString("b\n") + } + lines := Lines(a.String(), b.String()) + ins, del := Stats(lines) + if ins != 3*maxEdits || del != 3*maxEdits { + t.Errorf("inserted %d, deleted %d", ins, del) + } +} + +func FuzzLines(f *testing.F) { + f.Add("a\nb\nc\n", "a\nc\nd\n") + f.Add("", "x") + f.Fuzz(func(t *testing.T, a, b string) { + x, y := apply(Lines(a, b)) + if strings.Join(x, "\n") != strings.Join(split(a), "\n") || strings.Join(y, "\n") != strings.Join(split(b), "\n") { + t.Fatalf("diff of %q and %q does not reconstruct the inputs", a, b) + } + _ = Unified(a, b, 3) + }) +} diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 9c5d728..8fcf9a3 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -365,6 +365,50 @@ export interface paths { patch?: never; trace?: never; }; + "/config/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description Changes since the latest revision: audit entries of the data the + * generator uses and the difference between the configuration + * generated now and the latest revision (generated with the latest + * revision's number, so the SOA serial does not count as a change). + */ + get: operations["getPendingChanges"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description Server-Sent Events for live updates (ADR 0022). Events carry IDs + * only: "agent" {"id"}, "deployment" {"id","agent_id","revision"}, + * "revision" {"number"}, "pending" {}. A comment line is sent every + * 25 seconds as keepalive. The stream ends with the session. + */ + get: operations["streamEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/agents": { parameters: { query?: never; @@ -397,7 +441,11 @@ export interface paths { delete: operations["deleteAgent"]; options?: never; head?: never; - patch?: never; + /** + * @description Changes the operating mode (ADR 0022). A connected agent is + * reconnected so that it receives the new mode. + */ + patch: operations["updateAgent"]; trace?: never; }; "/agents/{id}/revoke": { @@ -499,6 +547,76 @@ export interface paths { patch?: never; trace?: never; }; + "/revisions/{number}/deploy": { + parameters: { + query?: never; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @description Pushes the revision to agents now (ADR 0022). Without agent_ids, all + * agents that are not revoked are targeted. Agents in manual mode get + * the revision as their target; agents in auto_pull mode accept only + * the latest revision (otherwise 422 rollback_required). Agents that + * are not connected receive the push when they reconnect or poll. + */ + post: operations["deployRevision"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/revisions/{number}/rollback": { + parameters: { + query?: never; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @description Creates a new revision with the content of the given one, rendered + * again with the new number so that SOA serials keep increasing + * (ADR 0009, 0022). Revisions created before M4 have no stored + * generator input: 409 rollback_unavailable. + */ + post: operations["rollbackRevision"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/revisions/{number}/diff": { + parameters: { + query?: { + /** @description Revision to compare with; default number - 1, 0 = empty. */ + against?: number; + }; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + get: operations["diffRevision"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/deployments": { parameters: { query?: never; @@ -1080,6 +1198,8 @@ export interface components { last_good_revision: number; /** @description Revision the agent should run, 0 = none. */ desired_revision: number; + /** @description Last revision pushed explicitly (manual mode), 0 = none. */ + target_revision: number; health: components["schemas"]["AgentHealth"]; /** Format: date-time */ revoked_at?: string; @@ -1137,15 +1257,23 @@ export interface components { revision: number; /** @enum {string} */ trigger: "pull" | "push"; - /** @enum {string} */ - outcome: "succeeded" | "rejected" | "rolled_back" | "failed"; + /** + * @description pending: pushed, no report yet; superseded: replaced by a newer push before the agent reported. + * @enum {string} + */ + outcome: "pending" | "succeeded" | "rejected" | "rolled_back" | "failed" | "superseded"; /** @description Details from the agent (English), e.g. checker output. */ message: string; active_revision: number; /** Format: date-time */ started_at: string; - /** Format: date-time */ - finished_at: string; + /** + * Format: date-time + * @description Absent while pending. + */ + finished_at?: string; + /** @description User who pushed (push deployments created by the server). */ + requested_by_name?: string; }; /** @enum {string} */ LeaseState: "active" | "expired" | "released" | "declined"; @@ -1169,6 +1297,39 @@ export interface components { /** Format: date-time */ reported_at: string; }; + DeployTarget: { + agent_id: string; + agent_hostname: string; + deployment_id: number; + /** @description Delivered over an open stream now; otherwise on reconnect or poll. */ + sent: boolean; + }; + FileDiff: { + role: components["schemas"]["AgentRole"]; + path: string; + /** @enum {string} */ + status: "added" | "removed" | "modified"; + /** @description Unified diff hunks (no file header). */ + diff: string; + inserted: number; + deleted: number; + }; + RevisionDiff: { + number: number; + against: number; + files: components["schemas"]["FileDiff"][]; + }; + PendingChanges: { + /** @description 0 = no revision yet. */ + latest_revision: number; + /** @description The generated configuration differs from the latest revision. */ + changed: boolean; + files: components["schemas"]["FileDiff"][]; + /** @description Audit entries since the latest revision that affect the configuration, newest first (at most 200). */ + changes: components["schemas"]["AuditEntry"][]; + dns_error?: components["schemas"]["GeneratorProblem"]; + dhcp_error?: components["schemas"]["GeneratorProblem"]; + }; SavedFilter: { id: string; name: string; @@ -1924,6 +2085,48 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + getPendingChanges: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Pending changes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PendingChanges"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + streamEvents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Event stream. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + default: components["responses"]["Problem"]; + }; + }; listAgents: { parameters: { query?: never; @@ -1991,6 +2194,35 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + updateAgent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + mode: components["schemas"]["AgentMode"]; + }; + }; + }; + responses: { + /** @description The updated agent. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Agent"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; revokeAgent: { parameters: { query?: never; @@ -2175,6 +2407,92 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + deployRevision: { + parameters: { + query?: never; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + agent_ids?: string[]; + }; + }; + }; + responses: { + /** @description One pending deployment per agent. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["DeployTarget"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + rollbackRevision: { + parameters: { + query?: never; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + comment?: string; + }; + }; + }; + responses: { + /** @description The new revision. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Revision"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + diffRevision: { + parameters: { + query?: { + /** @description Revision to compare with; default number - 1, 0 = empty. */ + against?: number; + }; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Changed files with unified diffs. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RevisionDiff"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; listDeployments: { parameters: { query?: {