From 9f4b6c2c5a1fd2ccb5ae3ce1c7e6066a59fd2872 Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Thu, 6 Aug 2026 06:57:11 +0530 Subject: [PATCH] fix(audit,web,deploy): audit ids, web dry-run view, hardened chart, docs Audit: correlation event_ids include stage and response code so the multi-stage lines of one request no longer collapse under dedup; match scans are bounded to a time window and a page instead of the object's full history. Web: dry-run results are read from dry_run_result (nested) so the verdict is rendered correctly and conflicts/errors/skipped are shown; the plans view no longer POSTs an unsolicited plan on page load; coverage and streams surface API errors instead of showing a misleading empty state and follow cursor pagination so they are not stuck on the oldest page; the diff path tokenizer handles backslash-escaped dotted keys. Deploy/CI: the chart no longer grants the query server a cluster-wide read ClusterRole, runs as non-root with a read-only root filesystem, adds liveness/readiness probes, wires the ConfigMap as env (STORE_PATH, LISTEN_ADDR), defaults the journal to a PVC instead of an ephemeral emptyDir, and adds imagePullSecrets; the replay ClusterRole drops the unused update verb; a Dockerfile builds a static distroless image; GitHub actions are pinned by commit SHA, jobs set least-privilege permissions, the Vercel deploy skips fork PRs, CI passes the Makefile test timeouts, and make lint runs a real web syntax check. Docs: event-schema/consistency no longer describe an ingest_sequence field, an observed-time-based event_id, or restart-from-checkpoint; the threat model documents the unauthenticated HTTP API surface and the chart's RBAC change; replay-safety matches the enforced dry-run gate. --- .github/workflows/ci.yml | 37 +++++++++++++++++--- .github/workflows/vercel.yml | 10 ++++-- .gitignore | 5 +++ Dockerfile | 14 ++++++++ Makefile | 2 +- cmd/krply-server/main.go | 18 +++++++--- deploy/helm/krply/templates/configmap.yaml | 4 +-- deploy/helm/krply/templates/deployment.yaml | 38 +++++++++++++++++---- deploy/helm/krply/templates/rbac.yaml | 32 ++++++----------- deploy/helm/krply/values.yaml | 19 +++++++---- deploy/rbac/replay-clusterrole.yaml | 4 +-- docs/consistency/consistency.md | 5 +-- docs/event-schema/event-schema.md | 6 ++-- docs/replay-safety/replay-safety.md | 6 ++-- docs/threat-model/threat-model.md | 16 +++++++-- internal/audit/audit.go | 13 +++++-- internal/audit/ingest.go | 9 +++-- web/package.json | 1 + web/src/api.js | 17 +++++++++ web/src/views/coverage.js | 19 ++++++++--- web/src/views/diff.js | 33 +++++++++++++++--- web/src/views/plans.js | 29 ++++------------ web/src/views/streams.js | 10 ++++-- 23 files changed, 251 insertions(+), 96 deletions(-) create mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e012338..9ca629f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,12 @@ jobs: go: name: build and test runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: "1.26.x" cache: true @@ -31,10 +33,37 @@ jobs: run: go test ./... - name: Integration tests (fake apiserver) - run: go test -tags integration ./test/integration/... + run: go test -tags integration ./test/integration/... -timeout 20m - name: End-to-end tests - run: go test -tags e2e ./test/e2e/... + run: go test -tags e2e ./test/e2e/... -timeout 30m + + web: + name: build web ui + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + + - name: Install + working-directory: web + run: npm ci + + - name: Lint + working-directory: web + run: npm run lint + + - name: Build + working-directory: web + run: npm run build + + - name: Verify dist + run: test -d web/dist && test -f web/dist/index.html web: name: build web ui diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml index 8a915ac..72949b5 100644 --- a/.github/workflows/vercel.yml +++ b/.github/workflows/vercel.yml @@ -19,15 +19,21 @@ jobs: deploy: name: deploy web ui runs-on: ubuntu-latest + # The vercel-action receives a deploy token, so only runs on pushes where + # the repository secrets are available and readable content. Fork PRs are + # skipped because they never receive repository secrets. + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Requires repository secrets: # VERCEL_TOKEN personal access token (vercel.com/account/tokens) # VERCEL_ORG_ID org id (vercel.com -> account settings) # VERCEL_PROJECT_ID project id (vercel.com -> project settings) - name: Deploy to Vercel - uses: amondnet/vercel-action@v20 + uses: amondnet/vercel-action@2d78157ee070f28ff89dd4da74e0369fc26d3b34 # v20 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} diff --git a/.gitignore b/.gitignore index 9706a88..cfd030c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ web/dist/ .DS_Store coverage.out tmp/ +.env +*.kubeconfig +*.pem +*.key +deploy/compose/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8592b52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build krply-server as a static, CGO-free binary and run it as the non-root +# distroless user (65532). The SQLite journal must be mounted writable at +# /data (see deploy/helm and deploy/compose). +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/krply-server ./cmd/krply-server + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/krply-server /usr/local/bin/krply-server +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/krply-server"] diff --git a/Makefile b/Makefile index a4b533f..1e67bc4 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ test-e2e: lint: go vet ./... - cd web && npm run lint 2>/dev/null || true + cd web && npm run lint vet: go vet ./... diff --git a/cmd/krply-server/main.go b/cmd/krply-server/main.go index 18deea5..e26a292 100644 --- a/cmd/krply-server/main.go +++ b/cmd/krply-server/main.go @@ -79,10 +79,11 @@ func seedDemo(ctx context.Context, store storage.Store, demoPath string) error { func run() error { var ( - storePath = flag.String("store", "krply.db", "path to the SQLite journal") - listen = flag.String("listen", ":8080", "listen address") - demoPath = flag.String("demo", "", "seed an empty journal from this SQLite demo fixture") - showVer = flag.Bool("version", false, "print version and exit") + storePath = flag.String("store", "krply.db", "path to the SQLite journal") + listen = flag.String("listen", ":8080", "listen address") + demoPath = flag.String("demo", "", "seed an empty journal from this SQLite demo fixture") + showVer = flag.Bool("version", false, "print version and exit") + storeFlagSet bool ) flag.Parse() @@ -97,10 +98,19 @@ func run() error { if f.Name == "listen" { listenFlagSet = true } + if f.Name == "store" { + storeFlagSet = true + } }) if !listenFlagSet && os.Getenv("PORT") != "" { listenAddr = ":" + os.Getenv("PORT") } + if !listenFlagSet && os.Getenv("LISTEN_ADDR") != "" { + listenAddr = os.Getenv("LISTEN_ADDR") + } + if !storeFlagSet && os.Getenv("STORE_PATH") != "" { + *storePath = os.Getenv("STORE_PATH") + } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) diff --git a/deploy/helm/krply/templates/configmap.yaml b/deploy/helm/krply/templates/configmap.yaml index 97095b9..4d0a7d2 100644 --- a/deploy/helm/krply/templates/configmap.yaml +++ b/deploy/helm/krply/templates/configmap.yaml @@ -5,5 +5,5 @@ metadata: labels: {{- include "krply.labels" . | nindent 4 }} data: - store-path: {{ .Values.store.path | quote }} - listen: ":{{ .Values.listen.port }}" + STORE_PATH: {{ .Values.store.path | quote }} + LISTEN_ADDR: ":{{ .Values.listen.port }}" diff --git a/deploy/helm/krply/templates/deployment.yaml b/deploy/helm/krply/templates/deployment.yaml index f95242b..5b6b182 100644 --- a/deploy/helm/krply/templates/deployment.yaml +++ b/deploy/helm/krply/templates/deployment.yaml @@ -22,24 +22,50 @@ spec: {{- include "krply.selectorLabels" . | nindent 8 }} spec: serviceAccountName: {{ include "krply.fullname" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - args: - - --store - - {{ .Values.store.path | quote }} - - --listen - - ":{{ .Values.listen.port }}" + envFrom: + - configMapRef: + name: {{ include "krply.fullname" . }}-args ports: - name: http containerPort: {{ .Values.listen.port }} protocol: TCP + livenessProbe: + httpGet: + path: /v1/health + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /v1/health + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] resources: {{- toYaml .Values.resources | nindent 12 }} volumeMounts: - name: store - mountPath: {{ .Values.store.path | dir }} + mountPath: /data volumes: - name: store {{- if .Values.store.persistence.enabled }} diff --git a/deploy/helm/krply/templates/rbac.yaml b/deploy/helm/krply/templates/rbac.yaml index c9e3718..372c062 100644 --- a/deploy/helm/krply/templates/rbac.yaml +++ b/deploy/helm/krply/templates/rbac.yaml @@ -1,9 +1,14 @@ -# Recorder RBAC for the krply-server ServiceAccount. -# -# Read-only: get/list/watch on the exact resources krply captures. No Secrets, -# no wildcards, no write verbs. Mirrors deploy/rbac/recorder-clusterrole.yaml. -# Disable with --set rbac.create=false if you bind an existing role instead. -{{- if .Values.rbac.create }} +{{/* +krply-server is a query API, materializer, and replay planner: it reads the +SQLite journal and, only when a replay is run, resolves the target cluster +through its own in-cluster configuration. It performs no listing or watching, +so its ServiceAccount needs no ClusterRole. + +When you co-locate a `krply record` agent in this chart, bind the agent's +ServiceAccount to deploy/rbac/recorder-clusterrole.yaml instead; that role is +read-only get/list/watch on the MVP resources with no Secrets access. +*/}} +{{- if .Values.rbac.recorder.create }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -20,19 +25,4 @@ rules: - apiGroups: ["node.k8s.io"] resources: ["runtimeclasses"] verbs: ["get", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: {{ include "krply.fullname" . }}-recorder - labels: - {{- include "krply.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: {{ include "krply.fullname" . }}-recorder -subjects: - - kind: ServiceAccount - name: {{ include "krply.fullname" . }} - namespace: {{ .Release.Namespace }} {{- end }} diff --git a/deploy/helm/krply/values.yaml b/deploy/helm/krply/values.yaml index ff3419e..ca8e255 100644 --- a/deploy/helm/krply/values.yaml +++ b/deploy/helm/krply/values.yaml @@ -5,16 +5,21 @@ image: tag: dev pullPolicy: IfNotPresent +imagePullSecrets: [] + nameOverride: "" fullnameOverride: "" store: + # Path of the SQLite journal inside the container. It must live under /data, + # which is the volume mounted for the store. path: /data/krply.db - # PersistentVolumeClaim to back the store path. If empty, an emptyDir is used. + # The journal is the primary data of krply, so a PersistentVolumeClaim is the + # default. Set enabled: false to use an emptyDir (ephemeral, lost on restart). persistence: - enabled: false + enabled: true storageClass: "" - size: 10Gi + size: 1Gi listen: port: 8080 @@ -23,10 +28,12 @@ service: type: ClusterIP port: 8080 -# Create a recorder ClusterRole/Binding for the service account. See -# deploy/rbac/recorder-clusterrole.yaml for the full reference role. +# The krply-server ServiceAccount needs no RBAC (it reads the journal and +# resolves the target cluster through its in-cluster configuration). Enable +# recorder.create only when a `krply record` agent runs in this chart. rbac: - create: true + recorder: + create: false replicaCount: 1 diff --git a/deploy/rbac/replay-clusterrole.yaml b/deploy/rbac/replay-clusterrole.yaml index 877eeda..45c1942 100644 --- a/deploy/rbac/replay-clusterrole.yaml +++ b/deploy/rbac/replay-clusterrole.yaml @@ -18,10 +18,10 @@ metadata: rules: - apiGroups: [""] resources: ["namespaces", "configmaps", "services"] - verbs: ["create", "patch", "update", "get", "list"] + verbs: ["create", "patch", "get", "list"] - apiGroups: ["apps"] resources: ["deployments", "statefulsets", "daemonsets"] - verbs: ["create", "patch", "update", "get", "list"] + verbs: ["create", "patch", "get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/docs/consistency/consistency.md b/docs/consistency/consistency.md index 69ed1e5..e03f648 100644 --- a/docs/consistency/consistency.md +++ b/docs/consistency/consistency.md @@ -62,9 +62,10 @@ The journal write uses the write-event-then-checkpoint order: 1. Write the raw event first. 2. Update the checkpoint in the same transaction. -3. On restart, replay from the last checkpoint and deduplicate. -This gives at-least-once delivery with deterministic deduplication. If the process crashes between writing an event and its checkpoint, the event may be written again. The field event_id is a deterministic key derived from the stream, the resource, the watch type, and the observed time. This key makes re-application idempotent. +Within a session, a reconnect resumes from the last durable resource version, so nothing after it is missed. After a process restart the collector performs a fresh list and baseline rather than resuming from the stored checkpoint; the journal deduplicates any event that is redelivered, so the fresh baseline and its synthetic events coexist with the earlier history. + +This gives at-least-once delivery with deterministic deduplication. If the process crashes between writing an event and its checkpoint, the event may be written again. The field event_id is a deterministic key derived from the stream, the resource, and the watch type (synthetic relist baselines also mix in the list observation time so a re-listed object is a new observation, not a duplicate). This key makes re-application idempotent. These behaviors are NOT guaranteed: diff --git a/docs/event-schema/event-schema.md b/docs/event-schema/event-schema.md index bcc5d16..870651a 100644 --- a/docs/event-schema/event-schema.md +++ b/docs/event-schema/event-schema.md @@ -9,7 +9,7 @@ Every journal record is an immutable entry. The wire format is api/event/v1. Thi | cluster_id | Separates resource version and UID domains; one per physical cluster | | stream_id | Identifies group, version, resource, namespace, and selector | | event_id | Deterministic deduplication key | -| ingest_sequence | Local storage order (ascending); returned in this order | +| ingest_seq | Local storage order (ascending); returned in this order | | observed_at | Collector observation time | | watch_type | Original event type (ADDED, MODIFIED, DELETED, BOOKMARK, ERROR) | | synthetic | True for baseline events | @@ -44,11 +44,11 @@ The field event_id is the deterministic deduplication key. The derivation is: EventID(stream, resource, watchType, observedAt) -> string ``` -The inputs are the stream identity, the resource reference, the watch type, and the observed time. The same key is recomputed for a redelivered event, for example after a reconnect or a crash before the checkpoint. Re-application is therefore idempotent. This makes at-least-once ingestion safe. See ../consistency/consistency.md for the ingestion guarantee. +The inputs are the stream identity, the resource reference, and the watch type. For live watch events the observed time is deliberately excluded: the same underlying API event must always produce the same key, so a duplicate delivery after a reconnect is idempotent. Collector-generated synthetic baselines pass the list observation time, so an unchanged object re-listed after a gap is treated as a new observation rather than a duplicate and survives deduplication. See ../consistency/consistency.md for the ingestion guarantee. ## object_hash -The function ObjectHash(objectJSON) returns a fast equality and deduplication hash over the raw object payload. It lets consumers detect that a MODIFIED event did not change the object. It lets the store collapse no-op writes. It is not a cryptographic commitment. Treat it as a performance and equality helper only. +The function ObjectHash(objectJSON) returns a fast equality hash over the raw object payload. It lets consumers detect that a MODIFIED event did not change the object. It is not a cryptographic commitment. Treat it as a performance and equality helper only. Deduplication is by event_id, not by object content. ## Provenance diff --git a/docs/replay-safety/replay-safety.md b/docs/replay-safety/replay-safety.md index 84cf7a7..88e22b3 100644 --- a/docs/replay-safety/replay-safety.md +++ b/docs/replay-safety/replay-safety.md @@ -11,7 +11,7 @@ Replay does NOT mean sending historical events back to a live cluster. krply rec 5. **Sort resources by dependency**. Sort namespaces first, then declarative roots. 6. **Run a server-side dry run** with the synthetic field manager. 7. **Show the plan and warnings**. Include dry-run conflicts and errors. -8. **Apply only after explicit approval**. The replay apply command requires a target context, a plan ID, a namespace allowlist, a successful dry run, and a confirmation flag. +8. **Apply only after explicit approval**. The replay apply command requires a successful dry run and a confirmation flag. A target namespace, when provided, is always honored. 9. **Observe the target without assuming convergence**. The tool does not prove that the target reached the intended state. Target controllers may still change it. ## Sanitization defaults @@ -79,8 +79,8 @@ Only these kinds can be replayed in the MVP: The planner refuses to produce or apply a plan in these cases: - **Coverage is incomplete**. Any stream that feeds the plan has an unresolved gap, unless the Policy flag AllowGaps is set explicitly. -- **Dry run fails**. The server-side dry run reports conflicts or errors that are not overridden. -- **No explicit approval**. The apply command is missing the target context, the plan ID, a namespace allowlist, a successful dry run, or the confirmation flag. +- **Dry run fails**. The server-side dry run reports conflicts, errors, or skipped objects, and Apply refuses unless the plan status is "dry-run-ok". +- **No explicit approval**. The apply command is missing the confirmation flag or a successful dry run. The target namespace, when given, is always honored, and objects are filtered to the source namespace first. - **Excluded kinds requested**. A request to replay an excluded kind, such as Secrets, Pods, RBAC, PVs, webhooks, or CRDs, is refused unless the corresponding Policy allowlist flag is set. ```mermaid diff --git a/docs/threat-model/threat-model.md b/docs/threat-model/threat-model.md index e1152d2..16aab6c 100644 --- a/docs/threat-model/threat-model.md +++ b/docs/threat-model/threat-model.md @@ -14,7 +14,7 @@ This document lists the threats that krply is designed against, the controls tha | Invalid owner references | Orphaned objects | Remove or remap UIDs, replay roots only | replay-safety | | Name collision | Community confusion | Rename or coordinate | none | -Each row of the risk table maps to at least one control above. The controls are structural. The journal format, the planner policy, and the RBAC manifests enforce them. They do not depend on operator discipline alone. +Each row of the risk table maps to at least one control above. The controls are structural. The journal format, the planner policy, and the RBAC manifests enforce them. They do not depend on operator discipline alone. The exception is the HTTP API surface, which is unauthenticated by design and relies on network placement; see "HTTP API surface" below. ## Trust boundaries @@ -51,7 +51,19 @@ Rules: - No cluster-admin. - No write access to the source cluster. -Reference manifests: ../../deploy/rbac/. The Helm chart ships a recorder ClusterRole bound to its own ServiceAccount. See ../../deploy/helm/krply/. +Reference manifests: ../../deploy/rbac/. The Helm chart ships no ClusterRole for the krply-server ServiceAccount because the server reads only the journal and resolves the replay target through its own in-cluster configuration. See ../../deploy/helm/krply/. + +## HTTP API surface + +The query API served by krply-server is **unauthenticated** and is intended to run on a trusted network. This is a deliberate simplification, not a hard control: + +| Surface | Risk | Mitigation (as shipped) | +|---|---|---| +| Read endpoints (`/v1/health`, clusters, streams, events, history, diff, snapshots, plans) | Anyone who can reach the port can read the journal, which may contain recorded ConfigMaps and object bodies | Bind to a trusted interface, place the server behind an authenticated reverse proxy, or restrict it to loopback; do not expose it to the public internet | +| `POST /v1/replay-plans/{id}/dry-run` and `POST /v1/replay-runs` | A network peer could trigger a dry run or an apply against the target cluster | The server resolves the target cluster only through its own kubeconfig and never accepts a client-supplied kubeconfig. Apply requires `confirm: true` and a plan whose dry run already succeeded; plans are held in server memory and are not persisted across restarts | +| CORS | A malicious web page could drive the API from a victim's browser | Set `KRPLY_CORS_ORIGINS` to an explicit allowlist. The default is `*`, so the wildcard must be overridden before the server is exposed anywhere a browser could reach it | + +If the server is exposed beyond a trusted network, add authentication and TLS at the proxy before it. ## What the tool does NOT claim diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 81a68bb..0a03020 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -63,12 +63,21 @@ func (c *Correlator) Match(ctx context.Context, evt AuditEvent) (string, error) } func (c *Correlator) matchRecord(ctx context.Context, evt AuditEvent) (*event.Record, error) { - recs, err := c.store.Events(ctx, storage.EventFilter{ + // The correlated journal event must be close in time to the audit line, so + // the scan is bounded to a window and a page instead of the object's whole + // history. + f := storage.EventFilter{ ClusterID: evt.ClusterID, Namespace: evt.Namespace, Name: evt.Name, RecordType: event.TypeEvent, - }) + Limit: 500, + } + if !evt.Timestamp.IsZero() { + f.Since = evt.Timestamp.Add(-1 * time.Minute) + f.Until = evt.Timestamp.Add(1 * time.Minute) + } + recs, err := c.store.Events(ctx, f) if err != nil { return nil, err } diff --git a/internal/audit/ingest.go b/internal/audit/ingest.go index 0293543..2689bf5 100644 --- a/internal/audit/ingest.go +++ b/internal/audit/ingest.go @@ -61,11 +61,14 @@ func correlationRecord(evt AuditEvent, matched *event.Record) *event.Record { } } -// auditEventID derives a deterministic key for an audit event. The same -// request against the same object always produces the same ID. +// auditEventID derives a deterministic key for an audit event. Stage and +// response code are included so the multi-stage lines of one request +// (RequestReceived / ResponseStarted / ResponseComplete) do not collapse to a +// single record under the journal's event_id deduplication, which would drop +// the authoritative ResponseComplete stage. func auditEventID(evt AuditEvent) string { h := sha256.New() - fmt.Fprintf(h, "%s\x00%s\x00%s\x00%s\x00%s\x00%s", evt.ClusterID, evt.RequestID, evt.Namespace, evt.Name, evt.UID, evt.ResourceVersion) + fmt.Fprintf(h, "%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d", evt.ClusterID, evt.RequestID, evt.Namespace, evt.Name, evt.UID, evt.ResourceVersion, evt.Stage, evt.ResponseCode) return "audit-" + hex.EncodeToString(h.Sum(nil)) } diff --git a/web/package.json b/web/package.json index 9b52dbd..9315691 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "description": "Dependency-light static UI for krply-server", "scripts": { "build": "node build.mjs", + "lint": "node --check src/api.js && node --check src/app.js && node --check src/util.js && node --check src/views/coverage.js && node --check src/views/diff.js && node --check src/views/plans.js && node --check src/views/snapshots.js && node --check src/views/streams.js && node --check src/views/timeline.js", "start": "node build.mjs && npx serve dist" } } diff --git a/web/src/api.js b/web/src/api.js index 78c934b..c54ae9b 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -55,6 +55,19 @@ export const api = { clusters: () => request('/v1/clusters'), streams: () => request('/v1/streams'), events: (q) => request('/v1/events?' + qs(q)), + // eventsAll follows cursor pagination so the whole journal is fetched, not + // just the first (oldest) page. Bounded to 20 pages of 1000. + eventsAll: async (q = {}) => { + const all = []; + let cursor = ''; + for (let i = 0; i < 20; i++) { + const page = await api.events({ ...q, limit: 1000, ...(cursor ? { cursor } : {}) }); + all.push(...asList(page.items)); + if (!page.has_more || !page.next_cursor) break; + cursor = page.next_cursor; + } + return all; + }, history: (cluster, stream, ns, name, since) => request(`/v1/objects/${enc(objectRef({ cluster, stream, ns, name }))}/history` + (since ? '?' + qs({ since }) : '')), diff: (q) => request('/v1/diff?' + qs(q)), @@ -67,6 +80,10 @@ export const api = { }), }; +function asList(v) { + return Array.isArray(v) ? v : []; +} + // objectRef encodes the composite object reference as a single base64url token // (stream IDs contain slashes and cannot be path segments). function objectRef({ cluster, stream, ns, name }) { diff --git a/web/src/views/coverage.js b/web/src/views/coverage.js index fa52dd3..7d06993 100644 --- a/web/src/views/coverage.js +++ b/web/src/views/coverage.js @@ -11,9 +11,20 @@ export async function viewCoverage(mount) { ])); mount.appendChild(el('hr', { className: 'rule' })); - const [c, s] = await Promise.all([api.clusters().catch(() => []), api.streams().catch(() => [])]); - const clusters = asList(c); - const streams = asList(s); + let clusters, streams, events; + try { + [clusters, streams, events] = await Promise.all([ + api.clusters(), + api.streams(), + api.eventsAll({}), + ]); + } catch (err) { + mount.appendChild(el('div', { className: 'notice error', textContent: `failed to load coverage: ${err.message || String(err)}` })); + return; + } + clusters = asList(clusters); + streams = asList(streams); + events = asList(events); if (!clusters.length && !streams.length) { mount.appendChild(emptyState( @@ -23,8 +34,6 @@ export async function viewCoverage(mount) { return; } - const events = asList(await api.events({ limit: 2000 }).catch(() => [])); - const byCluster = new Map(); for (const st of streams) { const list = byCluster.get(st.cluster_id) || []; diff --git a/web/src/views/diff.js b/web/src/views/diff.js index 6cdb18a..65a914d 100644 --- a/web/src/views/diff.js +++ b/web/src/views/diff.js @@ -143,13 +143,38 @@ function buildTree(changes, side) { return root; } +// pathTokens splits a dotted path like "spec.template.spec.containers[0].image" +// into key/index tokens. The server escapes '.', '[', ']' and '\' in segment +// names with a backslash (annotation keys such as "helm.sh/hook"), which the +// tokenizer unescapes so dotted keys stay a single segment. const pathTokens = (p) => { const out = []; - const re = /([^\[\].]+)(?:\[(\d+)\])?/g; - let m; - while ((m = re.exec(p || ''))) { - out.push({ key: m[1], index: m[2] != null ? Number(m[2]) : null }); + let key = ''; + for (let i = 0; i < p.length; i++) { + const c = p[i]; + if (c === '\\' && i + 1 < p.length) { + key += p[i + 1]; + i++; + } else if (c === '.') { + if (key) out.push({ key, index: null }); + key = ''; + } else if (c === '[') { + let j = i + 1; + let idx = ''; + while (j < p.length && p[j] !== ']') { + idx += p[j]; + j++; + } + const index = Number(idx); + if (key) out.push({ key, index }); + else if (out.length) out[out.length - 1].index = index; + key = ''; + i = j; + } else { + key += c; + } } + if (key) out.push({ key, index: null }); return out; }; diff --git a/web/src/views/plans.js b/web/src/views/plans.js index f5a8550..13deea1 100644 --- a/web/src/views/plans.js +++ b/web/src/views/plans.js @@ -35,7 +35,6 @@ export async function viewPlans(mount) { const sourceInput = el('input', { type: 'text', placeholder: 'source namespace' }); const targetInput = el('input', { type: 'text', placeholder: 'target namespace' }); const list = el('div', { className: 'plan-list' }); - let bootstrapped = false; const defCluster = pickFirst(clusterList); const defSnapshot = pickLatestSnapshot(snapshotList); @@ -91,7 +90,6 @@ export async function viewPlans(mount) { list.textContent = ''; if (!plans.length) { list.appendChild(el('p', { className: 'muted', textContent: 'No plans yet — create one above.' })); - bootstrapIfEmpty(); return; } for (const p of plans) list.appendChild(planCard(p)); @@ -101,23 +99,6 @@ export async function viewPlans(mount) { } } await load(); - - // Demo bootstrap: with a real cluster + snapshot, auto-create one plan so a - // visitor sees a populated page without filling the form. - async function bootstrapIfEmpty() { - if (bootstrapped) return; - bootstrapped = true; - const body = { - cluster_id: clusterSel.value, - snapshot_id: snapshotSel.value, - source_namespace: sourceInput.value.trim(), - target_namespace: targetInput.value.trim(), - }; - if (!body.cluster_id || !body.snapshot_id || !body.source_namespace || !body.target_namespace) return; - const plans = asList(await api.plans().catch(() => [])); - if (plans.length) return; - create(body); - } } const itemName = (x) => (x.namespace ? `${x.namespace}/${x.name}` : x.name); @@ -178,13 +159,17 @@ function planCard(p) { drySection.appendChild(el('p', { className: 'muted', textContent: 'running dry-run…' })); try { const r = await api.dryRun({ plan_id: p.id }); + // The server returns the full plan with the result nested under + // dry_run_result; fall back to the top level defensively. + const dr = r.dry_run_result || r; drySection.textContent = ''; drySection.appendChild(el('div', {}, [ el('span', { textContent: 'dry-run: ' }), - r.ok ? badge('OK', 'ok') : badge('NOT OK', 'gap'), - el('span', { textContent: ` · applied ${r.applied}` }), + dr.ok ? badge('OK', 'ok') : badge('NOT OK', 'gap'), + el('span', { textContent: ` · applied ${dr.applied}` }), + el('span', { textContent: ` · skipped ${asList(dr.skipped).length}` }), ])); - for (const [label, list] of [['conflicts', r.conflicts], ['errors', r.errors]]) { + for (const [label, list] of [['conflicts', dr.conflicts], ['errors', dr.errors], ['skipped', dr.skipped]]) { if (asList(list).length) { drySection.appendChild(el('div', { className: 'subsection' }, [ el('h4', { textContent: label }), diff --git a/web/src/views/streams.js b/web/src/views/streams.js index 90b27ff..0f0e77e 100644 --- a/web/src/views/streams.js +++ b/web/src/views/streams.js @@ -9,7 +9,14 @@ export async function viewStreams(mount) { ])); mount.appendChild(el('hr', { className: 'rule' })); - const streams = asList(await api.streams()); + let streams, events; + try { + streams = asList(await api.streams()); + events = asList(await api.eventsAll({})); + } catch (err) { + mount.appendChild(el('div', { className: 'notice error', textContent: `failed to load streams: ${err.message || String(err)}` })); + return; + } if (!streams.length) { mount.appendChild(emptyState( 'No streams recorded yet.', @@ -18,7 +25,6 @@ export async function viewStreams(mount) { return; } - const events = asList(await api.events({ limit: 2000 }).catch(() => [])); const span = observedSpan(events); mount.appendChild(simpleTable(