@checkstack/gitops-backend 0.1.0 → 0.1.2

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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # @checkstack/gitops-backend
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 79cf5f8: ### GitOps: Fix sync lifecycle management
8
+
9
+ - Schedule recurring sync job immediately when creating a provider (previously required server restart)
10
+ - Reschedule recurring job when provider's sync interval is updated
11
+ - Cancel recurring job when provider is deleted
12
+ - Fix manual sync trigger being silently dropped due to job ID deduplication
13
+
14
+ ## 0.1.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 86bab6a: ### GitOps: Fix authentication token handling
19
+
20
+ - Made `authToken` optional in `ReconcileProviderParams` and `ScraperOptions` to support unauthenticated access to public repositories
21
+ - GitHub and GitLab scrapers now conditionally set authentication headers only when a token is provided
22
+ - Sync worker now decrypts the encrypted `authToken` from the database before passing it to scrapers, fixing authentication failures caused by sending encrypted values in HTTP headers
23
+
24
+ ### SLO: Fix premature Nines Club achievement unlock
25
+
26
+ - The "Nines Club" achievement now requires both ≥99.99% availability **and** a 365-day compliance streak, preventing immediate unlock on newly created SLOs with 100% default availability
27
+
28
+ ### SLO: Align frontend achievement descriptions with backend criteria
29
+
30
+ - Fixed mismatched descriptions for Iron Uptime (7-day, not 30), Diamond Uptime (30-day, not 90), Clean Sheet (rolling window, not quarter), Full Coverage (3+ SLOs, not all systems in group), and Nines Club (99.99%)
31
+
32
+ ### SLO: Enrich milestones with system names
33
+
34
+ - The `getRecentMilestones` endpoint now resolves human-readable system names via the Catalog API instead of returning raw system IDs
35
+
36
+ - Updated dependencies [86bab6a]
37
+ - @checkstack/gitops-common@0.1.1
38
+
3
39
  ## 0.1.0
4
40
 
5
41
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/gitops-backend",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@checkstack/backend-api": "0.12.0",
17
- "@checkstack/gitops-common": "0.0.1",
17
+ "@checkstack/gitops-common": "0.1.0",
18
18
  "@checkstack/common": "0.6.5",
19
19
  "@checkstack/command-backend": "0.1.19",
20
20
  "@checkstack/queue-api": "0.2.13",
package/src/router.ts CHANGED
@@ -5,7 +5,7 @@ import { gitopsContract } from "@checkstack/gitops-common";
5
5
  import type { SafeDatabase } from "@checkstack/backend-api";
6
6
  import type { QueueManager } from "@checkstack/queue-api";
7
7
  import type { InternalEntityKindRegistry } from "./kind-registry";
8
- import { triggerSyncForProvider } from "./sync/sync-worker";
8
+ import { triggerSyncForProvider, scheduleSyncForProvider, cancelSyncForProvider } from "./sync/sync-worker";
9
9
  import * as schema from "./schema";
10
10
  import { eq, and } from "drizzle-orm";
11
11
  import { v4 as uuidv4 } from "uuid";
@@ -82,6 +82,7 @@ export const createGitOpsRouter = ({
82
82
 
83
83
  const createProvider = os.createProvider.handler(async ({ input }) => {
84
84
  const id = uuidv4();
85
+ const syncInterval = input.syncInterval ?? 300;
85
86
  await db.insert(schema.providers).values({
86
87
  id,
87
88
  type: input.type,
@@ -89,9 +90,17 @@ export const createGitOpsRouter = ({
89
90
  pathPattern: input.pathPattern,
90
91
  baseUrl: input.baseUrl ?? null, // eslint-disable-line unicorn/no-null
91
92
  authToken: input.authToken ? encrypt(input.authToken) : null, // eslint-disable-line unicorn/no-null
92
- syncInterval: input.syncInterval ?? 300,
93
+ syncInterval,
93
94
  deletionPolicy: input.deletionPolicy ?? "orphan",
94
95
  });
96
+
97
+ // Schedule recurring sync for the new provider
98
+ await scheduleSyncForProvider({
99
+ queueManager,
100
+ providerId: id,
101
+ syncIntervalSeconds: syncInterval,
102
+ });
103
+
95
104
  return { id };
96
105
  });
97
106
 
@@ -112,8 +121,11 @@ export const createGitOpsRouter = ({
112
121
  if (input.data.pathPattern !== undefined)
113
122
  updates.pathPattern = input.data.pathPattern;
114
123
  if (input.data.baseUrl !== undefined) updates.baseUrl = input.data.baseUrl;
115
- if (input.data.authToken !== undefined)
116
- updates.authToken = encrypt(input.data.authToken);
124
+ if (input.data.authToken !== undefined) {
125
+ // null = explicitly clear token, string = encrypt and store
126
+ // eslint-disable-next-line unicorn/no-null
127
+ updates.authToken = input.data.authToken ? encrypt(input.data.authToken) : null;
128
+ }
117
129
  if (input.data.syncInterval !== undefined)
118
130
  updates.syncInterval = input.data.syncInterval;
119
131
  if (input.data.deletionPolicy !== undefined)
@@ -124,6 +136,15 @@ export const createGitOpsRouter = ({
124
136
  .set(updates)
125
137
  .where(eq(schema.providers.id, input.id));
126
138
 
139
+ // If syncInterval changed, reschedule the recurring job
140
+ if (input.data.syncInterval !== undefined) {
141
+ await scheduleSyncForProvider({
142
+ queueManager,
143
+ providerId: input.id,
144
+ syncIntervalSeconds: input.data.syncInterval,
145
+ });
146
+ }
147
+
127
148
  return { success: true };
128
149
  });
129
150
 
@@ -142,6 +163,12 @@ export const createGitOpsRouter = ({
142
163
  // Provenance entries are cascade-deleted via FK constraint
143
164
  await db.delete(schema.providers).where(eq(schema.providers.id, input.id));
144
165
 
166
+ // Cancel the recurring sync job
167
+ await cancelSyncForProvider({
168
+ queueManager,
169
+ providerId: input.id,
170
+ });
171
+
145
172
  return { success: true };
146
173
  });
147
174
 
@@ -352,4 +352,55 @@ describe("githubScraper", () => {
352
352
  expect(url).toStartWith(enterpriseUrl);
353
353
  }
354
354
  });
355
+ it("works without auth token and does not send Authorization header", async () => {
356
+ const capturedHeaders: Record<string, string>[] = [];
357
+
358
+ const mockFetch: FetchFn = async (input, init) => {
359
+ const url = typeof input === "string" ? input : input.toString();
360
+ const headers = init?.headers as Record<string, string> | undefined;
361
+ if (headers) {
362
+ capturedHeaders.push(headers);
363
+ }
364
+
365
+ if (url.includes("git/trees")) {
366
+ return new Response(
367
+ JSON.stringify({
368
+ sha: "abc",
369
+ tree: [{ path: ".checkstack/sys.yaml", type: "blob" }],
370
+ truncated: false,
371
+ }),
372
+ { headers: { "Content-Type": "application/json" } },
373
+ );
374
+ }
375
+
376
+ if (url.includes("contents/")) {
377
+ return new Response(
378
+ JSON.stringify({ content: btoa("yaml"), encoding: "base64" }),
379
+ { headers: { "Content-Type": "application/json" } },
380
+ );
381
+ }
382
+
383
+ if (url.includes("repos/public-org/repo")) {
384
+ return new Response(
385
+ JSON.stringify({ full_name: "public-org/repo", default_branch: "main" }),
386
+ { headers: { "Content-Type": "application/json" } },
387
+ );
388
+ }
389
+
390
+ return new Response("Not Found", { status: 404 });
391
+ };
392
+
393
+ const files = await githubScraper.discoverFiles({
394
+ target: "public-org/repo",
395
+ pathPattern: ".checkstack/**/*.yaml",
396
+ logger: mockLogger,
397
+ fetch: mockFetch,
398
+ });
399
+
400
+ expect(files).toHaveLength(1);
401
+ // Verify no Authorization header was sent
402
+ for (const headers of capturedHeaders) {
403
+ expect(headers.Authorization).toBeUndefined();
404
+ }
405
+ });
355
406
  });
@@ -43,17 +43,18 @@ function parseNextPageUrl(linkHeader: string | null): string | undefined {
43
43
  */
44
44
  async function githubFetch(params: {
45
45
  url: string;
46
- authToken: string;
46
+ authToken?: string;
47
47
  fetchFn: FetchFn;
48
48
  }): Promise<Response> {
49
49
  const { url, authToken, fetchFn } = params;
50
- return fetchFn(url, {
51
- headers: {
52
- Authorization: `Bearer ${authToken}`,
53
- Accept: "application/vnd.github+json",
54
- "X-GitHub-Api-Version": "2022-11-28",
55
- },
56
- });
50
+ const headers: Record<string, string> = {
51
+ Accept: "application/vnd.github+json",
52
+ "X-GitHub-Api-Version": "2022-11-28",
53
+ };
54
+ if (authToken) {
55
+ headers.Authorization = `Bearer ${authToken}`;
56
+ }
57
+ return fetchFn(url, { headers });
57
58
  }
58
59
 
59
60
  // ─── Core Logic ────────────────────────────────────────────────────────────
@@ -64,7 +65,7 @@ async function githubFetch(params: {
64
65
  */
65
66
  async function enumerateRepos(params: {
66
67
  target: string;
67
- authToken: string;
68
+ authToken?: string;
68
69
  fetchFn: FetchFn;
69
70
  apiUrl: string;
70
71
  }): Promise<GitHubRepo[]> {
@@ -113,7 +114,7 @@ async function enumerateRepos(params: {
113
114
  async function getMatchingFiles(params: {
114
115
  repo: GitHubRepo;
115
116
  pathPattern: string;
116
- authToken: string;
117
+ authToken?: string;
117
118
  fetchFn: FetchFn;
118
119
  apiUrl: string;
119
120
  }): Promise<string[]> {
@@ -143,7 +144,7 @@ async function fetchFileContent(params: {
143
144
  repoFullName: string;
144
145
  filePath: string;
145
146
  branch: string;
146
- authToken: string;
147
+ authToken?: string;
147
148
  fetchFn: FetchFn;
148
149
  apiUrl: string;
149
150
  }): Promise<string> {
@@ -293,4 +293,56 @@ describe("gitlabScraper", () => {
293
293
  expect(url).toStartWith(selfHostedUrl);
294
294
  }
295
295
  });
296
+ it("works without auth token and does not send PRIVATE-TOKEN header", async () => {
297
+ const capturedHeaders: Record<string, string>[] = [];
298
+
299
+ const mockFetch: FetchFn = async (input, init) => {
300
+ const url = typeof input === "string" ? input : input.toString();
301
+ const headers = init?.headers as Record<string, string> | undefined;
302
+ if (headers) {
303
+ capturedHeaders.push(headers);
304
+ }
305
+
306
+ if (url.includes("/repository/tree")) {
307
+ return new Response(
308
+ JSON.stringify([
309
+ { id: "a1", name: "sys.yaml", type: "blob", path: ".checkstack/sys.yaml" },
310
+ ]),
311
+ { headers: { "Content-Type": "application/json" } },
312
+ );
313
+ }
314
+
315
+ if (url.includes("/repository/files/") && url.includes("/raw")) {
316
+ return new Response("yaml-content", {
317
+ headers: { "Content-Type": "text/plain" },
318
+ });
319
+ }
320
+
321
+ if (url.includes("/projects/")) {
322
+ return new Response(
323
+ JSON.stringify({
324
+ id: 99,
325
+ path_with_namespace: "public/repo",
326
+ default_branch: "main",
327
+ }),
328
+ { headers: { "Content-Type": "application/json" } },
329
+ );
330
+ }
331
+
332
+ return new Response("Not Found", { status: 404 });
333
+ };
334
+
335
+ const files = await gitlabScraper.discoverFiles({
336
+ target: "public/repo",
337
+ pathPattern: ".checkstack/**/*.yaml",
338
+ logger: mockLogger,
339
+ fetch: mockFetch,
340
+ });
341
+
342
+ expect(files).toHaveLength(1);
343
+ // Verify no PRIVATE-TOKEN header was sent
344
+ for (const headers of capturedHeaders) {
345
+ expect(headers["PRIVATE-TOKEN"]).toBeUndefined();
346
+ }
347
+ });
296
348
  });
@@ -25,15 +25,15 @@ interface GitLabTreeItem {
25
25
  */
26
26
  async function gitlabFetch(params: {
27
27
  url: string;
28
- authToken: string;
28
+ authToken?: string;
29
29
  fetchFn: FetchFn;
30
30
  }): Promise<Response> {
31
31
  const { url, authToken, fetchFn } = params;
32
- return fetchFn(url, {
33
- headers: {
34
- "PRIVATE-TOKEN": authToken,
35
- },
36
- });
32
+ const headers: Record<string, string> = {};
33
+ if (authToken) {
34
+ headers["PRIVATE-TOKEN"] = authToken;
35
+ }
36
+ return fetchFn(url, { headers });
37
37
  }
38
38
 
39
39
  /**
@@ -41,7 +41,7 @@ async function gitlabFetch(params: {
41
41
  */
42
42
  async function paginateGitLab<T>(params: {
43
43
  initialUrl: string;
44
- authToken: string;
44
+ authToken?: string;
45
45
  fetchFn: FetchFn;
46
46
  }): Promise<T[]> {
47
47
  const { initialUrl, authToken, fetchFn } = params;
@@ -80,7 +80,7 @@ async function paginateGitLab<T>(params: {
80
80
  */
81
81
  async function enumerateProjects(params: {
82
82
  target: string;
83
- authToken: string;
83
+ authToken?: string;
84
84
  fetchFn: FetchFn;
85
85
  apiUrl: string;
86
86
  }): Promise<GitLabProject[]> {
@@ -96,7 +96,7 @@ async function enumerateProjects(params: {
96
96
  async function getMatchingFiles(params: {
97
97
  project: GitLabProject;
98
98
  pathPattern: string;
99
- authToken: string;
99
+ authToken?: string;
100
100
  fetchFn: FetchFn;
101
101
  apiUrl: string;
102
102
  }): Promise<string[]> {
@@ -122,7 +122,7 @@ async function fetchFileContent(params: {
122
122
  projectId: number;
123
123
  filePath: string;
124
124
  branch: string;
125
- authToken: string;
125
+ authToken?: string;
126
126
  fetchFn: FetchFn;
127
127
  apiUrl: string;
128
128
  }): Promise<string> {
@@ -32,8 +32,8 @@ export interface ScraperOptions {
32
32
  target: string;
33
33
  /** Glob pattern for matching file paths (e.g., ".checkstack/**\/*.yaml"). */
34
34
  pathPattern: string;
35
- /** Decrypted auth token for the Git provider API. */
36
- authToken: string;
35
+ /** Decrypted auth token for the Git provider API. Optional for public repos. */
36
+ authToken?: string;
37
37
  /** Custom API base URL for enterprise/on-prem installations. */
38
38
  baseUrl?: string;
39
39
  /** Logger for diagnostic output. */
@@ -19,7 +19,7 @@ interface ReconcileProviderParams {
19
19
  providerType: "github" | "gitlab";
20
20
  target: string;
21
21
  pathPattern: string;
22
- authToken: string;
22
+ authToken?: string;
23
23
  baseUrl?: string;
24
24
  deletionPolicy: "orphan" | "auto";
25
25
  db: Db;
@@ -1,4 +1,5 @@
1
1
  import type { Logger, SafeDatabase } from "@checkstack/backend-api";
2
+ import { decrypt } from "@checkstack/backend-api";
2
3
  import type { QueueManager } from "@checkstack/queue-api";
3
4
  import type { InternalEntityKindRegistry } from "../kind-registry";
4
5
  import type { SecretStore } from "../secret-resolver";
@@ -104,12 +105,25 @@ async function runSyncForProvider(params: {
104
105
  const scraper =
105
106
  provider.type === "github" ? githubScraper : gitlabScraper;
106
107
 
108
+ // Decrypt authToken from database (stored encrypted via DynamicForm secret field)
109
+ let authToken: string | undefined;
110
+ if (provider.authToken) {
111
+ try {
112
+ authToken = decrypt(provider.authToken);
113
+ } catch (error) {
114
+ logger.error(
115
+ `GitOps sync: failed to decrypt authToken for provider ${providerId}: ${error}`,
116
+ );
117
+ throw new Error(`Failed to decrypt auth token for provider ${providerId}`);
118
+ }
119
+ }
120
+
107
121
  return reconcileProvider({
108
122
  providerId: provider.id,
109
123
  providerType: provider.type,
110
124
  target: provider.target,
111
125
  pathPattern: provider.pathPattern,
112
- authToken: provider.authToken ?? "",
126
+ authToken,
113
127
  baseUrl: provider.baseUrl ?? undefined,
114
128
  deletionPolicy: provider.deletionPolicy,
115
129
  db,
@@ -151,8 +165,18 @@ export async function triggerSyncForProvider(params: {
151
165
  const { queueManager, providerId } = params;
152
166
  const queue = queueManager.getQueue<SyncJobPayload>(SYNC_QUEUE);
153
167
 
154
- await queue.enqueue(
155
- { providerId },
156
- { jobId: `gitops-sync-${providerId}-manual` },
157
- );
168
+ await queue.enqueue({ providerId });
169
+ }
170
+
171
+ /**
172
+ * Cancels the recurring sync job for a provider (used when deleting a provider).
173
+ */
174
+ export async function cancelSyncForProvider(params: {
175
+ queueManager: QueueManager;
176
+ providerId: string;
177
+ }): Promise<void> {
178
+ const { queueManager, providerId } = params;
179
+ const queue = queueManager.getQueue<SyncJobPayload>(SYNC_QUEUE);
180
+
181
+ await queue.cancelRecurring(`gitops-sync-${providerId}`);
158
182
  }