@checkstack/gitops-backend 0.1.0 → 0.1.1
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 +25 -0
- package/package.json +2 -2
- package/src/router.ts +5 -2
- package/src/scrapers/github-scraper.test.ts +51 -0
- package/src/scrapers/github-scraper.ts +12 -11
- package/src/scrapers/gitlab-scraper.test.ts +52 -0
- package/src/scrapers/gitlab-scraper.ts +10 -10
- package/src/scrapers/types.ts +2 -2
- package/src/sync/reconciler.ts +1 -1
- package/src/sync/sync-worker.ts +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @checkstack/gitops-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 86bab6a: ### GitOps: Fix authentication token handling
|
|
8
|
+
|
|
9
|
+
- Made `authToken` optional in `ReconcileProviderParams` and `ScraperOptions` to support unauthenticated access to public repositories
|
|
10
|
+
- GitHub and GitLab scrapers now conditionally set authentication headers only when a token is provided
|
|
11
|
+
- 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
|
|
12
|
+
|
|
13
|
+
### SLO: Fix premature Nines Club achievement unlock
|
|
14
|
+
|
|
15
|
+
- 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
|
|
16
|
+
|
|
17
|
+
### SLO: Align frontend achievement descriptions with backend criteria
|
|
18
|
+
|
|
19
|
+
- 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%)
|
|
20
|
+
|
|
21
|
+
### SLO: Enrich milestones with system names
|
|
22
|
+
|
|
23
|
+
- The `getRecentMilestones` endpoint now resolves human-readable system names via the Catalog API instead of returning raw system IDs
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [86bab6a]
|
|
26
|
+
- @checkstack/gitops-common@0.1.1
|
|
27
|
+
|
|
3
28
|
## 0.1.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/gitops-backend",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
|
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
|
@@ -112,8 +112,11 @@ export const createGitOpsRouter = ({
|
|
|
112
112
|
if (input.data.pathPattern !== undefined)
|
|
113
113
|
updates.pathPattern = input.data.pathPattern;
|
|
114
114
|
if (input.data.baseUrl !== undefined) updates.baseUrl = input.data.baseUrl;
|
|
115
|
-
if (input.data.authToken !== undefined)
|
|
116
|
-
|
|
115
|
+
if (input.data.authToken !== undefined) {
|
|
116
|
+
// null = explicitly clear token, string = encrypt and store
|
|
117
|
+
// eslint-disable-next-line unicorn/no-null
|
|
118
|
+
updates.authToken = input.data.authToken ? encrypt(input.data.authToken) : null;
|
|
119
|
+
}
|
|
117
120
|
if (input.data.syncInterval !== undefined)
|
|
118
121
|
updates.syncInterval = input.data.syncInterval;
|
|
119
122
|
if (input.data.deletionPolicy !== undefined)
|
|
@@ -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
|
|
46
|
+
authToken?: string;
|
|
47
47
|
fetchFn: FetchFn;
|
|
48
48
|
}): Promise<Response> {
|
|
49
49
|
const { url, authToken, fetchFn } = params;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
28
|
+
authToken?: string;
|
|
29
29
|
fetchFn: FetchFn;
|
|
30
30
|
}): Promise<Response> {
|
|
31
31
|
const { url, authToken, fetchFn } = params;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
125
|
+
authToken?: string;
|
|
126
126
|
fetchFn: FetchFn;
|
|
127
127
|
apiUrl: string;
|
|
128
128
|
}): Promise<string> {
|
package/src/scrapers/types.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/sync/reconciler.ts
CHANGED
package/src/sync/sync-worker.ts
CHANGED
|
@@ -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
|
|
126
|
+
authToken,
|
|
113
127
|
baseUrl: provider.baseUrl ?? undefined,
|
|
114
128
|
deletionPolicy: provider.deletionPolicy,
|
|
115
129
|
db,
|