mirror of
https://github.com/vee1e/krply.git
synced 2026-09-01 09:49:51 +00:00
Adds regression tests for the fixed-width timestamp format and sub-second boundaries, in-memory store dedup and cursor pagination, stream-ID selector escaping, live-vs-synthetic event IDs, gap healing and baseline state reset, path-scoped diff ignores, nested Added/Removed flags, snapshot watermarks and the TypeSnapshot record, NodePort/headless service sanitization, the dry-run-gated apply, skipped-object reporting, and the expanded gvrFor table. The object-history endpoint now scopes gap markers to the requested window, and record --server fails loudly instead of silently recording locally.
413 lines
14 KiB
Go
413 lines
14 KiB
Go
package replay
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/krply/krply/internal/event"
|
|
"github.com/krply/krply/internal/materialize"
|
|
"github.com/krply/krply/internal/storage"
|
|
)
|
|
|
|
func rawObject(obj map[string]any) json.RawMessage {
|
|
raw, err := json.Marshal(obj)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func appendRec(t *testing.T, store storage.Store, rec *event.Record) {
|
|
t.Helper()
|
|
if _, err := store.Append(context.Background(), rec); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
}
|
|
|
|
func eventRec(cluster, stream string, wt event.WatchType, res event.ResourceRef, obj map[string]any, at time.Time) *event.Record {
|
|
return &event.Record{
|
|
ClusterID: cluster,
|
|
StreamID: stream,
|
|
Type: event.TypeEvent,
|
|
WatchType: wt,
|
|
Resource: res,
|
|
Object: rawObject(obj),
|
|
ObservedAt: at,
|
|
}
|
|
}
|
|
|
|
func baselineRec(cluster, stream string, at time.Time) *event.Record {
|
|
return &event.Record{
|
|
ClusterID: cluster,
|
|
StreamID: stream,
|
|
Type: event.TypeBaseline,
|
|
ObservedAt: at,
|
|
}
|
|
}
|
|
|
|
func gapRec(cluster, stream string, at time.Time) *event.Record {
|
|
return &event.Record{
|
|
ClusterID: cluster,
|
|
StreamID: stream,
|
|
Type: event.TypeGap,
|
|
ObservedAt: at,
|
|
Gap: &event.GapInfo{Reason: "test gap"},
|
|
}
|
|
}
|
|
|
|
func deploymentObject(name, namespace string) map[string]any {
|
|
return map[string]any{
|
|
"apiVersion": "apps/v1",
|
|
"kind": "Deployment",
|
|
"metadata": map[string]any{
|
|
"name": name,
|
|
"namespace": namespace,
|
|
"uid": "uid-" + name,
|
|
"resourceVersion": "42",
|
|
"generation": 2,
|
|
"creationTimestamp": "2026-08-01T00:00:00Z",
|
|
"managedFields": []any{
|
|
map[string]any{"manager": "kubectl", "operation": "Update"},
|
|
},
|
|
"annotations": map[string]any{
|
|
"user.example.com/owner": "team",
|
|
"kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"apps/v1","kind":"Deployment"}`,
|
|
},
|
|
},
|
|
"spec": map[string]any{
|
|
"replicas": 3,
|
|
"selector": map[string]any{"matchLabels": map[string]any{"app": name}},
|
|
"template": map[string]any{
|
|
"metadata": map[string]any{
|
|
"creationTimestamp": "2026-08-01T00:00:00Z",
|
|
"uid": "uid-pod-" + name,
|
|
"resourceVersion": "42",
|
|
"labels": map[string]any{"app": name},
|
|
},
|
|
"spec": map[string]any{
|
|
"containers": []any{
|
|
map[string]any{"name": "app", "image": "nginx:1.21"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"status": map[string]any{"readyReplicas": 1, "replicas": 3},
|
|
}
|
|
}
|
|
|
|
func secretObject(name string) map[string]any {
|
|
return map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]any{
|
|
"name": name,
|
|
"namespace": "default",
|
|
"uid": "uid-" + name,
|
|
"resourceVersion": "1",
|
|
},
|
|
"type": "Opaque",
|
|
"data": map[string]any{"password": "c2VjcmV0"},
|
|
}
|
|
}
|
|
|
|
func loadBalancerService(name string) map[string]any {
|
|
return map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Service",
|
|
"metadata": map[string]any{
|
|
"name": name,
|
|
"namespace": "default",
|
|
"uid": "uid-" + name,
|
|
"resourceVersion": "1",
|
|
},
|
|
"spec": map[string]any{
|
|
"type": "LoadBalancer",
|
|
"clusterIP": "10.0.0.1",
|
|
},
|
|
"status": map[string]any{
|
|
"loadBalancer": map[string]any{"ingress": []any{map[string]any{"ip": "203.0.113.5"}}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func saveSnapshot(t *testing.T, store storage.Store, id, cluster string, at time.Time) {
|
|
t.Helper()
|
|
if err := store.SaveSnapshot(context.Background(), &storage.SnapshotRef{ID: id, ClusterID: cluster, Name: "daily", At: at}); err != nil {
|
|
t.Fatalf("SaveSnapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPlanSanitizesAndExcludes(t *testing.T) {
|
|
store := storage.NewInMemory()
|
|
mat := materialize.NewMaterializer(store)
|
|
ctx := context.Background()
|
|
|
|
cluster := "c1"
|
|
streamDeploy := event.Stream{ClusterID: cluster, Group: "apps", Version: "v1", Resource: "deployments", Namespace: ""}.ID()
|
|
streamSecret := event.Stream{ClusterID: cluster, Group: "", Version: "v1", Resource: "secrets", Namespace: ""}.ID()
|
|
streamSvc := event.Stream{ClusterID: cluster, Group: "", Version: "v1", Resource: "services", Namespace: ""}.ID()
|
|
|
|
base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
|
appendRec(t, store, baselineRec(cluster, streamDeploy, base))
|
|
appendRec(t, store, eventRec(cluster, streamDeploy, event.WatchAdded, event.ResourceRef{Namespace: "default", Name: "web", Kind: "Deployment"}, deploymentObject("web", "default"), base.Add(1*time.Minute)))
|
|
appendRec(t, store, baselineRec(cluster, streamSecret, base))
|
|
appendRec(t, store, eventRec(cluster, streamSecret, event.WatchAdded, event.ResourceRef{Namespace: "default", Name: "creds", Kind: "Secret"}, secretObject("creds"), base.Add(1*time.Minute)))
|
|
appendRec(t, store, baselineRec(cluster, streamSvc, base))
|
|
appendRec(t, store, eventRec(cluster, streamSvc, event.WatchAdded, event.ResourceRef{Namespace: "default", Name: "lb", Kind: "Service"}, loadBalancerService("lb"), base.Add(1*time.Minute)))
|
|
|
|
snapAt := base.Add(10 * time.Minute)
|
|
saveSnapshot(t, store, "snap-test", cluster, snapAt)
|
|
|
|
planner := NewPlanner(store, mat, DefaultPolicy())
|
|
plan, err := planner.Plan(ctx, cluster, "snap-test", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Plan: %v", err)
|
|
}
|
|
|
|
if plan.Status != "planned" {
|
|
t.Fatalf("Status = %q, want planned", plan.Status)
|
|
}
|
|
if !plan.CoverageComplete {
|
|
t.Fatalf("CoverageComplete = false, want true")
|
|
}
|
|
if plan.ID == "" || plan.FieldManager != "krply-"+plan.ID {
|
|
t.Fatalf("ID = %q, FieldManager = %q, want krply-<id>", plan.ID, plan.FieldManager)
|
|
}
|
|
if len(plan.Objects) != 1 {
|
|
t.Fatalf("objects = %d, want 1: %+v", len(plan.Objects), plan.Objects)
|
|
}
|
|
|
|
po := plan.Objects[0]
|
|
if po.Kind != "Deployment" || po.Name != "web" || po.Order != 1 {
|
|
t.Fatalf("object = %s/%s order %d, want Deployment/web order 1", po.Kind, po.Name, po.Order)
|
|
}
|
|
meta, _ := po.Object["metadata"].(map[string]any)
|
|
for _, f := range []string{"uid", "resourceVersion", "creationTimestamp", "generation", "managedFields", "ownerReferences"} {
|
|
if _, ok := meta[f]; ok {
|
|
t.Errorf("metadata.%s still present", f)
|
|
}
|
|
}
|
|
if anns, ok := meta["annotations"].(map[string]any); ok {
|
|
if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
|
|
t.Errorf("last-applied annotation still present")
|
|
}
|
|
if _, ok := anns["user.example.com/owner"]; !ok {
|
|
t.Errorf("user annotation dropped")
|
|
}
|
|
}
|
|
if _, ok := po.Object["status"]; ok {
|
|
t.Errorf("status still present")
|
|
}
|
|
spec, _ := po.Object["spec"].(map[string]any)
|
|
if spec["replicas"] != float64(3) {
|
|
t.Errorf("spec.replicas = %v, want 3", spec["replicas"])
|
|
}
|
|
tmplMeta, _ := spec["template"].(map[string]any)["metadata"].(map[string]any)
|
|
for _, f := range []string{"creationTimestamp", "uid", "resourceVersion"} {
|
|
if _, ok := tmplMeta[f]; ok {
|
|
t.Errorf("spec.template.metadata.%s still present", f)
|
|
}
|
|
}
|
|
|
|
excluded := map[string]string{}
|
|
for _, ex := range plan.Excluded {
|
|
excluded[ex.Kind] = ex.Reason
|
|
}
|
|
if got := excluded["Secret"]; got != "secret excluded by default" {
|
|
t.Errorf("Secret exclusion = %q, want secret excluded by default", got)
|
|
}
|
|
if got := excluded["Service"]; got != "load balancer service not safe to replay" {
|
|
t.Errorf("Service exclusion = %q, want load balancer service not safe to replay", got)
|
|
}
|
|
}
|
|
|
|
func TestPlanRefusesIncompleteCoverage(t *testing.T) {
|
|
store := storage.NewInMemory()
|
|
mat := materialize.NewMaterializer(store)
|
|
ctx := context.Background()
|
|
|
|
cluster := "c1"
|
|
streamDeploy := event.Stream{ClusterID: cluster, Group: "apps", Version: "v1", Resource: "deployments", Namespace: ""}.ID()
|
|
|
|
base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
|
appendRec(t, store, baselineRec(cluster, streamDeploy, base))
|
|
appendRec(t, store, eventRec(cluster, streamDeploy, event.WatchAdded, event.ResourceRef{Namespace: "default", Name: "web", Kind: "Deployment"}, deploymentObject("web", "default"), base.Add(1*time.Minute)))
|
|
appendRec(t, store, gapRec(cluster, streamDeploy, base.Add(2*time.Minute)))
|
|
|
|
snapAt := base.Add(10 * time.Minute)
|
|
saveSnapshot(t, store, "snap-gap", cluster, snapAt)
|
|
|
|
planner := NewPlanner(store, mat, DefaultPolicy())
|
|
_, err := planner.Plan(ctx, cluster, "snap-gap", "", "")
|
|
if err == nil {
|
|
t.Fatalf("Plan with gaps = nil error, want refusal")
|
|
}
|
|
if !strings.Contains(err.Error(), "incomplete coverage") {
|
|
t.Fatalf("error %q does not mention incomplete coverage", err)
|
|
}
|
|
|
|
allow := DefaultPolicy()
|
|
allow.AllowGaps = true
|
|
planner2 := NewPlanner(store, mat, allow)
|
|
plan, err := planner2.Plan(ctx, cluster, "snap-gap", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Plan with allow-gaps: %v", err)
|
|
}
|
|
if plan.CoverageComplete {
|
|
t.Fatalf("CoverageComplete = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestPlanNamespaceMapping(t *testing.T) {
|
|
store := storage.NewInMemory()
|
|
mat := materialize.NewMaterializer(store)
|
|
ctx := context.Background()
|
|
|
|
cluster := "c1"
|
|
streamDeploy := event.Stream{ClusterID: cluster, Group: "apps", Version: "v1", Resource: "deployments", Namespace: ""}.ID()
|
|
|
|
base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
|
appendRec(t, store, baselineRec(cluster, streamDeploy, base))
|
|
appendRec(t, store, eventRec(cluster, streamDeploy, event.WatchAdded, event.ResourceRef{Namespace: "default", Name: "web", Kind: "Deployment"}, deploymentObject("web", "default"), base.Add(1*time.Minute)))
|
|
appendRec(t, store, eventRec(cluster, streamDeploy, event.WatchAdded, event.ResourceRef{Namespace: "other", Name: "other-web", Kind: "Deployment"}, deploymentObject("other-web", "other"), base.Add(2*time.Minute)))
|
|
|
|
snapAt := base.Add(10 * time.Minute)
|
|
saveSnapshot(t, store, "snap-map", cluster, snapAt)
|
|
|
|
planner := NewPlanner(store, mat, DefaultPolicy())
|
|
plan, err := planner.Plan(ctx, cluster, "snap-map", "default", "sandbox")
|
|
if err != nil {
|
|
t.Fatalf("Plan: %v", err)
|
|
}
|
|
|
|
if len(plan.Objects) != 1 {
|
|
t.Fatalf("objects = %d, want 1 (only source namespace objects)", len(plan.Objects))
|
|
}
|
|
po := plan.Objects[0]
|
|
if po.Name != "web" {
|
|
t.Fatalf("object = %s, want web", po.Name)
|
|
}
|
|
if po.Namespace != "sandbox" {
|
|
t.Fatalf("object namespace = %q, want sandbox", po.Namespace)
|
|
}
|
|
meta, _ := po.Object["metadata"].(map[string]any)
|
|
if got := meta["namespace"]; got != "sandbox" {
|
|
t.Fatalf("metadata.namespace = %v, want sandbox", got)
|
|
}
|
|
|
|
var foundOutside bool
|
|
for _, ex := range plan.Excluded {
|
|
if ex.Kind == "Deployment" && ex.Name == "other-web" && ex.Reason == "outside source namespace" {
|
|
foundOutside = true
|
|
}
|
|
}
|
|
if !foundOutside {
|
|
t.Fatalf("expected other-web excluded as outside source namespace: %+v", plan.Excluded)
|
|
}
|
|
|
|
var mapped bool
|
|
for _, w := range plan.Warnings {
|
|
if strings.Contains(w, "namespace mapping") {
|
|
mapped = true
|
|
}
|
|
}
|
|
if !mapped {
|
|
t.Fatalf("plan warnings %v lack a namespace mapping note", plan.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeServiceNodePortAndHeadless(t *testing.T) {
|
|
nodePort := map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Service",
|
|
"metadata": map[string]any{"name": "np", "namespace": "default"},
|
|
"spec": map[string]any{
|
|
"type": "NodePort",
|
|
"clusterIP": "10.96.0.5",
|
|
"clusterIPs": []any{"10.96.0.5"},
|
|
"ports": []any{map[string]any{"port": 80, "nodePort": 30080}},
|
|
},
|
|
}
|
|
clean, _ := sanitizeObject(nodePort, "Service", DefaultPolicy())
|
|
spec := clean["spec"].(map[string]any)
|
|
if _, ok := spec["clusterIP"]; ok {
|
|
t.Fatalf("NodePort service kept clusterIP: %+v", spec)
|
|
}
|
|
if _, ok := spec["clusterIPs"]; ok {
|
|
t.Fatalf("NodePort service kept clusterIPs: %+v", spec)
|
|
}
|
|
ports := spec["ports"].([]any)
|
|
pm := ports[0].(map[string]any)
|
|
if _, ok := pm["nodePort"]; ok {
|
|
t.Fatalf("NodePort service kept nodePort: %+v", pm)
|
|
}
|
|
|
|
headless := map[string]any{
|
|
"apiVersion": "v1",
|
|
"kind": "Service",
|
|
"metadata": map[string]any{"name": "hl", "namespace": "default"},
|
|
"spec": map[string]any{"type": "ClusterIP", "clusterIP": "None"},
|
|
}
|
|
clean2, _ := sanitizeObject(headless, "Service", DefaultPolicy())
|
|
if got := clean2["spec"].(map[string]any)["clusterIP"]; got != "None" {
|
|
t.Fatalf("headless service clusterIP = %v, want None", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyRequiresDryRun(t *testing.T) {
|
|
plan := &Plan{
|
|
ID: "plan-test",
|
|
Status: "planned",
|
|
Objects: []PlanObject{{
|
|
Namespace: "default", Name: "x", Kind: "ConfigMap",
|
|
Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "x"}},
|
|
}},
|
|
}
|
|
_, err := plan.Apply(context.Background(), "", "", true)
|
|
if err == nil {
|
|
t.Fatal("Apply without a successful dry run must be refused")
|
|
}
|
|
}
|
|
|
|
func TestApplyItemsReportsSkipped(t *testing.T) {
|
|
plan := &Plan{
|
|
Objects: []PlanObject{
|
|
{Namespace: "default", Name: "known", Kind: "ConfigMap", Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "known"}}},
|
|
{Namespace: "default", Name: "unknown", Kind: "MysteryKind", Object: map[string]any{"apiVersion": "acme.io/v1", "kind": "MysteryKind", "metadata": map[string]any{"name": "unknown"}}},
|
|
},
|
|
}
|
|
items, skipped := plan.applyItems()
|
|
if len(items) != 1 {
|
|
t.Fatalf("applyItems = %d items, want 1", len(items))
|
|
}
|
|
if len(skipped) != 1 || skipped[0].Kind != "MysteryKind" {
|
|
t.Fatalf("skipped = %+v, want 1 MysteryKind", skipped)
|
|
}
|
|
}
|
|
|
|
func TestGVRForCoversPolicyKinds(t *testing.T) {
|
|
for _, kind := range []string{"Secret", "Role", "ClusterRole", "RoleBinding", "ClusterRoleBinding", "Job", "CronJob", "Pod", "PersistentVolume", "PersistentVolumeClaim", "StorageClass", "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration", "CustomResourceDefinition"} {
|
|
apiVersion := "v1"
|
|
switch kind {
|
|
case "Role", "RoleBinding", "ClusterRole", "ClusterRoleBinding":
|
|
apiVersion = "rbac.authorization.k8s.io/v1"
|
|
case "Job", "CronJob":
|
|
apiVersion = "batch/v1"
|
|
case "StorageClass":
|
|
apiVersion = "storage.k8s.io/v1"
|
|
case "PersistentVolume", "PersistentVolumeClaim":
|
|
apiVersion = "v1"
|
|
case "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration":
|
|
apiVersion = "admissionregistration.k8s.io/v1"
|
|
case "CustomResourceDefinition":
|
|
apiVersion = "apiextensions.k8s.io/v1"
|
|
}
|
|
obj := map[string]any{"apiVersion": apiVersion, "kind": kind, "metadata": map[string]any{"name": "x"}}
|
|
if _, err := gvrFor(obj, kind); err != nil {
|
|
t.Fatalf("gvrFor(%s): %v", kind, err)
|
|
}
|
|
}
|
|
}
|