@intentius/chant-lexicon-gitlab 0.0.15 → 0.0.18
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/dist/integrity.json +18 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl016.ts +82 -0
- package/dist/rules/wgl017.ts +54 -0
- package/dist/rules/wgl018.ts +39 -0
- package/dist/rules/wgl019.ts +44 -0
- package/dist/rules/wgl020.ts +56 -0
- package/dist/rules/wgl021.ts +62 -0
- package/dist/rules/wgl022.ts +44 -0
- package/dist/rules/wgl023.ts +51 -0
- package/dist/rules/wgl024.ts +46 -0
- package/dist/rules/wgl025.ts +49 -0
- package/dist/rules/wgl026.ts +67 -0
- package/dist/rules/wgl027.ts +54 -0
- package/dist/rules/wgl028.ts +67 -0
- package/dist/rules/yaml-helpers.ts +82 -0
- package/dist/skills/chant-gitlab.md +1 -1
- package/dist/skills/gitlab-ci-patterns.md +309 -0
- package/package.json +3 -3
- package/src/codegen/fetch.test.ts +30 -0
- package/src/codegen/generate.test.ts +65 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.test.ts +93 -0
- package/src/codegen/snapshot.test.ts +28 -19
- package/src/composites/composites.test.ts +160 -0
- package/src/coverage.test.ts +15 -7
- package/src/import/roundtrip.test.ts +132 -0
- package/src/lint/post-synth/wgl016.test.ts +72 -0
- package/src/lint/post-synth/wgl016.ts +82 -0
- package/src/lint/post-synth/wgl017.test.ts +53 -0
- package/src/lint/post-synth/wgl017.ts +54 -0
- package/src/lint/post-synth/wgl018.test.ts +69 -0
- package/src/lint/post-synth/wgl018.ts +39 -0
- package/src/lint/post-synth/wgl019.test.ts +76 -0
- package/src/lint/post-synth/wgl019.ts +44 -0
- package/src/lint/post-synth/wgl020.test.ts +54 -0
- package/src/lint/post-synth/wgl020.ts +56 -0
- package/src/lint/post-synth/wgl021.test.ts +62 -0
- package/src/lint/post-synth/wgl021.ts +62 -0
- package/src/lint/post-synth/wgl022.test.ts +86 -0
- package/src/lint/post-synth/wgl022.ts +44 -0
- package/src/lint/post-synth/wgl023.test.ts +88 -0
- package/src/lint/post-synth/wgl023.ts +51 -0
- package/src/lint/post-synth/wgl024.test.ts +77 -0
- package/src/lint/post-synth/wgl024.ts +46 -0
- package/src/lint/post-synth/wgl025.test.ts +85 -0
- package/src/lint/post-synth/wgl025.ts +49 -0
- package/src/lint/post-synth/wgl026.test.ts +87 -0
- package/src/lint/post-synth/wgl026.ts +67 -0
- package/src/lint/post-synth/wgl027.test.ts +84 -0
- package/src/lint/post-synth/wgl027.ts +54 -0
- package/src/lint/post-synth/wgl028.test.ts +95 -0
- package/src/lint/post-synth/wgl028.ts +67 -0
- package/src/lint/post-synth/yaml-helpers.ts +82 -0
- package/src/lsp/completions.test.ts +16 -6
- package/src/lsp/hover.test.ts +18 -7
- package/src/plugin.test.ts +15 -2
- package/src/plugin.ts +66 -3
- package/src/skills/gitlab-ci-patterns.md +309 -0
- package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
- package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
- package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
- package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
- package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
- package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
- package/src/validate.test.ts +12 -6
- package/src/variables.test.ts +58 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { wgl022 } from "./wgl022";
|
|
5
|
+
|
|
6
|
+
class MockJob implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType = "GitLab::CI::Job";
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
19
|
+
return {
|
|
20
|
+
outputs: new Map(),
|
|
21
|
+
entities,
|
|
22
|
+
buildResult: {
|
|
23
|
+
outputs: new Map(),
|
|
24
|
+
entities,
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
sourceFileCount: 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WGL022: Missing Artifacts Expiry", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl022.id).toBe("WGL022");
|
|
35
|
+
expect(wgl022.description).toContain("artifacts");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags artifacts without expire_in", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["buildJob", new MockJob({
|
|
41
|
+
script: ["npm build"],
|
|
42
|
+
artifacts: { paths: ["dist/"] },
|
|
43
|
+
})],
|
|
44
|
+
]);
|
|
45
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
46
|
+
expect(diags).toHaveLength(1);
|
|
47
|
+
expect(diags[0].severity).toBe("warning");
|
|
48
|
+
expect(diags[0].message).toContain("buildJob");
|
|
49
|
+
expect(diags[0].message).toContain("expire_in");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag artifacts with expire_in", () => {
|
|
53
|
+
const entities = new Map<string, Declarable>([
|
|
54
|
+
["buildJob", new MockJob({
|
|
55
|
+
script: ["npm build"],
|
|
56
|
+
artifacts: { paths: ["dist/"], expire_in: "1 week" },
|
|
57
|
+
})],
|
|
58
|
+
]);
|
|
59
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
60
|
+
expect(diags).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("does not flag job without artifacts", () => {
|
|
64
|
+
const entities = new Map<string, Declarable>([
|
|
65
|
+
["testJob", new MockJob({ script: ["npm test"] })],
|
|
66
|
+
]);
|
|
67
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
68
|
+
expect(diags).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("handles artifacts as declarable with props", () => {
|
|
72
|
+
const entities = new Map<string, Declarable>([
|
|
73
|
+
["buildJob", new MockJob({
|
|
74
|
+
script: ["npm build"],
|
|
75
|
+
artifacts: { props: { paths: ["dist/"], expire_in: "30 days" } },
|
|
76
|
+
})],
|
|
77
|
+
]);
|
|
78
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
79
|
+
expect(diags).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("no diagnostics on empty entities", () => {
|
|
83
|
+
const diags = wgl022.check(makeCtx(new Map()));
|
|
84
|
+
expect(diags).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL022: Missing Artifacts Expiry
|
|
3
|
+
*
|
|
4
|
+
* Warns about `artifacts:` without `expire_in:`, which causes disk bloat
|
|
5
|
+
* on the GitLab instance. Default retention is "never expire" in some
|
|
6
|
+
* GitLab configurations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
|
|
12
|
+
export const wgl022: PostSynthCheck = {
|
|
13
|
+
id: "WGL022",
|
|
14
|
+
description: "Missing artifacts expiry — artifacts without expire_in cause disk bloat",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
20
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
21
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
22
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
23
|
+
|
|
24
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
25
|
+
if (!props?.artifacts) continue;
|
|
26
|
+
|
|
27
|
+
const artifacts = props.artifacts as Record<string, unknown>;
|
|
28
|
+
// artifacts might be a Declarable with its own props
|
|
29
|
+
const artProps = (artifacts.props as Record<string, unknown> | undefined) ?? artifacts;
|
|
30
|
+
|
|
31
|
+
if (!artProps.expire_in && !artProps.expireIn) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "WGL022",
|
|
34
|
+
severity: "warning",
|
|
35
|
+
message: `Job "${entityName}" has artifacts without expire_in — set an expiry to avoid disk bloat`,
|
|
36
|
+
entity: entityName,
|
|
37
|
+
lexicon: "gitlab",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { wgl023 } from "./wgl023";
|
|
5
|
+
|
|
6
|
+
class MockJob implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType = "GitLab::CI::Job";
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
19
|
+
return {
|
|
20
|
+
outputs: new Map(),
|
|
21
|
+
entities,
|
|
22
|
+
buildResult: {
|
|
23
|
+
outputs: new Map(),
|
|
24
|
+
entities,
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
sourceFileCount: 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WGL023: Overly Broad Rules", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl023.id).toBe("WGL023");
|
|
35
|
+
expect(wgl023.description).toContain("Overly broad");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags single rule with only when: always", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["alwaysJob", new MockJob({
|
|
41
|
+
script: ["test"],
|
|
42
|
+
rules: [{ when: "always" }],
|
|
43
|
+
})],
|
|
44
|
+
]);
|
|
45
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
46
|
+
expect(diags).toHaveLength(1);
|
|
47
|
+
expect(diags[0].severity).toBe("info");
|
|
48
|
+
expect(diags[0].message).toContain("alwaysJob");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not flag rule with when: always and if condition", () => {
|
|
52
|
+
const entities = new Map<string, Declarable>([
|
|
53
|
+
["conditionalJob", new MockJob({
|
|
54
|
+
script: ["test"],
|
|
55
|
+
rules: [{ if: "$CI_COMMIT_BRANCH == 'main'", when: "always" }],
|
|
56
|
+
})],
|
|
57
|
+
]);
|
|
58
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
59
|
+
expect(diags).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not flag multiple rules", () => {
|
|
63
|
+
const entities = new Map<string, Declarable>([
|
|
64
|
+
["multiRuleJob", new MockJob({
|
|
65
|
+
script: ["test"],
|
|
66
|
+
rules: [
|
|
67
|
+
{ when: "always" },
|
|
68
|
+
{ when: "never" },
|
|
69
|
+
],
|
|
70
|
+
})],
|
|
71
|
+
]);
|
|
72
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
73
|
+
expect(diags).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("does not flag job without rules", () => {
|
|
77
|
+
const entities = new Map<string, Declarable>([
|
|
78
|
+
["simpleJob", new MockJob({ script: ["test"] })],
|
|
79
|
+
]);
|
|
80
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
81
|
+
expect(diags).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("no diagnostics on empty entities", () => {
|
|
85
|
+
const diags = wgl023.check(makeCtx(new Map()));
|
|
86
|
+
expect(diags).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL023: Overly Broad Rules
|
|
3
|
+
*
|
|
4
|
+
* Flags jobs with a single rule that has only `when: always` and no
|
|
5
|
+
* conditions (no `if:`, `changes:`, etc.). This effectively disables
|
|
6
|
+
* all pipeline filtering for the job, which is usually unintended.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
|
|
12
|
+
export const wgl023: PostSynthCheck = {
|
|
13
|
+
id: "WGL023",
|
|
14
|
+
description: "Overly broad rules — job with only when: always rule (no conditions)",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
20
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
21
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
22
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
23
|
+
|
|
24
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
25
|
+
if (!props?.rules || !Array.isArray(props.rules)) continue;
|
|
26
|
+
|
|
27
|
+
const rules = props.rules as Array<Record<string, unknown>>;
|
|
28
|
+
if (rules.length !== 1) continue;
|
|
29
|
+
|
|
30
|
+
const rule = rules[0];
|
|
31
|
+
const ruleProps = (rule.props as Record<string, unknown> | undefined) ?? rule;
|
|
32
|
+
|
|
33
|
+
const when = ruleProps.when;
|
|
34
|
+
const hasIf = !!ruleProps.if;
|
|
35
|
+
const hasChanges = !!ruleProps.changes;
|
|
36
|
+
const hasExists = !!ruleProps.exists;
|
|
37
|
+
|
|
38
|
+
if (when === "always" && !hasIf && !hasChanges && !hasExists) {
|
|
39
|
+
diagnostics.push({
|
|
40
|
+
checkId: "WGL023",
|
|
41
|
+
severity: "info",
|
|
42
|
+
message: `Job "${entityName}" has a single rule with only "when: always" — this disables all pipeline filtering. Consider adding conditions or removing rules entirely.`,
|
|
43
|
+
entity: entityName,
|
|
44
|
+
lexicon: "gitlab",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return diagnostics;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { wgl024 } from "./wgl024";
|
|
5
|
+
|
|
6
|
+
class MockJob implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType = "GitLab::CI::Job";
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
19
|
+
return {
|
|
20
|
+
outputs: new Map(),
|
|
21
|
+
entities,
|
|
22
|
+
buildResult: {
|
|
23
|
+
outputs: new Map(),
|
|
24
|
+
entities,
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
sourceFileCount: 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WGL024: Manual Without allow_failure", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl024.id).toBe("WGL024");
|
|
35
|
+
expect(wgl024.description).toContain("Manual");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags manual job without allow_failure", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["manualDeploy", new MockJob({ script: ["deploy.sh"], when: "manual" })],
|
|
41
|
+
]);
|
|
42
|
+
const diags = wgl024.check(makeCtx(entities));
|
|
43
|
+
expect(diags).toHaveLength(1);
|
|
44
|
+
expect(diags[0].severity).toBe("warning");
|
|
45
|
+
expect(diags[0].message).toContain("manualDeploy");
|
|
46
|
+
expect(diags[0].message).toContain("block");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not flag manual job with allow_failure: true", () => {
|
|
50
|
+
const entities = new Map<string, Declarable>([
|
|
51
|
+
["manualDeploy", new MockJob({ script: ["deploy.sh"], when: "manual", allow_failure: true })],
|
|
52
|
+
]);
|
|
53
|
+
const diags = wgl024.check(makeCtx(entities));
|
|
54
|
+
expect(diags).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("does not flag non-manual job", () => {
|
|
58
|
+
const entities = new Map<string, Declarable>([
|
|
59
|
+
["autoJob", new MockJob({ script: ["test"] })],
|
|
60
|
+
]);
|
|
61
|
+
const diags = wgl024.check(makeCtx(entities));
|
|
62
|
+
expect(diags).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("flags manual job with allow_failure: false", () => {
|
|
66
|
+
const entities = new Map<string, Declarable>([
|
|
67
|
+
["manualJob", new MockJob({ script: ["test"], when: "manual", allow_failure: false })],
|
|
68
|
+
]);
|
|
69
|
+
const diags = wgl024.check(makeCtx(entities));
|
|
70
|
+
expect(diags).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("no diagnostics on empty entities", () => {
|
|
74
|
+
const diags = wgl024.check(makeCtx(new Map()));
|
|
75
|
+
expect(diags).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL024: Manual Without allow_failure
|
|
3
|
+
*
|
|
4
|
+
* Warns about jobs with `when: manual` that don't set `allow_failure: true`.
|
|
5
|
+
* Without it, the manual job blocks the pipeline from progressing past
|
|
6
|
+
* its stage until someone manually triggers it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
|
|
12
|
+
export const wgl024: PostSynthCheck = {
|
|
13
|
+
id: "WGL024",
|
|
14
|
+
description: "Manual without allow_failure — manual jobs block pipeline without allow_failure: true",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
20
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
21
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
22
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
23
|
+
|
|
24
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
25
|
+
if (!props) continue;
|
|
26
|
+
|
|
27
|
+
// Check top-level when: manual
|
|
28
|
+
const isManual = props.when === "manual";
|
|
29
|
+
if (!isManual) continue;
|
|
30
|
+
|
|
31
|
+
// Check allow_failure
|
|
32
|
+
const allowFailure = props.allow_failure ?? props.allowFailure;
|
|
33
|
+
if (allowFailure !== true) {
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
checkId: "WGL024",
|
|
36
|
+
severity: "warning",
|
|
37
|
+
message: `Job "${entityName}" has when: manual but no allow_failure: true — this will block the pipeline`,
|
|
38
|
+
entity: entityName,
|
|
39
|
+
lexicon: "gitlab",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return diagnostics;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { wgl025 } from "./wgl025";
|
|
5
|
+
|
|
6
|
+
class MockJob implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType = "GitLab::CI::Job";
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
19
|
+
return {
|
|
20
|
+
outputs: new Map(),
|
|
21
|
+
entities,
|
|
22
|
+
buildResult: {
|
|
23
|
+
outputs: new Map(),
|
|
24
|
+
entities,
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
sourceFileCount: 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WGL025: Missing Cache Key", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl025.id).toBe("WGL025");
|
|
35
|
+
expect(wgl025.description).toContain("cache");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags cache without key", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["buildJob", new MockJob({
|
|
41
|
+
script: ["npm build"],
|
|
42
|
+
cache: { paths: ["node_modules/"] },
|
|
43
|
+
})],
|
|
44
|
+
]);
|
|
45
|
+
const diags = wgl025.check(makeCtx(entities));
|
|
46
|
+
expect(diags).toHaveLength(1);
|
|
47
|
+
expect(diags[0].severity).toBe("warning");
|
|
48
|
+
expect(diags[0].message).toContain("buildJob");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not flag cache with key", () => {
|
|
52
|
+
const entities = new Map<string, Declarable>([
|
|
53
|
+
["buildJob", new MockJob({
|
|
54
|
+
script: ["npm build"],
|
|
55
|
+
cache: { key: "$CI_COMMIT_REF_SLUG", paths: ["node_modules/"] },
|
|
56
|
+
})],
|
|
57
|
+
]);
|
|
58
|
+
const diags = wgl025.check(makeCtx(entities));
|
|
59
|
+
expect(diags).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not flag job without cache", () => {
|
|
63
|
+
const entities = new Map<string, Declarable>([
|
|
64
|
+
["testJob", new MockJob({ script: ["npm test"] })],
|
|
65
|
+
]);
|
|
66
|
+
const diags = wgl025.check(makeCtx(entities));
|
|
67
|
+
expect(diags).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("handles cache as declarable with props", () => {
|
|
71
|
+
const entities = new Map<string, Declarable>([
|
|
72
|
+
["buildJob", new MockJob({
|
|
73
|
+
script: ["npm build"],
|
|
74
|
+
cache: { props: { key: "my-cache", paths: ["node_modules/"] } },
|
|
75
|
+
})],
|
|
76
|
+
]);
|
|
77
|
+
const diags = wgl025.check(makeCtx(entities));
|
|
78
|
+
expect(diags).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("no diagnostics on empty entities", () => {
|
|
82
|
+
const diags = wgl025.check(makeCtx(new Map()));
|
|
83
|
+
expect(diags).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL025: Missing Cache Key
|
|
3
|
+
*
|
|
4
|
+
* Warns about `cache:` without `key:`. Without an explicit key, GitLab
|
|
5
|
+
* uses `default` as the key, which causes cache collisions between
|
|
6
|
+
* unrelated jobs sharing the same runner.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
|
|
12
|
+
export const wgl025: PostSynthCheck = {
|
|
13
|
+
id: "WGL025",
|
|
14
|
+
description: "Missing cache key — cache without key causes collisions",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
20
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
21
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
22
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
23
|
+
|
|
24
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
25
|
+
if (!props?.cache) continue;
|
|
26
|
+
|
|
27
|
+
// Cache can be a single object or an array
|
|
28
|
+
const caches = Array.isArray(props.cache) ? props.cache : [props.cache];
|
|
29
|
+
|
|
30
|
+
for (const cache of caches) {
|
|
31
|
+
const cacheObj = cache as Record<string, unknown>;
|
|
32
|
+
const cacheProps = (cacheObj.props as Record<string, unknown> | undefined) ?? cacheObj;
|
|
33
|
+
|
|
34
|
+
if (!cacheProps.key) {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "WGL025",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `Job "${entityName}" has cache without a key — this causes cache collisions between jobs`,
|
|
39
|
+
entity: entityName,
|
|
40
|
+
lexicon: "gitlab",
|
|
41
|
+
});
|
|
42
|
+
break; // One diagnostic per job is enough
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return diagnostics;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { wgl026 } from "./wgl026";
|
|
5
|
+
|
|
6
|
+
class MockJob implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType = "GitLab::CI::Job";
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
19
|
+
return {
|
|
20
|
+
outputs: new Map(),
|
|
21
|
+
entities,
|
|
22
|
+
buildResult: {
|
|
23
|
+
outputs: new Map(),
|
|
24
|
+
entities,
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
sourceFileCount: 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WGL026: Privileged Services Without TLS", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl026.id).toBe("WGL026");
|
|
35
|
+
expect(wgl026.description).toContain("TLS");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags DinD service without TLS cert dir", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["buildImage", new MockJob({
|
|
41
|
+
script: ["docker build ."],
|
|
42
|
+
services: [{ name: "docker:dind" }],
|
|
43
|
+
})],
|
|
44
|
+
]);
|
|
45
|
+
const diags = wgl026.check(makeCtx(entities));
|
|
46
|
+
expect(diags).toHaveLength(1);
|
|
47
|
+
expect(diags[0].severity).toBe("warning");
|
|
48
|
+
expect(diags[0].message).toContain("buildImage");
|
|
49
|
+
expect(diags[0].message).toContain("TLS");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag DinD service with TLS cert dir in job variables", () => {
|
|
53
|
+
const entities = new Map<string, Declarable>([
|
|
54
|
+
["buildImage", new MockJob({
|
|
55
|
+
script: ["docker build ."],
|
|
56
|
+
services: [{ name: "docker:dind" }],
|
|
57
|
+
variables: { DOCKER_TLS_CERTDIR: "/certs" },
|
|
58
|
+
})],
|
|
59
|
+
]);
|
|
60
|
+
const diags = wgl026.check(makeCtx(entities));
|
|
61
|
+
expect(diags).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not flag non-DinD service", () => {
|
|
65
|
+
const entities = new Map<string, Declarable>([
|
|
66
|
+
["testJob", new MockJob({
|
|
67
|
+
script: ["npm test"],
|
|
68
|
+
services: [{ name: "postgres:15" }],
|
|
69
|
+
})],
|
|
70
|
+
]);
|
|
71
|
+
const diags = wgl026.check(makeCtx(entities));
|
|
72
|
+
expect(diags).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("does not flag job without services", () => {
|
|
76
|
+
const entities = new Map<string, Declarable>([
|
|
77
|
+
["simpleJob", new MockJob({ script: ["test"] })],
|
|
78
|
+
]);
|
|
79
|
+
const diags = wgl026.check(makeCtx(entities));
|
|
80
|
+
expect(diags).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("no diagnostics on empty entities", () => {
|
|
84
|
+
const diags = wgl026.check(makeCtx(new Map()));
|
|
85
|
+
expect(diags).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL026: Privileged Services Without TLS
|
|
3
|
+
*
|
|
4
|
+
* Warns about Docker-in-Docker (DinD) services that don't set
|
|
5
|
+
* DOCKER_TLS_CERTDIR. Running DinD without TLS exposes the Docker
|
|
6
|
+
* daemon on an unencrypted socket.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
11
|
+
|
|
12
|
+
const DIND_IMAGES = ["docker:dind", "docker:stable-dind"];
|
|
13
|
+
|
|
14
|
+
function isDindImage(image: string): boolean {
|
|
15
|
+
return DIND_IMAGES.some((dind) => image.includes(dind));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const wgl026: PostSynthCheck = {
|
|
19
|
+
id: "WGL026",
|
|
20
|
+
description: "Privileged services without TLS — DinD services without DOCKER_TLS_CERTDIR",
|
|
21
|
+
|
|
22
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
23
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
24
|
+
|
|
25
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
26
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
27
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
28
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
29
|
+
|
|
30
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
31
|
+
if (!props?.services || !Array.isArray(props.services)) continue;
|
|
32
|
+
|
|
33
|
+
for (const service of props.services) {
|
|
34
|
+
let imageName: string | undefined;
|
|
35
|
+
let serviceVars: Record<string, unknown> | undefined;
|
|
36
|
+
|
|
37
|
+
if (typeof service === "string") {
|
|
38
|
+
imageName = service;
|
|
39
|
+
} else if (typeof service === "object" && service !== null) {
|
|
40
|
+
const svc = service as Record<string, unknown>;
|
|
41
|
+
const svcProps = (svc.props as Record<string, unknown> | undefined) ?? svc;
|
|
42
|
+
imageName = svcProps.name as string | undefined;
|
|
43
|
+
serviceVars = svcProps.variables as Record<string, unknown> | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!imageName || !isDindImage(imageName)) continue;
|
|
47
|
+
|
|
48
|
+
// Check if DOCKER_TLS_CERTDIR is set in service variables or job variables
|
|
49
|
+
const jobVars = props.variables as Record<string, unknown> | undefined;
|
|
50
|
+
const hasTLS = serviceVars?.DOCKER_TLS_CERTDIR !== undefined ||
|
|
51
|
+
jobVars?.DOCKER_TLS_CERTDIR !== undefined;
|
|
52
|
+
|
|
53
|
+
if (!hasTLS) {
|
|
54
|
+
diagnostics.push({
|
|
55
|
+
checkId: "WGL026",
|
|
56
|
+
severity: "warning",
|
|
57
|
+
message: `Job "${entityName}" uses DinD service without DOCKER_TLS_CERTDIR — the Docker daemon will be unencrypted`,
|
|
58
|
+
entity: entityName,
|
|
59
|
+
lexicon: "gitlab",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return diagnostics;
|
|
66
|
+
},
|
|
67
|
+
};
|