@checkstack/gitops-common 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # @checkstack/gitops-common
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6c40b5b: feat: add GitOps Entity System foundation — entity envelope schema, Entity Kind Registry extension point, secret field utility, secret resolution engine, provenance tracking, and RPC contract
8
+ - 6c40b5b: Generalized provenance system and GitOps frontend plugin
9
+
10
+ **Breaking**: `EntityKindDefinition.reconcile()` now returns `{ entityId: string }` instead of `void`. Plugins must return the plugin-specific entity ID (e.g., catalog system UUID) so the engine can store it in provenance.
11
+
12
+ - Added `entityId` column to the provenance table (non-nullable)
13
+ - Reconciler engine passes `existingEntityId` to plugins for updates
14
+ - `getProvenance` now supports lookup by `entityId` in addition to `entityName`
15
+ - Added provider CRUD endpoints: `createProvider`, `updateProvider`, `deleteProvider`
16
+ - Created `gitops-frontend` plugin with provider management, secret management, and sync status dashboard
17
+ - Removed `gitops_entity_name` metadata markers from catalog entities
18
+ - Removed `findSystemByGitOpsName`, `deleteSystemByGitOpsName` (and Group equivalents) from EntityService
19
+ - Added provenance-based UI locking in catalog-frontend: edit/delete/drag disabled for GitOps-managed systems and groups
20
+
21
+ - 6c40b5b: ### GitOps Ecosystem: Healthcheck Kind Registration (Phase 5)
22
+
23
+ **gitops-common**: Added required `resolveEntityRef` to `ReconcileContext`, enabling extension reconcilers to resolve cross-kind entity references (e.g., healthcheck refs in System extensions).
24
+
25
+ **gitops-backend**: Updated reconciler to populate `resolveEntityRef` by querying local provenance — no RPC round-trip needed.
26
+
27
+ **healthcheck-backend**: Registered `kind: Healthcheck` and `System → healthchecks` extension with the EntityKindRegistry:
28
+
29
+ - Validates strategy configs against registered strategy schemas at reconcile time
30
+ - Validates collector configs against registered collector schemas at reconcile time
31
+ - Manages system ↔ healthcheck associations with automatic stale removal
32
+
33
+ **healthcheck-frontend**: Added GitOps provenance locking to the HealthCheck IDE editor — GitOps-managed health checks show a lock banner and disable editing.
34
+
35
+ **catalog-backend**: Updated test fixtures for new required `resolveEntityRef` context field.
36
+
37
+ - 6c40b5b: Add Kind Registry browser and developer documentation
38
+
39
+ - Added `gitopsAccess.kinds.read` access rule for standalone Kind Registry access
40
+ - Added `describeKinds()` method to the internal entity kind registry, serializing Zod schemas to JSON Schema
41
+ - Added `listKinds` RPC endpoint gated by the new access rule
42
+ - Created standalone Kind Registry page with schema visualization, extension listing, and auto-generated YAML examples
43
+ - Added Kind Registry link to the user menu
44
+ - Created developer documentation for entity kind and extension registration in `docs/backend/gitops-entity-kinds.md`
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@checkstack/gitops-common",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.ts"
8
+ }
9
+ },
10
+ "dependencies": {
11
+ "@checkstack/common": "0.6.5",
12
+ "@orpc/contract": "^1.13.14",
13
+ "zod": "^4.2.1"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.7.2",
17
+ "@checkstack/tsconfig": "0.0.5",
18
+ "@checkstack/scripts": "0.1.2"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "bun run lint:code",
23
+ "lint:code": "eslint . --max-warnings 0"
24
+ },
25
+ "checkstack": {
26
+ "type": "common"
27
+ }
28
+ }
package/src/access.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { access, accessPair } from "@checkstack/common";
2
+
3
+ /**
4
+ * Access rules for the GitOps plugin.
5
+ */
6
+ export const gitopsAccess = {
7
+ /** Provider management (add/edit/remove Git providers). */
8
+ provider: accessPair("provider", {
9
+ read: {
10
+ description: "View GitOps providers and sync status",
11
+ isDefault: true,
12
+ },
13
+ manage: {
14
+ description: "Add, edit, and remove GitOps providers",
15
+ },
16
+ }),
17
+
18
+ /** Secret management for secretRef values. */
19
+ secret: accessPair("secret", {
20
+ read: {
21
+ description: "View secret names (not values)",
22
+ isDefault: true,
23
+ },
24
+ manage: {
25
+ description: "Create, rotate, and delete secrets",
26
+ },
27
+ }),
28
+
29
+ /** Kind registry browsing. */
30
+ kinds: {
31
+ read: access("kinds", "read", "View entity kind definitions and schemas", {
32
+ isDefault: true,
33
+ }),
34
+ },
35
+ };
36
+
37
+ /**
38
+ * All access rules for registration with the plugin system.
39
+ */
40
+ export const gitopsAccessRules = [
41
+ gitopsAccess.provider.read,
42
+ gitopsAccess.provider.manage,
43
+ gitopsAccess.secret.read,
44
+ gitopsAccess.secret.manage,
45
+ gitopsAccess.kinds.read,
46
+ ];
47
+
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ entityEnvelopeSchema,
4
+ CHECKSTACK_API_VERSION,
5
+ } from "./entity-envelope";
6
+
7
+ describe("entityEnvelopeSchema", () => {
8
+ it("accepts a valid entity envelope", () => {
9
+ const result = entityEnvelopeSchema.safeParse({
10
+ apiVersion: CHECKSTACK_API_VERSION,
11
+ kind: "System",
12
+ metadata: {
13
+ name: "payment-service",
14
+ title: "Payment Service",
15
+ description: "Handles payments",
16
+ labels: { team: "platform" },
17
+ annotations: { "pagerduty.com/service-id": "PD123" },
18
+ tags: ["production"],
19
+ },
20
+ spec: { description: "test" },
21
+ });
22
+ expect(result.success).toBe(true);
23
+ });
24
+
25
+ it("accepts a minimal entity envelope (only required fields)", () => {
26
+ const result = entityEnvelopeSchema.safeParse({
27
+ apiVersion: CHECKSTACK_API_VERSION,
28
+ kind: "System",
29
+ metadata: { name: "my-system" },
30
+ });
31
+ expect(result.success).toBe(true);
32
+ });
33
+
34
+ it("rejects metadata.name with uppercase characters", () => {
35
+ const result = entityEnvelopeSchema.safeParse({
36
+ apiVersion: CHECKSTACK_API_VERSION,
37
+ kind: "System",
38
+ metadata: { name: "MySystem" },
39
+ });
40
+ expect(result.success).toBe(false);
41
+ });
42
+
43
+ it("rejects metadata.name exceeding 63 characters", () => {
44
+ const result = entityEnvelopeSchema.safeParse({
45
+ apiVersion: CHECKSTACK_API_VERSION,
46
+ kind: "System",
47
+ metadata: { name: "a".repeat(64) },
48
+ });
49
+ expect(result.success).toBe(false);
50
+ });
51
+
52
+ it("rejects metadata.name starting with a hyphen", () => {
53
+ const result = entityEnvelopeSchema.safeParse({
54
+ apiVersion: CHECKSTACK_API_VERSION,
55
+ kind: "System",
56
+ metadata: { name: "-invalid" },
57
+ });
58
+ expect(result.success).toBe(false);
59
+ });
60
+
61
+ it("rejects missing apiVersion", () => {
62
+ const result = entityEnvelopeSchema.safeParse({
63
+ kind: "System",
64
+ metadata: { name: "test" },
65
+ });
66
+ expect(result.success).toBe(false);
67
+ });
68
+
69
+ it("rejects invalid apiVersion format", () => {
70
+ const result = entityEnvelopeSchema.safeParse({
71
+ apiVersion: "invalid",
72
+ kind: "System",
73
+ metadata: { name: "test" },
74
+ });
75
+ expect(result.success).toBe(false);
76
+ });
77
+
78
+ it("rejects empty kind", () => {
79
+ const result = entityEnvelopeSchema.safeParse({
80
+ apiVersion: CHECKSTACK_API_VERSION,
81
+ kind: "",
82
+ metadata: { name: "test" },
83
+ });
84
+ expect(result.success).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The current Checkstack API version for entity descriptors.
5
+ */
6
+ export const CHECKSTACK_API_VERSION = "checkstack.io/v1alpha1";
7
+
8
+ /**
9
+ * Regex for valid entity names.
10
+ * Must start with a lowercase letter or digit, followed by lowercase letters, digits, or hyphens.
11
+ * Maximum 63 characters (aligned with Backstage/Kubernetes conventions).
12
+ */
13
+ const entityNameRegex = /^[a-z0-9][a-z0-9-]*$/;
14
+
15
+ /**
16
+ * Schema for the entity metadata block.
17
+ * These fields are common to ALL entity kinds.
18
+ */
19
+ export const entityMetadataSchema = z.object({
20
+ /** URL-safe unique identifier. Lowercase alphanumeric + hyphens, max 63 chars. */
21
+ name: z
22
+ .string()
23
+ .regex(
24
+ entityNameRegex,
25
+ "Must be lowercase alphanumeric with hyphens, starting with a letter or digit",
26
+ )
27
+ .max(63, "Entity name must not exceed 63 characters"),
28
+
29
+ /** Optional human-readable display name. */
30
+ title: z.string().optional(),
31
+
32
+ /** Optional description. */
33
+ description: z.string().optional(),
34
+
35
+ /** Optional key-value pairs for filtering and organizing. */
36
+ labels: z.record(z.string(), z.string()).optional(),
37
+
38
+ /** Optional key-value pairs for machine-readable metadata. */
39
+ annotations: z.record(z.string(), z.string()).optional(),
40
+
41
+ /** Optional string tags for categorization. */
42
+ tags: z.array(z.string()).optional(),
43
+ });
44
+
45
+ export type EntityMetadata = z.infer<typeof entityMetadataSchema>;
46
+
47
+ /**
48
+ * Schema for the base entity envelope.
49
+ * All YAML descriptors must conform to this shape.
50
+ * The `spec` field is validated per-kind by the Entity Kind Registry.
51
+ */
52
+ export const entityEnvelopeSchema = z.object({
53
+ /** Versioned API identifier (e.g., "checkstack.io/v1alpha1"). */
54
+ apiVersion: z
55
+ .string()
56
+ .regex(
57
+ /^[\w.-]+\/v[\w.]+$/,
58
+ "Must follow format: domain/version (e.g., checkstack.io/v1alpha1)",
59
+ ),
60
+
61
+ /** The entity kind (e.g., "System", "Healthcheck"). Must be registered. */
62
+ kind: z.string().min(1, "Kind must not be empty"),
63
+
64
+ /** Common metadata fields. */
65
+ metadata: entityMetadataSchema,
66
+
67
+ /** Kind-specific specification. Validated by the kind's registered schema. */
68
+ spec: z.record(z.string(), z.unknown()).optional(),
69
+ });
70
+
71
+ export type EntityEnvelope = z.infer<typeof entityEnvelopeSchema>;
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import type { EntityEnvelope } from "./entity-envelope";
3
+
4
+ /**
5
+ * Context passed to reconcile/delete functions.
6
+ * Extended by gitops-backend with concrete service references.
7
+ */
8
+ export interface ReconcileContext {
9
+ /** Logger scoped to this reconciliation */
10
+ logger: {
11
+ debug: (msg: string) => void;
12
+ info: (msg: string) => void;
13
+ warn: (msg: string) => void;
14
+ error: (msg: string) => void;
15
+ };
16
+
17
+ /**
18
+ * Resolve a GitOps entity reference to its plugin-specific entity ID.
19
+ * Used by extension reconcilers to look up cross-kind references
20
+ * (e.g., a System extension resolving a Healthcheck ref name → config UUID).
21
+ *
22
+ * Returns `undefined` if the entity is not found in provenance.
23
+ */
24
+ resolveEntityRef: (params: {
25
+ kind: string;
26
+ entityName: string;
27
+ }) => Promise<string | undefined>;
28
+ }
29
+
30
+ /**
31
+ * Definition for registering a new entity kind.
32
+ * The owning plugin provides the base spec schema and reconciliation logic.
33
+ */
34
+ export interface EntityKindDefinition<TSpec = unknown> {
35
+ /** The API version this kind belongs to (e.g., "checkstack.io/v1alpha1"). */
36
+ apiVersion: string;
37
+
38
+ /** The kind name (e.g., "System", "Healthcheck"). Must be unique per apiVersion. */
39
+ kind: string;
40
+
41
+ /** Zod schema for validating the `spec` section of descriptors of this kind. */
42
+ specSchema: z.ZodType<TSpec>;
43
+
44
+ /**
45
+ * Called when an entity of this kind is discovered or updated via GitOps.
46
+ * The entity's spec is fully validated and all secretRef values are resolved.
47
+ *
48
+ * Must return the plugin-specific entity ID (e.g., the catalog system UUID).
49
+ * The reconciler engine stores this in provenance for generic frontend lookups.
50
+ */
51
+ reconcile: (params: {
52
+ entity: EntityEnvelope & { spec: TSpec };
53
+ /** The plugin-specific entity ID from a previous reconcile, if this entity was reconciled before. */
54
+ existingEntityId?: string;
55
+ context: ReconcileContext;
56
+ }) => Promise<{ entityId: string }>;
57
+
58
+ /**
59
+ * Called when an entity of this kind is removed from git (deletion policy: "auto")
60
+ * or when an orphan is manually confirmed for deletion.
61
+ */
62
+ delete?: (params: {
63
+ entityName: string;
64
+ /** The plugin-specific entity ID from provenance, if available. */
65
+ entityId?: string;
66
+ context: ReconcileContext;
67
+ }) => Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Definition for extending an existing entity kind's spec.
72
+ * The extending plugin adds namespaced fields to another kind's spec schema.
73
+ */
74
+ export interface EntityKindExtensionDefinition<TExtensionSpec = unknown> {
75
+ /** The API version of the kind being extended. */
76
+ apiVersion: string;
77
+
78
+ /** The kind being extended (e.g., "System"). */
79
+ kind: string;
80
+
81
+ /**
82
+ * Namespace for this extension's spec fields.
83
+ * The extension's spec is placed under `spec.<namespace>` in the descriptor.
84
+ * Must be unique per kind. Convention: use your plugin ID (e.g., "healthcheck").
85
+ */
86
+ namespace: string;
87
+
88
+ /** Zod schema for validating the extension's spec fields. Should be `.optional()`. */
89
+ specSchema: z.ZodType<TExtensionSpec>;
90
+
91
+ /**
92
+ * Called when an entity with this extension's namespace is reconciled.
93
+ * Only called if the extension's namespace is present in the spec.
94
+ */
95
+ reconcile: (params: {
96
+ entity: EntityEnvelope;
97
+ extensionSpec: TExtensionSpec;
98
+ /** The plugin-specific entity ID returned by the base kind's reconciler. */
99
+ entityId: string;
100
+ context: ReconcileContext;
101
+ }) => Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * The registry interface exposed via the Extension Point.
106
+ * Plugins call these methods during their `register()` phase.
107
+ */
108
+ export interface EntityKindRegistry {
109
+ /** Register a new entity kind (e.g., catalog registers "System"). */
110
+ registerKind<TSpec>(definition: EntityKindDefinition<TSpec>): void;
111
+
112
+ /** Extend an existing kind's spec (e.g., healthcheck extends "System"). */
113
+ registerKindExtension<TExtensionSpec>(
114
+ definition: EntityKindExtensionDefinition<TExtensionSpec>,
115
+ ): void;
116
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ entityRefSchema,
4
+ extractEntityRefs,
5
+ type EntityRef,
6
+ } from "./entity-ref";
7
+
8
+ describe("entityRefSchema", () => {
9
+ it("validates a well-formed entity ref", () => {
10
+ const result = entityRefSchema.safeParse({
11
+ kind: "Healthcheck",
12
+ name: "payment-db-check",
13
+ });
14
+ expect(result.success).toBe(true);
15
+ });
16
+
17
+ it("rejects missing kind", () => {
18
+ const result = entityRefSchema.safeParse({ name: "foo" });
19
+ expect(result.success).toBe(false);
20
+ });
21
+
22
+ it("rejects missing name", () => {
23
+ const result = entityRefSchema.safeParse({ kind: "System" });
24
+ expect(result.success).toBe(false);
25
+ });
26
+
27
+ it("rejects empty kind", () => {
28
+ const result = entityRefSchema.safeParse({ kind: "", name: "foo" });
29
+ expect(result.success).toBe(false);
30
+ });
31
+
32
+ it("rejects empty name", () => {
33
+ const result = entityRefSchema.safeParse({ kind: "System", name: "" });
34
+ expect(result.success).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("extractEntityRefs", () => {
39
+ it("extracts a single ref from a flat object", () => {
40
+ const spec = {
41
+ ref: { kind: "Healthcheck", name: "db-check" },
42
+ };
43
+ const refs = extractEntityRefs(spec);
44
+ expect(refs).toEqual([{ kind: "Healthcheck", name: "db-check" }]);
45
+ });
46
+
47
+ it("extracts multiple refs from an array", () => {
48
+ const spec = {
49
+ healthchecks: [
50
+ { ref: { kind: "Healthcheck", name: "db-check" } },
51
+ { ref: { kind: "Healthcheck", name: "api-check" } },
52
+ ],
53
+ };
54
+ const refs = extractEntityRefs(spec);
55
+ expect(refs).toEqual([
56
+ { kind: "Healthcheck", name: "db-check" },
57
+ { kind: "Healthcheck", name: "api-check" },
58
+ ]);
59
+ });
60
+
61
+ it("extracts refs from deeply nested structures", () => {
62
+ const spec = {
63
+ level1: {
64
+ level2: {
65
+ level3: { kind: "System", name: "nested-sys" },
66
+ },
67
+ },
68
+ };
69
+ const refs = extractEntityRefs(spec);
70
+ expect(refs).toEqual([{ kind: "System", name: "nested-sys" }]);
71
+ });
72
+
73
+ it("ignores objects with non-string kind or name", () => {
74
+ const spec = {
75
+ notARef1: { kind: 123, name: "foo" },
76
+ notARef2: { kind: "System", name: 456 },
77
+ notARef3: { kind: true, name: false },
78
+ };
79
+ const refs = extractEntityRefs(spec);
80
+ expect(refs).toEqual([]);
81
+ });
82
+
83
+ it("ignores objects with empty kind or name", () => {
84
+ const spec = {
85
+ emptyKind: { kind: "", name: "foo" },
86
+ emptyName: { kind: "System", name: "" },
87
+ };
88
+ const refs = extractEntityRefs(spec);
89
+ expect(refs).toEqual([]);
90
+ });
91
+
92
+ it("ignores primitives, nulls, and strings", () => {
93
+ const spec = {
94
+ str: "just a string",
95
+ num: 42,
96
+ bool: true,
97
+ nil: null,
98
+ };
99
+ const refs = extractEntityRefs(spec);
100
+ expect(refs).toEqual([]);
101
+ });
102
+
103
+ it("does not recurse into matched refs (no double-counting)", () => {
104
+ const ref: EntityRef = { kind: "Healthcheck", name: "db-check" };
105
+ const spec = { ref };
106
+ const refs = extractEntityRefs(spec);
107
+ // Should find exactly one ref, not recurse into it
108
+ expect(refs).toHaveLength(1);
109
+ });
110
+
111
+ it("handles mixed valid and invalid entries", () => {
112
+ const spec = {
113
+ items: [
114
+ { ref: { kind: "Healthcheck", name: "valid" } },
115
+ { ref: "invalid-string" },
116
+ { ref: { kind: "System", name: "also-valid" } },
117
+ { ref: null },
118
+ ],
119
+ };
120
+ const refs = extractEntityRefs(spec);
121
+ expect(refs).toEqual([
122
+ { kind: "Healthcheck", name: "valid" },
123
+ { kind: "System", name: "also-valid" },
124
+ ]);
125
+ });
126
+ });
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * K8s-style structured entity reference.
5
+ * Used in YAML specs to reference other GitOps-managed entities.
6
+ *
7
+ * @example
8
+ * ```yaml
9
+ * healthchecks:
10
+ * - ref:
11
+ * kind: Healthcheck
12
+ * name: payment-db-check
13
+ * ```
14
+ */
15
+ export const entityRefSchema = z.object({
16
+ kind: z.string().min(1),
17
+ name: z.string().min(1),
18
+ });
19
+
20
+ export type EntityRef = z.infer<typeof entityRefSchema>;
21
+
22
+ /**
23
+ * Check if a value is a valid EntityRef shape (object with string `kind` and `name`).
24
+ */
25
+ function isEntityRef(value: unknown): value is EntityRef {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
27
+ return false;
28
+ }
29
+ const obj = value as Record<string, unknown>;
30
+ return (
31
+ typeof obj.kind === "string" &&
32
+ obj.kind.length > 0 &&
33
+ typeof obj.name === "string" &&
34
+ obj.name.length > 0
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Recursively extract all entity refs from a spec value.
40
+ *
41
+ * Walks objects and arrays, collecting objects that have both
42
+ * a `kind` (non-empty string) and `name` (non-empty string) property.
43
+ * Matched refs are not recursed into (no double-counting).
44
+ */
45
+ export function extractEntityRefs(value: unknown): EntityRef[] {
46
+ const refs: EntityRef[] = [];
47
+ walk(value, refs);
48
+ return refs;
49
+ }
50
+
51
+ function walk(value: unknown, refs: EntityRef[]): void {
52
+ if (typeof value !== "object" || value === null) {
53
+ return;
54
+ }
55
+
56
+ if (Array.isArray(value)) {
57
+ for (const item of value) {
58
+ walk(item, refs);
59
+ }
60
+ return;
61
+ }
62
+
63
+ // Check if this object IS a ref — collect it and stop recursing
64
+ if (isEntityRef(value)) {
65
+ refs.push({ kind: value.kind, name: value.name });
66
+ return;
67
+ }
68
+
69
+ // Otherwise recurse into child values
70
+ for (const child of Object.values(value as Record<string, unknown>)) {
71
+ walk(child, refs);
72
+ }
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ export { pluginMetadata } from "./plugin-metadata";
2
+ export {
3
+ entityEnvelopeSchema,
4
+ entityMetadataSchema,
5
+ CHECKSTACK_API_VERSION,
6
+ type EntityEnvelope,
7
+ type EntityMetadata,
8
+ } from "./entity-envelope";
9
+ export {
10
+ secretField,
11
+ secretRefSchema,
12
+ isSecretRef,
13
+ type SecretRef,
14
+ type ResolvedSecretField,
15
+ } from "./secret-field";
16
+ export {
17
+ entityRefSchema,
18
+ extractEntityRefs,
19
+ type EntityRef,
20
+ } from "./entity-ref";
21
+ export {
22
+ type EntityKindDefinition,
23
+ type EntityKindExtensionDefinition,
24
+ type EntityKindRegistry,
25
+ type ReconcileContext,
26
+ } from "./entity-kind-registry";
27
+ export { gitopsAccess, gitopsAccessRules } from "./access";
28
+ export { gitopsRoutes } from "./routes";
29
+ export {
30
+ provenanceSchema,
31
+ provenanceStatusSchema,
32
+ deletionPolicySchema,
33
+ type Provenance,
34
+ type ProvenanceStatus,
35
+ type DeletionPolicy,
36
+ } from "./provenance-types";
37
+ export { gitopsContract, GitOpsApi, type GitOpsContract } from "./rpc-contract";
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the GitOps plugin.
5
+ * Exported from the common package so both backend and frontend can reference it.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "gitops",
9
+ });
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+
3
+ export const provenanceStatusSchema = z.enum(["synced", "error", "orphaned"]);
4
+ export type ProvenanceStatus = z.infer<typeof provenanceStatusSchema>;
5
+
6
+ export const deletionPolicySchema = z.enum(["orphan", "auto"]);
7
+ export type DeletionPolicy = z.infer<typeof deletionPolicySchema>;
8
+
9
+ export const provenanceSchema = z.object({
10
+ id: z.string(),
11
+ apiVersion: z.string(),
12
+ kind: z.string(),
13
+ entityName: z.string(),
14
+ /** Plugin-specific entity ID (e.g., catalog system UUID). Set by the reconciler engine. */
15
+ entityId: z.string(),
16
+ providerId: z.string(),
17
+ repository: z.string(),
18
+ filePath: z.string(),
19
+ lastSyncHash: z.string(),
20
+ status: provenanceStatusSchema,
21
+ errorMessage: z.string().nullable(),
22
+ lastSyncedAt: z.date(),
23
+ createdAt: z.date(),
24
+ });
25
+
26
+ export type Provenance = z.infer<typeof provenanceSchema>;
package/src/routes.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { createRoutes } from "@checkstack/common";
2
+
3
+ /**
4
+ * Route definitions for the GitOps plugin.
5
+ */
6
+ export const gitopsRoutes = createRoutes("gitops", {
7
+ home: "/",
8
+ providers: "/providers",
9
+ secrets: "/secrets",
10
+ status: "/status",
11
+ kinds: "/kinds",
12
+ });
@@ -0,0 +1,250 @@
1
+ import { createClientDefinition, proc } from "@checkstack/common";
2
+ import { pluginMetadata } from "./plugin-metadata";
3
+ import { gitopsAccess } from "./access";
4
+ import {
5
+ provenanceSchema,
6
+ provenanceStatusSchema,
7
+ deletionPolicySchema,
8
+ } from "./provenance-types";
9
+ import { z } from "zod";
10
+
11
+ export const gitopsContract = {
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // PROVENANCE QUERIES (public read access)
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ /** Check if an entity is GitOps-managed. Used by frontends to lock editors. */
17
+ getProvenance: proc({
18
+ operationType: "query",
19
+ userType: "public",
20
+ access: [gitopsAccess.provider.read],
21
+ })
22
+ .input(z.object({
23
+ kind: z.string(),
24
+ /** Look up by envelope entity name. */
25
+ entityName: z.string().optional(),
26
+ /** Look up by plugin-specific entity ID (e.g., catalog system UUID). */
27
+ entityId: z.string().optional(),
28
+ }))
29
+ .output(provenanceSchema.nullable()),
30
+
31
+ /** List all provenance entries, optionally filtered by status. */
32
+ listProvenance: proc({
33
+ operationType: "query",
34
+ userType: "public",
35
+ access: [gitopsAccess.provider.read],
36
+ })
37
+ .input(
38
+ z
39
+ .object({
40
+ status: provenanceStatusSchema.optional(),
41
+ providerId: z.string().optional(),
42
+ })
43
+ .optional(),
44
+ )
45
+ .output(z.array(provenanceSchema)),
46
+
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+ // PROVIDER MANAGEMENT (admin)
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+
51
+ /** List all configured providers. */
52
+ listProviders: proc({
53
+ operationType: "query",
54
+ userType: "authenticated",
55
+ access: [gitopsAccess.provider.read],
56
+ }).output(
57
+ z.array(
58
+ z.object({
59
+ id: z.string(),
60
+ type: z.enum(["github", "gitlab"]),
61
+ target: z.string(),
62
+ pathPattern: z.string(),
63
+ baseUrl: z.string().nullable(),
64
+ syncInterval: z.number(),
65
+ deletionPolicy: deletionPolicySchema,
66
+ lastSyncAt: z.date().nullable(),
67
+ lastSyncError: z.string().nullable(),
68
+ createdAt: z.date(),
69
+ }),
70
+ ),
71
+ ),
72
+
73
+ /** Create a new GitOps provider. */
74
+ createProvider: proc({
75
+ operationType: "mutation",
76
+ userType: "authenticated",
77
+ access: [gitopsAccess.provider.manage],
78
+ })
79
+ .input(
80
+ z.object({
81
+ type: z.enum(["github", "gitlab"]),
82
+ target: z.string().min(1),
83
+ pathPattern: z.string().min(1),
84
+ baseUrl: z.string().optional(),
85
+ authToken: z.string().optional(),
86
+ syncInterval: z.number().int().min(60).optional(),
87
+ deletionPolicy: deletionPolicySchema.optional(),
88
+ }),
89
+ )
90
+ .output(z.object({ id: z.string() })),
91
+
92
+ /** Update an existing provider. */
93
+ updateProvider: proc({
94
+ operationType: "mutation",
95
+ userType: "authenticated",
96
+ access: [gitopsAccess.provider.manage],
97
+ })
98
+ .input(
99
+ z.object({
100
+ id: z.string(),
101
+ data: z.object({
102
+ target: z.string().min(1).optional(),
103
+ pathPattern: z.string().min(1).optional(),
104
+ baseUrl: z.string().nullable().optional(),
105
+ authToken: z.string().optional(),
106
+ syncInterval: z.number().int().min(60).optional(),
107
+ deletionPolicy: deletionPolicySchema.optional(),
108
+ }),
109
+ }),
110
+ )
111
+ .output(z.object({ success: z.boolean() })),
112
+
113
+ /** Delete a provider and all its provenance entries. */
114
+ deleteProvider: proc({
115
+ operationType: "mutation",
116
+ userType: "authenticated",
117
+ access: [gitopsAccess.provider.manage],
118
+ })
119
+ .input(z.object({ id: z.string() }))
120
+ .output(z.object({ success: z.boolean() })),
121
+
122
+ /** Trigger a manual sync for a provider. */
123
+ triggerSync: proc({
124
+ operationType: "mutation",
125
+ userType: "authenticated",
126
+ access: [gitopsAccess.provider.manage],
127
+ })
128
+ .input(z.object({ providerId: z.string() }))
129
+ .output(z.object({ success: z.boolean() })),
130
+
131
+ /** Confirm deletion of an orphaned entity. */
132
+ confirmOrphanDeletion: proc({
133
+ operationType: "mutation",
134
+ userType: "authenticated",
135
+ access: [gitopsAccess.provider.manage],
136
+ })
137
+ .input(z.object({ provenanceId: z.string() }))
138
+ .output(z.object({ success: z.boolean() })),
139
+
140
+ /** Dismiss orphan status (keep entity, remove provenance tracking). */
141
+ dismissOrphan: proc({
142
+ operationType: "mutation",
143
+ userType: "authenticated",
144
+ access: [gitopsAccess.provider.manage],
145
+ })
146
+ .input(z.object({ provenanceId: z.string() }))
147
+ .output(z.object({ success: z.boolean() })),
148
+
149
+ // ═══════════════════════════════════════════════════════════════════════════
150
+ // SECRET MANAGEMENT
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+
153
+ /** List secret names (never values). */
154
+ listSecrets: proc({
155
+ operationType: "query",
156
+ userType: "authenticated",
157
+ access: [gitopsAccess.secret.read],
158
+ }).output(
159
+ z.array(
160
+ z.object({
161
+ id: z.string(),
162
+ name: z.string(),
163
+ description: z.string().nullable(),
164
+ createdAt: z.date(),
165
+ updatedAt: z.date(),
166
+ }),
167
+ ),
168
+ ),
169
+
170
+ /** Create a new secret. */
171
+ createSecret: proc({
172
+ operationType: "mutation",
173
+ userType: "authenticated",
174
+ access: [gitopsAccess.secret.manage],
175
+ })
176
+ .input(
177
+ z.object({
178
+ name: z.string().min(1).max(63),
179
+ value: z.string().min(1),
180
+ description: z.string().optional(),
181
+ }),
182
+ )
183
+ .output(z.object({ id: z.string(), name: z.string() })),
184
+
185
+ /** Rotate (update) a secret's value. */
186
+ rotateSecret: proc({
187
+ operationType: "mutation",
188
+ userType: "authenticated",
189
+ access: [gitopsAccess.secret.manage],
190
+ })
191
+ .input(
192
+ z.object({
193
+ id: z.string(),
194
+ value: z.string().min(1),
195
+ }),
196
+ )
197
+ .output(z.object({ success: z.boolean() })),
198
+
199
+ /** Delete a secret. */
200
+ deleteSecret: proc({
201
+ operationType: "mutation",
202
+ userType: "authenticated",
203
+ access: [gitopsAccess.secret.manage],
204
+ })
205
+ .input(z.object({ id: z.string() }))
206
+ .output(z.object({ success: z.boolean() })),
207
+
208
+ // ═══════════════════════════════════════════════════════════════════════════
209
+ // SERVICE INTERFACE (backend-to-backend)
210
+ // ═══════════════════════════════════════════════════════════════════════════
211
+
212
+ /** Resolve a secret by name. Used internally by the reconciliation engine. */
213
+ resolveSecret: proc({
214
+ operationType: "query",
215
+ userType: "service",
216
+ access: [],
217
+ })
218
+ .input(z.object({ name: z.string() }))
219
+ .output(z.object({ value: z.string() })),
220
+
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+ // KIND REGISTRY (browsing)
223
+ // ═══════════════════════════════════════════════════════════════════════════
224
+
225
+ /** List all registered entity kinds with their spec schemas and extensions. */
226
+ listKinds: proc({
227
+ operationType: "query",
228
+ userType: "authenticated",
229
+ access: [gitopsAccess.kinds.read],
230
+ }).output(
231
+ z.array(
232
+ z.object({
233
+ apiVersion: z.string(),
234
+ kind: z.string(),
235
+ specSchema: z.record(z.string(), z.unknown()),
236
+ extensions: z.array(
237
+ z.object({
238
+ namespace: z.string(),
239
+ specSchema: z.record(z.string(), z.unknown()),
240
+ }),
241
+ ),
242
+ }),
243
+ ),
244
+ ),
245
+ };
246
+
247
+ export type GitOpsContract = typeof gitopsContract;
248
+
249
+ /** Export client definition for type-safe forPlugin usage. */
250
+ export const GitOpsApi = createClientDefinition(gitopsContract, pluginMetadata);
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { secretField, isSecretRef, type SecretRef } from "./secret-field";
3
+
4
+ describe("secretField", () => {
5
+ const schema = secretField();
6
+
7
+ it("accepts a plain string value", () => {
8
+ const result = schema.safeParse("my-password");
9
+ expect(result.success).toBe(true);
10
+ if (result.success) expect(result.data).toBe("my-password");
11
+ });
12
+
13
+ it("accepts a secretRef object", () => {
14
+ const result = schema.safeParse({ secretRef: "prod-db-password" });
15
+ expect(result.success).toBe(true);
16
+ if (result.success) {
17
+ expect(result.data).toEqual({ secretRef: "prod-db-password" });
18
+ }
19
+ });
20
+
21
+ it("rejects a number", () => {
22
+ const result = schema.safeParse(42);
23
+ expect(result.success).toBe(false);
24
+ });
25
+
26
+ it("rejects an object without secretRef", () => {
27
+ const result = schema.safeParse({ key: "value" });
28
+ expect(result.success).toBe(false);
29
+ });
30
+
31
+ it("rejects secretRef with empty name", () => {
32
+ const result = schema.safeParse({ secretRef: "" });
33
+ expect(result.success).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("isSecretRef", () => {
38
+ it("returns true for a SecretRef object", () => {
39
+ const ref: SecretRef = { secretRef: "my-secret" };
40
+ expect(isSecretRef(ref)).toBe(true);
41
+ });
42
+
43
+ it("returns false for a plain string", () => {
44
+ expect(isSecretRef("plain")).toBe(false);
45
+ });
46
+
47
+ it("returns false for null", () => {
48
+ expect(isSecretRef(null)).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Schema for a secret reference object.
5
+ * Used in YAML descriptors to reference secrets stored in the Checkstack secret store.
6
+ */
7
+ export const secretRefSchema = z.object({
8
+ secretRef: z.string().min(1, "Secret reference name must not be empty"),
9
+ });
10
+
11
+ export type SecretRef = z.infer<typeof secretRefSchema>;
12
+
13
+ /**
14
+ * Type guard to check if a value is a SecretRef object.
15
+ */
16
+ export function isSecretRef(value: unknown): value is SecretRef {
17
+ if (value === null || value === undefined || typeof value !== "object") {
18
+ return false;
19
+ }
20
+ return (
21
+ "secretRef" in value &&
22
+ typeof (value as SecretRef).secretRef === "string"
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Creates a Zod schema for fields that accept either a plain string or a secret reference.
28
+ *
29
+ * In YAML descriptors, users can write either:
30
+ * ```yaml
31
+ * password: "dev-password" # plain string
32
+ * password:
33
+ * secretRef: production-db-creds # reference to secret store
34
+ * ```
35
+ *
36
+ * The GitOps reconciliation engine resolves all secretRef values before calling
37
+ * plugin reconcilers, so plugins always receive plain strings.
38
+ */
39
+ export function secretField() {
40
+ return z.union([z.string(), secretRefSchema]);
41
+ }
42
+
43
+ /**
44
+ * The resolved type of a secret field is always a string.
45
+ * After the reconciliation engine resolves secretRefs, all values are plain strings.
46
+ */
47
+ export type ResolvedSecretField = string;
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": ["src"]
4
+ }