feat: M4 contracts for push deployments, diff, rollback and live events

ADR 0022, OpenAPI additions (agent mode, deploy, rollback, diff, pending
changes, server-sent events), deployment_id in GetDesiredStateResponse
for pushes picked up by polling, and the textdiff package (Myers,
unified format).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:53:21 +02:00
co-authored by Claude Opus 5.5
parent 866c0cbdcb
commit d5511b3286
8 changed files with 936 additions and 13 deletions
+49
View File
@@ -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).
+1
View File
@@ -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 |
+4
View File
@@ -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 {
+16 -4
View File
@@ -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" +
+187 -4
View File
@@ -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
+242
View File
@@ -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)
}
+114
View File
@@ -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)
})
}
Generated Vendored
+323 -5
View File
@@ -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?: {