@checkstack/gitops-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ import { isSecretRef } from "@checkstack/gitops-common";
2
+
3
+ /**
4
+ * Interface for resolving secret names to their decrypted values.
5
+ */
6
+ export interface SecretStore {
7
+ resolve: (name: string) => Promise<string>;
8
+ }
9
+
10
+ /**
11
+ * Recursively walks a parsed spec object and resolves any `{ secretRef: "name" }`
12
+ * values by looking them up in the secret store.
13
+ *
14
+ * After resolution, all secretRef objects are replaced with plain strings.
15
+ * This function is called by the reconciliation engine before invoking plugin reconcilers.
16
+ */
17
+ export async function resolveSecrets(params: {
18
+ spec: Record<string, unknown>;
19
+ secretStore: SecretStore;
20
+ }): Promise<Record<string, unknown>> {
21
+ const { spec, secretStore } = params;
22
+ return resolveValue(spec, secretStore) as Promise<Record<string, unknown>>;
23
+ }
24
+
25
+ async function resolveValue(
26
+ value: unknown,
27
+ secretStore: SecretStore,
28
+ ): Promise<unknown> {
29
+ if (value === null || value === undefined) {
30
+ return value;
31
+ }
32
+
33
+ // Check if this value is a secretRef object
34
+ if (isSecretRef(value)) {
35
+ return secretStore.resolve(value.secretRef);
36
+ }
37
+
38
+ // Recurse into arrays
39
+ if (Array.isArray(value)) {
40
+ return Promise.all(value.map((item) => resolveValue(item, secretStore)));
41
+ }
42
+
43
+ // Recurse into plain objects
44
+ if (typeof value === "object") {
45
+ const resolved: Record<string, unknown> = {};
46
+ for (const [key, val] of Object.entries(value)) {
47
+ resolved[key] = await resolveValue(val, secretStore);
48
+ }
49
+ return resolved;
50
+ }
51
+
52
+ // Primitives pass through
53
+ return value;
54
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { parseEntityDocuments, computeHash } from "./document-parser";
3
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
4
+
5
+ const VALID_SYSTEM_YAML = `apiVersion: ${CHECKSTACK_API_VERSION}
6
+ kind: System
7
+ metadata:
8
+ name: payment-service
9
+ title: Payment Service
10
+ description: Handles payments
11
+ spec:
12
+ description: A payment processing system`;
13
+
14
+ const VALID_HEALTHCHECK_YAML = `apiVersion: ${CHECKSTACK_API_VERSION}
15
+ kind: Healthcheck
16
+ metadata:
17
+ name: payment-db-check
18
+ spec:
19
+ strategy: postgres`;
20
+
21
+ describe("parseEntityDocuments", () => {
22
+ it("parses a single document", () => {
23
+ const result = parseEntityDocuments({ content: VALID_SYSTEM_YAML });
24
+ expect(result.errors).toHaveLength(0);
25
+ expect(result.entities).toHaveLength(1);
26
+ expect(result.entities[0].entity.kind).toBe("System");
27
+ expect(result.entities[0].entity.metadata.name).toBe("payment-service");
28
+ expect(result.entities[0].contentHash).toBeString();
29
+ expect(result.entities[0].contentHash).toHaveLength(64); // SHA-256 hex
30
+ });
31
+
32
+ it("parses multi-document YAML separated by ---", () => {
33
+ const content = `${VALID_SYSTEM_YAML}\n---\n${VALID_HEALTHCHECK_YAML}`;
34
+ const result = parseEntityDocuments({ content });
35
+ expect(result.errors).toHaveLength(0);
36
+ expect(result.entities).toHaveLength(2);
37
+ expect(result.entities[0].entity.kind).toBe("System");
38
+ expect(result.entities[1].entity.kind).toBe("Healthcheck");
39
+ });
40
+
41
+ it("handles leading --- in multi-doc YAML", () => {
42
+ const content = `---\n${VALID_SYSTEM_YAML}\n---\n${VALID_HEALTHCHECK_YAML}`;
43
+ const result = parseEntityDocuments({ content });
44
+ expect(result.errors).toHaveLength(0);
45
+ expect(result.entities).toHaveLength(2);
46
+ });
47
+
48
+ it("skips empty documents (trailing ---)", () => {
49
+ const content = `${VALID_SYSTEM_YAML}\n---\n`;
50
+ const result = parseEntityDocuments({ content });
51
+ expect(result.errors).toHaveLength(0);
52
+ expect(result.entities).toHaveLength(1);
53
+ });
54
+
55
+ it("collects validation errors for invalid envelope", () => {
56
+ const content = `kind: System
57
+ metadata:
58
+ name: INVALID-UPPERCASE
59
+ spec: {}`;
60
+ const result = parseEntityDocuments({ content });
61
+ expect(result.errors).toHaveLength(1);
62
+ expect(result.errors[0].documentIndex).toBe(0);
63
+ expect(result.errors[0].message).toContain("Validation error");
64
+ expect(result.entities).toHaveLength(0);
65
+ });
66
+
67
+ it("parses valid docs and collects errors for invalid ones in same file", () => {
68
+ const content = `${VALID_SYSTEM_YAML}\n---\nkind: Bad\nmetadata:\n name: UPPERCASE\nspec: {}`;
69
+ const result = parseEntityDocuments({ content });
70
+ expect(result.entities).toHaveLength(1);
71
+ expect(result.entities[0].entity.kind).toBe("System");
72
+ expect(result.errors).toHaveLength(1);
73
+ expect(result.errors[0].documentIndex).toBe(1);
74
+ });
75
+
76
+ it("returns content hashes that are stable for identical content", () => {
77
+ const result1 = parseEntityDocuments({ content: VALID_SYSTEM_YAML });
78
+ const result2 = parseEntityDocuments({ content: VALID_SYSTEM_YAML });
79
+ expect(result1.entities[0].contentHash).toBe(
80
+ result2.entities[0].contentHash,
81
+ );
82
+ });
83
+
84
+ it("returns different hashes for different content", () => {
85
+ const modified = VALID_SYSTEM_YAML.replace(
86
+ "Handles payments",
87
+ "Handles refunds",
88
+ );
89
+ const result1 = parseEntityDocuments({ content: VALID_SYSTEM_YAML });
90
+ const result2 = parseEntityDocuments({ content: modified });
91
+ expect(result1.entities[0].contentHash).not.toBe(
92
+ result2.entities[0].contentHash,
93
+ );
94
+ });
95
+
96
+ it("handles completely invalid YAML gracefully", () => {
97
+ const content = `{{{not valid yaml at all`;
98
+ const result = parseEntityDocuments({ content });
99
+ expect(result.entities).toHaveLength(0);
100
+ expect(result.errors.length).toBeGreaterThan(0);
101
+ });
102
+ });
103
+
104
+ describe("computeHash", () => {
105
+ it("produces a 64-character hex string", () => {
106
+ const hash = computeHash({ input: "hello world" });
107
+ expect(hash).toHaveLength(64);
108
+ expect(hash).toMatch(/^[\da-f]+$/);
109
+ });
110
+
111
+ it("produces deterministic output", () => {
112
+ expect(computeHash({ input: "test" })).toBe(
113
+ computeHash({ input: "test" }),
114
+ );
115
+ });
116
+ });
@@ -0,0 +1,124 @@
1
+ import { parseAllDocuments } from "yaml";
2
+ import { entityEnvelopeSchema } from "@checkstack/gitops-common";
3
+ import { extractErrorMessage } from "@checkstack/common";
4
+ import type { EntityEnvelope } from "@checkstack/gitops-common";
5
+
6
+ /**
7
+ * A successfully parsed entity with its content hash for diffing.
8
+ */
9
+ export interface ParsedEntity {
10
+ /** The validated entity envelope. */
11
+ entity: EntityEnvelope;
12
+ /** SHA-256 hex hash of the raw YAML source for this entity. */
13
+ contentHash: string;
14
+ }
15
+
16
+ /**
17
+ * An error encountered during parsing or validation.
18
+ */
19
+ export interface ParseError {
20
+ /** 0-based document index within the YAML file. */
21
+ documentIndex: number;
22
+ /** Human-readable error message. */
23
+ message: string;
24
+ }
25
+
26
+ /**
27
+ * Result of parsing a YAML file containing one or more entity documents.
28
+ */
29
+ export interface ParseResult {
30
+ entities: ParsedEntity[];
31
+ errors: ParseError[];
32
+ }
33
+
34
+ /**
35
+ * Parses a YAML string (potentially multi-document) into entity envelopes.
36
+ * Never throws — all parse/validation errors are collected in `errors`.
37
+ *
38
+ * @param content - Raw YAML content (may contain multiple docs separated by `---`)
39
+ * @returns Parsed entities and any errors encountered
40
+ */
41
+ export function parseEntityDocuments(params: { content: string }): ParseResult {
42
+ const { content } = params;
43
+ const entities: ParsedEntity[] = [];
44
+ const errors: ParseError[] = [];
45
+
46
+ let docs: ReturnType<typeof parseAllDocuments>;
47
+ try {
48
+ docs = parseAllDocuments(content);
49
+ } catch (error) {
50
+ errors.push({
51
+ documentIndex: 0,
52
+ message: `Failed to parse YAML: ${extractErrorMessage(error, "Unknown error")}`,
53
+ });
54
+ return { entities, errors };
55
+ }
56
+
57
+ for (const [i, doc] of docs.entries()) {
58
+ // Check for YAML-level parse errors
59
+ if (doc.errors.length > 0) {
60
+ errors.push({
61
+ documentIndex: i,
62
+ message: `YAML parse error: ${doc.errors.map((e) => e.message).join("; ")}`,
63
+ });
64
+ continue;
65
+ }
66
+
67
+ const raw = doc.toJSON();
68
+
69
+ // Skip null/empty documents (e.g., trailing `---`)
70
+ if (raw === null || raw === undefined) {
71
+ continue;
72
+ }
73
+
74
+ // Validate against entity envelope schema
75
+ const result = entityEnvelopeSchema.safeParse(raw);
76
+ if (!result.success) {
77
+ const issues = result.error.issues
78
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
79
+ .join("; ");
80
+ errors.push({
81
+ documentIndex: i,
82
+ message: `Validation error: ${issues}`,
83
+ });
84
+ continue;
85
+ }
86
+
87
+ // Compute content hash from the raw YAML source for this document
88
+ const docSource = extractDocumentSource({ content, documentIndex: i });
89
+ const contentHash = computeHash({ input: docSource });
90
+
91
+ entities.push({ entity: result.data, contentHash });
92
+ }
93
+
94
+ return { entities, errors };
95
+ }
96
+
97
+ /**
98
+ * Extracts the raw YAML source for a single document from a multi-doc string.
99
+ * Uses `---` as the separator.
100
+ */
101
+ function extractDocumentSource(params: {
102
+ content: string;
103
+ documentIndex: number;
104
+ }): string {
105
+ const { content, documentIndex } = params;
106
+ // Split on document separators, handling leading `---`
107
+ const parts = content.split(/^---$/m);
108
+
109
+ // If the content starts with `---`, the first part is empty
110
+ const nonEmptyParts = parts[0].trim() === "" ? parts.slice(1) : parts;
111
+
112
+ return (nonEmptyParts[documentIndex] ?? "").trim();
113
+ }
114
+
115
+ /**
116
+ * Computes a SHA-256 hex hash of a string.
117
+ */
118
+ function computeHash(params: { input: string }): string {
119
+ const hasher = new Bun.CryptoHasher("sha256");
120
+ hasher.update(params.input);
121
+ return hasher.digest("hex");
122
+ }
123
+
124
+ export { computeHash };
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
4
+ import { createEntityKindRegistry } from "../kind-registry";
5
+
6
+ /**
7
+ * Tests that the `detectOrphans` logic in the reconciler properly invokes
8
+ * the kind's `delete()` reconciler before removing provenance entries.
9
+ *
10
+ * These tests verify the delete wiring in isolation by directly testing
11
+ * the kind registry's delete behavior, which the reconciler calls.
12
+ */
13
+
14
+ describe("Kind delete reconciler wiring", () => {
15
+ it("calls the registered delete function when available", async () => {
16
+ const deleteHandler = mock(async (_params: { entityName: string; entityId?: string }) => {});
17
+ const registry = createEntityKindRegistry();
18
+
19
+ registry.registerKind({
20
+ apiVersion: CHECKSTACK_API_VERSION,
21
+ kind: "System",
22
+ specSchema: z.object({ description: z.string().optional() }),
23
+ reconcile: async () => ({ entityId: "test-id" }),
24
+ delete: deleteHandler,
25
+ });
26
+
27
+ const kindDef = registry.getKind({
28
+ apiVersion: CHECKSTACK_API_VERSION,
29
+ kind: "System",
30
+ });
31
+
32
+ expect(kindDef?.delete).toBeDefined();
33
+
34
+ await kindDef!.delete!({
35
+ entityName: "payment-service",
36
+ entityId: "sys-123",
37
+ context: {
38
+ logger: {
39
+ debug: () => {},
40
+ info: () => {},
41
+ warn: () => {},
42
+ error: () => {},
43
+ },
44
+ resolveEntityRef: async () => undefined,
45
+ },
46
+ });
47
+
48
+ expect(deleteHandler).toHaveBeenCalledTimes(1);
49
+ expect(deleteHandler).toHaveBeenCalledWith({
50
+ entityName: "payment-service",
51
+ entityId: "sys-123",
52
+ context: expect.objectContaining({
53
+ logger: expect.any(Object),
54
+ }),
55
+ });
56
+ });
57
+
58
+ it("handles kinds with no delete handler gracefully", () => {
59
+ const registry = createEntityKindRegistry();
60
+
61
+ registry.registerKind({
62
+ apiVersion: CHECKSTACK_API_VERSION,
63
+ kind: "ReadOnlyKind",
64
+ specSchema: z.object({}),
65
+ reconcile: async () => ({ entityId: "test-id" }),
66
+ // No delete handler
67
+ });
68
+
69
+ const kindDef = registry.getKind({
70
+ apiVersion: CHECKSTACK_API_VERSION,
71
+ kind: "ReadOnlyKind",
72
+ });
73
+
74
+ expect(kindDef).toBeDefined();
75
+ expect(kindDef?.delete).toBeUndefined();
76
+ });
77
+
78
+ it("propagates errors from the delete handler", async () => {
79
+ const registry = createEntityKindRegistry();
80
+
81
+ registry.registerKind({
82
+ apiVersion: CHECKSTACK_API_VERSION,
83
+ kind: "FailingKind",
84
+ specSchema: z.object({}),
85
+ reconcile: async () => ({ entityId: "test-id" }),
86
+ delete: async () => {
87
+ throw new Error("DB connection failed");
88
+ },
89
+ });
90
+
91
+ const kindDef = registry.getKind({
92
+ apiVersion: CHECKSTACK_API_VERSION,
93
+ kind: "FailingKind",
94
+ });
95
+
96
+ expect(
97
+ kindDef!.delete!({
98
+ entityName: "broken-entity",
99
+ entityId: "broken-id",
100
+ context: {
101
+ logger: {
102
+ debug: () => {},
103
+ info: () => {},
104
+ warn: () => {},
105
+ error: () => {},
106
+ },
107
+ resolveEntityRef: async () => undefined,
108
+ },
109
+ }),
110
+ ).rejects.toThrow("DB connection failed");
111
+ });
112
+
113
+ it("returns undefined for unknown kind lookups (delete not called)", () => {
114
+ const registry = createEntityKindRegistry();
115
+
116
+ const kindDef = registry.getKind({
117
+ apiVersion: CHECKSTACK_API_VERSION,
118
+ kind: "NonExistent",
119
+ });
120
+
121
+ expect(kindDef).toBeUndefined();
122
+ });
123
+ });