mirror of
https://github.com/vee1e/krply.git
synced 2026-09-01 09:49:51 +00:00
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.
This commit is contained in:
parent
44fbd878a1
commit
9f4b6c2c5a
23 changed files with 251 additions and 96 deletions
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
|
|
@ -13,10 +13,12 @@ jobs:
|
||||||
go:
|
go:
|
||||||
name: build and test
|
name: build and test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
@ -31,10 +33,37 @@ jobs:
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
||||||
- name: Integration tests (fake apiserver)
|
- 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
|
- 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:
|
web:
|
||||||
name: build web ui
|
name: build web ui
|
||||||
|
|
|
||||||
10
.github/workflows/vercel.yml
vendored
10
.github/workflows/vercel.yml
vendored
|
|
@ -19,15 +19,21 @@ jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: deploy web ui
|
name: deploy web ui
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
# Requires repository secrets:
|
# Requires repository secrets:
|
||||||
# VERCEL_TOKEN personal access token (vercel.com/account/tokens)
|
# VERCEL_TOKEN personal access token (vercel.com/account/tokens)
|
||||||
# VERCEL_ORG_ID org id (vercel.com -> account settings)
|
# VERCEL_ORG_ID org id (vercel.com -> account settings)
|
||||||
# VERCEL_PROJECT_ID project id (vercel.com -> project settings)
|
# VERCEL_PROJECT_ID project id (vercel.com -> project settings)
|
||||||
- name: Deploy to Vercel
|
- name: Deploy to Vercel
|
||||||
uses: amondnet/vercel-action@v20
|
uses: amondnet/vercel-action@2d78157ee070f28ff89dd4da74e0369fc26d3b34 # v20
|
||||||
with:
|
with:
|
||||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||||
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -10,3 +10,8 @@ web/dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage.out
|
coverage.out
|
||||||
tmp/
|
tmp/
|
||||||
|
.env
|
||||||
|
*.kubeconfig
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
deploy/compose/data/
|
||||||
|
|
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
2
Makefile
2
Makefile
|
|
@ -28,7 +28,7 @@ test-e2e:
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
cd web && npm run lint 2>/dev/null || true
|
cd web && npm run lint
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,11 @@ func seedDemo(ctx context.Context, store storage.Store, demoPath string) error {
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
var (
|
var (
|
||||||
storePath = flag.String("store", "krply.db", "path to the SQLite journal")
|
storePath = flag.String("store", "krply.db", "path to the SQLite journal")
|
||||||
listen = flag.String("listen", ":8080", "listen address")
|
listen = flag.String("listen", ":8080", "listen address")
|
||||||
demoPath = flag.String("demo", "", "seed an empty journal from this SQLite demo fixture")
|
demoPath = flag.String("demo", "", "seed an empty journal from this SQLite demo fixture")
|
||||||
showVer = flag.Bool("version", false, "print version and exit")
|
showVer = flag.Bool("version", false, "print version and exit")
|
||||||
|
storeFlagSet bool
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -97,10 +98,19 @@ func run() error {
|
||||||
if f.Name == "listen" {
|
if f.Name == "listen" {
|
||||||
listenFlagSet = true
|
listenFlagSet = true
|
||||||
}
|
}
|
||||||
|
if f.Name == "store" {
|
||||||
|
storeFlagSet = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if !listenFlagSet && os.Getenv("PORT") != "" {
|
if !listenFlagSet && os.Getenv("PORT") != "" {
|
||||||
listenAddr = ":" + 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)))
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,5 @@ metadata:
|
||||||
labels:
|
labels:
|
||||||
{{- include "krply.labels" . | nindent 4 }}
|
{{- include "krply.labels" . | nindent 4 }}
|
||||||
data:
|
data:
|
||||||
store-path: {{ .Values.store.path | quote }}
|
STORE_PATH: {{ .Values.store.path | quote }}
|
||||||
listen: ":{{ .Values.listen.port }}"
|
LISTEN_ADDR: ":{{ .Values.listen.port }}"
|
||||||
|
|
|
||||||
|
|
@ -22,24 +22,50 @@ spec:
|
||||||
{{- include "krply.selectorLabels" . | nindent 8 }}
|
{{- include "krply.selectorLabels" . | nindent 8 }}
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: {{ include "krply.fullname" . }}
|
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:
|
containers:
|
||||||
- name: {{ .Chart.Name }}
|
- name: {{ .Chart.Name }}
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
args:
|
envFrom:
|
||||||
- --store
|
- configMapRef:
|
||||||
- {{ .Values.store.path | quote }}
|
name: {{ include "krply.fullname" . }}-args
|
||||||
- --listen
|
|
||||||
- ":{{ .Values.listen.port }}"
|
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: {{ .Values.listen.port }}
|
containerPort: {{ .Values.listen.port }}
|
||||||
protocol: TCP
|
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:
|
resources:
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: store
|
- name: store
|
||||||
mountPath: {{ .Values.store.path | dir }}
|
mountPath: /data
|
||||||
volumes:
|
volumes:
|
||||||
- name: store
|
- name: store
|
||||||
{{- if .Values.store.persistence.enabled }}
|
{{- if .Values.store.persistence.enabled }}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
# Recorder RBAC for the krply-server ServiceAccount.
|
{{/*
|
||||||
#
|
krply-server is a query API, materializer, and replay planner: it reads the
|
||||||
# Read-only: get/list/watch on the exact resources krply captures. No Secrets,
|
SQLite journal and, only when a replay is run, resolves the target cluster
|
||||||
# no wildcards, no write verbs. Mirrors deploy/rbac/recorder-clusterrole.yaml.
|
through its own in-cluster configuration. It performs no listing or watching,
|
||||||
# Disable with --set rbac.create=false if you bind an existing role instead.
|
so its ServiceAccount needs no ClusterRole.
|
||||||
{{- if .Values.rbac.create }}
|
|
||||||
|
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
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: ClusterRole
|
kind: ClusterRole
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -20,19 +25,4 @@ rules:
|
||||||
- apiGroups: ["node.k8s.io"]
|
- apiGroups: ["node.k8s.io"]
|
||||||
resources: ["runtimeclasses"]
|
resources: ["runtimeclasses"]
|
||||||
verbs: ["get", "list", "watch"]
|
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 }}
|
{{- end }}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,21 @@ image:
|
||||||
tag: dev
|
tag: dev
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
fullnameOverride: ""
|
fullnameOverride: ""
|
||||||
|
|
||||||
store:
|
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
|
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:
|
persistence:
|
||||||
enabled: false
|
enabled: true
|
||||||
storageClass: ""
|
storageClass: ""
|
||||||
size: 10Gi
|
size: 1Gi
|
||||||
|
|
||||||
listen:
|
listen:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
@ -23,10 +28,12 @@ service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
# Create a recorder ClusterRole/Binding for the service account. See
|
# The krply-server ServiceAccount needs no RBAC (it reads the journal and
|
||||||
# deploy/rbac/recorder-clusterrole.yaml for the full reference role.
|
# resolves the target cluster through its in-cluster configuration). Enable
|
||||||
|
# recorder.create only when a `krply record` agent runs in this chart.
|
||||||
rbac:
|
rbac:
|
||||||
create: true
|
recorder:
|
||||||
|
create: false
|
||||||
|
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ metadata:
|
||||||
rules:
|
rules:
|
||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["namespaces", "configmaps", "services"]
|
resources: ["namespaces", "configmaps", "services"]
|
||||||
verbs: ["create", "patch", "update", "get", "list"]
|
verbs: ["create", "patch", "get", "list"]
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments", "statefulsets", "daemonsets"]
|
resources: ["deployments", "statefulsets", "daemonsets"]
|
||||||
verbs: ["create", "patch", "update", "get", "list"]
|
verbs: ["create", "patch", "get", "list"]
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: ClusterRoleBinding
|
kind: ClusterRoleBinding
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,10 @@ The journal write uses the write-event-then-checkpoint order:
|
||||||
|
|
||||||
1. Write the raw event first.
|
1. Write the raw event first.
|
||||||
2. Update the checkpoint in the same transaction.
|
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:
|
These behaviors are NOT guaranteed:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| cluster_id | Separates resource version and UID domains; one per physical cluster |
|
||||||
| stream_id | Identifies group, version, resource, namespace, and selector |
|
| stream_id | Identifies group, version, resource, namespace, and selector |
|
||||||
| event_id | Deterministic deduplication key |
|
| 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 |
|
| observed_at | Collector observation time |
|
||||||
| watch_type | Original event type (ADDED, MODIFIED, DELETED, BOOKMARK, ERROR) |
|
| watch_type | Original event type (ADDED, MODIFIED, DELETED, BOOKMARK, ERROR) |
|
||||||
| synthetic | True for baseline events |
|
| 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
|
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
|
## 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
|
## Provenance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
5. **Sort resources by dependency**. Sort namespaces first, then declarative roots.
|
||||||
6. **Run a server-side dry run** with the synthetic field manager.
|
6. **Run a server-side dry run** with the synthetic field manager.
|
||||||
7. **Show the plan and warnings**. Include dry-run conflicts and errors.
|
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.
|
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
|
## 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:
|
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.
|
- **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.
|
- **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 target context, the plan ID, a namespace allowlist, a successful dry run, or the confirmation flag.
|
- **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.
|
- **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
|
```mermaid
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| Invalid owner references | Orphaned objects | Remove or remap UIDs, replay roots only | replay-safety |
|
||||||
| Name collision | Community confusion | Rename or coordinate | none |
|
| 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
|
## Trust boundaries
|
||||||
|
|
||||||
|
|
@ -51,7 +51,19 @@ Rules:
|
||||||
- No cluster-admin.
|
- No cluster-admin.
|
||||||
- No write access to the source cluster.
|
- 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
|
## What the tool does NOT claim
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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,
|
ClusterID: evt.ClusterID,
|
||||||
Namespace: evt.Namespace,
|
Namespace: evt.Namespace,
|
||||||
Name: evt.Name,
|
Name: evt.Name,
|
||||||
RecordType: event.TypeEvent,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,14 @@ func correlationRecord(evt AuditEvent, matched *event.Record) *event.Record {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditEventID derives a deterministic key for an audit event. The same
|
// auditEventID derives a deterministic key for an audit event. Stage and
|
||||||
// request against the same object always produces the same ID.
|
// 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 {
|
func auditEventID(evt AuditEvent) string {
|
||||||
h := sha256.New()
|
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))
|
return "audit-" + hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"description": "Dependency-light static UI for krply-server",
|
"description": "Dependency-light static UI for krply-server",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs",
|
"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"
|
"start": "node build.mjs && npx serve dist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,19 @@ export const api = {
|
||||||
clusters: () => request('/v1/clusters'),
|
clusters: () => request('/v1/clusters'),
|
||||||
streams: () => request('/v1/streams'),
|
streams: () => request('/v1/streams'),
|
||||||
events: (q) => request('/v1/events?' + qs(q)),
|
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) =>
|
history: (cluster, stream, ns, name, since) =>
|
||||||
request(`/v1/objects/${enc(objectRef({ cluster, stream, ns, name }))}/history` + (since ? '?' + qs({ since }) : '')),
|
request(`/v1/objects/${enc(objectRef({ cluster, stream, ns, name }))}/history` + (since ? '?' + qs({ since }) : '')),
|
||||||
diff: (q) => request('/v1/diff?' + qs(q)),
|
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
|
// objectRef encodes the composite object reference as a single base64url token
|
||||||
// (stream IDs contain slashes and cannot be path segments).
|
// (stream IDs contain slashes and cannot be path segments).
|
||||||
function objectRef({ cluster, stream, ns, name }) {
|
function objectRef({ cluster, stream, ns, name }) {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,20 @@ export async function viewCoverage(mount) {
|
||||||
]));
|
]));
|
||||||
mount.appendChild(el('hr', { className: 'rule' }));
|
mount.appendChild(el('hr', { className: 'rule' }));
|
||||||
|
|
||||||
const [c, s] = await Promise.all([api.clusters().catch(() => []), api.streams().catch(() => [])]);
|
let clusters, streams, events;
|
||||||
const clusters = asList(c);
|
try {
|
||||||
const streams = asList(s);
|
[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) {
|
if (!clusters.length && !streams.length) {
|
||||||
mount.appendChild(emptyState(
|
mount.appendChild(emptyState(
|
||||||
|
|
@ -23,8 +34,6 @@ export async function viewCoverage(mount) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = asList(await api.events({ limit: 2000 }).catch(() => []));
|
|
||||||
|
|
||||||
const byCluster = new Map();
|
const byCluster = new Map();
|
||||||
for (const st of streams) {
|
for (const st of streams) {
|
||||||
const list = byCluster.get(st.cluster_id) || [];
|
const list = byCluster.get(st.cluster_id) || [];
|
||||||
|
|
|
||||||
|
|
@ -143,13 +143,38 @@ function buildTree(changes, side) {
|
||||||
return root;
|
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 pathTokens = (p) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
const re = /([^\[\].]+)(?:\[(\d+)\])?/g;
|
let key = '';
|
||||||
let m;
|
for (let i = 0; i < p.length; i++) {
|
||||||
while ((m = re.exec(p || ''))) {
|
const c = p[i];
|
||||||
out.push({ key: m[1], index: m[2] != null ? Number(m[2]) : null });
|
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;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ export async function viewPlans(mount) {
|
||||||
const sourceInput = el('input', { type: 'text', placeholder: 'source namespace' });
|
const sourceInput = el('input', { type: 'text', placeholder: 'source namespace' });
|
||||||
const targetInput = el('input', { type: 'text', placeholder: 'target namespace' });
|
const targetInput = el('input', { type: 'text', placeholder: 'target namespace' });
|
||||||
const list = el('div', { className: 'plan-list' });
|
const list = el('div', { className: 'plan-list' });
|
||||||
let bootstrapped = false;
|
|
||||||
|
|
||||||
const defCluster = pickFirst(clusterList);
|
const defCluster = pickFirst(clusterList);
|
||||||
const defSnapshot = pickLatestSnapshot(snapshotList);
|
const defSnapshot = pickLatestSnapshot(snapshotList);
|
||||||
|
|
@ -91,7 +90,6 @@ export async function viewPlans(mount) {
|
||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if (!plans.length) {
|
if (!plans.length) {
|
||||||
list.appendChild(el('p', { className: 'muted', textContent: 'No plans yet — create one above.' }));
|
list.appendChild(el('p', { className: 'muted', textContent: 'No plans yet — create one above.' }));
|
||||||
bootstrapIfEmpty();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const p of plans) list.appendChild(planCard(p));
|
for (const p of plans) list.appendChild(planCard(p));
|
||||||
|
|
@ -101,23 +99,6 @@ export async function viewPlans(mount) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await load();
|
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);
|
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…' }));
|
drySection.appendChild(el('p', { className: 'muted', textContent: 'running dry-run…' }));
|
||||||
try {
|
try {
|
||||||
const r = await api.dryRun({ plan_id: p.id });
|
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.textContent = '';
|
||||||
drySection.appendChild(el('div', {}, [
|
drySection.appendChild(el('div', {}, [
|
||||||
el('span', { textContent: 'dry-run: ' }),
|
el('span', { textContent: 'dry-run: ' }),
|
||||||
r.ok ? badge('OK', 'ok') : badge('NOT OK', 'gap'),
|
dr.ok ? badge('OK', 'ok') : badge('NOT OK', 'gap'),
|
||||||
el('span', { textContent: ` · applied ${r.applied}` }),
|
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) {
|
if (asList(list).length) {
|
||||||
drySection.appendChild(el('div', { className: 'subsection' }, [
|
drySection.appendChild(el('div', { className: 'subsection' }, [
|
||||||
el('h4', { textContent: label }),
|
el('h4', { textContent: label }),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ export async function viewStreams(mount) {
|
||||||
]));
|
]));
|
||||||
mount.appendChild(el('hr', { className: 'rule' }));
|
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) {
|
if (!streams.length) {
|
||||||
mount.appendChild(emptyState(
|
mount.appendChild(emptyState(
|
||||||
'No streams recorded yet.',
|
'No streams recorded yet.',
|
||||||
|
|
@ -18,7 +25,6 @@ export async function viewStreams(mount) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = asList(await api.events({ limit: 2000 }).catch(() => []));
|
|
||||||
const span = observedSpan(events);
|
const span = observedSpan(events);
|
||||||
|
|
||||||
mount.appendChild(simpleTable(
|
mount.appendChild(simpleTable(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue