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:
co-authored by
Claude Opus 5.5
parent
866c0cbdcb
commit
d5511b3286
@@ -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).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+16
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+323
-5
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user