Skip to content

Commit 846456e

Browse files
jrdfirecow
andauthored
Allow to use component.reference, component.sha, component.version and component.name (#1836)
Co-authored-by: Mads Jon Nielsen <madsjon@gmail.com>
1 parent 207c148 commit 846456e

9 files changed

Lines changed: 209 additions & 51 deletions

File tree

src/parser-includes.ts

Lines changed: 98 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,31 @@ type ParserIncludesInitOptions = {
2626
};
2727

2828
type ParsedComponent = {
29+
_cache: {
30+
version: string | null | undefined;
31+
effectiveRef: string | undefined;
32+
sha: string | undefined;
33+
};
34+
gitData: GitData;
2935
domain: string;
3036
port: string;
3137
projectPath: string;
38+
componentPath: string;
3239
name: string;
33-
ref: string;
40+
reference: string;
41+
version: string | null;
42+
effectiveRef: string;
43+
sha: string;
3444
isLocal: boolean;
3545
};
3646

47+
type GitRemoteInfoContext = {
48+
gitData: GitData;
49+
domain: string;
50+
port: string;
51+
projectPath: string;
52+
};
53+
3754
export class ParserIncludes {
3855
private static count: number = 0;
3956

@@ -100,7 +117,7 @@ export class ParserIncludes {
100117
componentParseCache.set(index, component);
101118
if (!component.isLocal)
102119
{
103-
promises.push(this.downloadIncludeComponent(opts, component.projectPath, component.ref, component.name));
120+
promises.push(this.downloadIncludeComponent(opts, component.projectPath, component.effectiveRef, component.componentPath));
104121
}
105122
}
106123

@@ -142,13 +159,13 @@ export class ParserIncludes {
142159
const component = componentParseCache.get(index);
143160
assert(component !== undefined, `Internal error, component parse cache missing entry [${index}]`);
144161
// Gitlab allows two different file paths to include a component
145-
const files = [`${component.name}.yml`, `${component.name}/template.yml`];
162+
const files = [`${component.componentPath}.yml`, `${component.componentPath}/template.yml`];
146163

147164
let file = null;
148165
for (const f of files) {
149166
let searchPath = `${cwd}/${f}`;
150167
if (!component.isLocal) {
151-
searchPath = `${cwd}/${stateDir}/includes/${gitData.remote.host}/${component.projectPath}/${component.ref}/${f}`;
168+
searchPath = `${cwd}/${stateDir}/includes/${gitData.remote.host}/${component.projectPath}/${component.effectiveRef}/${f}`;
152169
}
153170
if (fs.existsSync(searchPath)) {
154171
file = searchPath;
@@ -158,13 +175,15 @@ export class ParserIncludes {
158175
(component.port ? `:${component.port}` : "") + `/${component.projectPath}\``);
159176

160177
// Extract component name for component-specific inputs
161-
const componentName = component.name.replace(/^templates\//, "");
178+
const componentName = component.componentPath.replace(/^templates\//, "");
162179
const fileComponentInputs = isStructured ? (fileInputs[componentName] ?? {}) : {};
163180
const cliComponentSpecificInputs = cliComponentInputs[componentName] ?? {};
164181
const mergedInputs = {...(value.inputs ?? {}), ...globalInputs, ...fileComponentInputs, ...cliComponentSpecificInputs};
165-
const fileDoc = await Parser.loadYaml(file, {inputs: mergedInputs}, expandVariables, writeStreams);
166-
// Expand local includes inside to a "project"-like include
167-
fileDoc["include"] = this.expandInnerLocalIncludes(fileDoc["include"], component.projectPath, component.ref, opts);
182+
const fileDoc = await Parser.loadYaml(file, {inputs: mergedInputs, component}, expandVariables, writeStreams);
183+
if (!component.isLocal) {
184+
// Expand local includes inside to a "project"-like include
185+
fileDoc["include"] = this.expandInnerLocalIncludes(fileDoc["include"], component.projectPath, component.effectiveRef, opts);
186+
}
168187
includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
169188
} else if (value["template"]) {
170189
const {project, ref, file, domain} = this.covertTemplateToProjectFile(value["template"]);
@@ -236,42 +255,70 @@ export class ParserIncludes {
236255
assert(!component.includes("://"), `This GitLab CI configuration is invalid: component: \`${component}\` should not contain protocol`);
237256
const pattern = /(?<domain>[^/:\s]+)(:(?<port>\d+))?\/(?<projectPath>.+)\/(?<componentName>[^@]+)@(?<ref>.+)/; // https://regexr.com/7v7hm
238257
const gitRemoteMatch = pattern.exec(component);
239-
240258
if (gitRemoteMatch?.groups == null) throw new Error(`This is a bug, please create a github issue if this is something you're expecting to work. input: ${component}`);
241-
242-
const {domain, projectPath, port} = gitRemoteMatch.groups;
243-
let ref = gitRemoteMatch.groups["ref"];
259+
const {domain, projectPath, port, componentName, ref} = gitRemoteMatch.groups;
244260
const isLocalComponent = projectPath === `${gitData.remote.group}/${gitData.remote.project}` && ref === gitData.commit.SHA;
245-
246-
if (!isLocalComponent) {
247-
const semanticVersionRangesPattern = /^\d+(\.\d+)?$/;
248-
if (ref == "~latest" || semanticVersionRangesPattern.test(ref)) {
249-
// https://docs.gitlab.com/ci/components/#semantic-version-ranges
250-
let stdout;
251-
if (gitData.remote.schema == "git" || gitData.remote.schema == "ssh") {
252-
stdout = Utils.syncSpawn(["git", "ls-remote", "--tags", `git@${domain}:${projectPath}`]).stdout;
253-
} else {
254-
stdout = Utils.syncSpawn(["git", "ls-remote", "--tags", `${gitData.remote.schema}://${domain}:${port ?? 443}/${projectPath}.git`]).stdout;
255-
}
256-
assert(stdout);
257-
const tags = stdout
258-
.split("\n")
259-
.map((line) => {
260-
return line
261-
.split("\t")[1]
262-
.split("/")[2];
263-
});
264-
const _ref = resolveSemanticVersionRange(ref, tags);
265-
assert(_ref, `This GitLab CI configuration is invalid: component: \`${component}\` - The ref (${ref}) is invalid`);
266-
ref = _ref;
267-
}
268-
}
269261
return {
270-
domain: domain,
271-
port: port,
272-
projectPath: projectPath,
273-
name: `templates/${gitRemoteMatch.groups["componentName"]}`,
274-
ref: ref,
262+
_cache: {
263+
version: undefined,
264+
effectiveRef: undefined,
265+
sha: undefined,
266+
},
267+
gitData,
268+
domain,
269+
port,
270+
projectPath,
271+
componentPath: `templates/${componentName}`,
272+
name: componentName,
273+
reference: ref,
274+
get version () {
275+
if (this._cache.version === undefined) {
276+
if (this.isLocal) {
277+
this._cache.version = this.gitData.commit.SHA;
278+
} else {
279+
const semanticVersionRangesPattern = /^\d+(\.\d+)?$/;
280+
if (this.reference == "~latest" || semanticVersionRangesPattern.test(this.reference)) {
281+
// https://docs.gitlab.com/ci/components/#semantic-version-ranges
282+
const stdout = getGitRemoteInfo(this, "--tags");
283+
const tags = stdout.split("\n").map(line => line.split("\t")[1].split("/")[2]);
284+
const version = resolveSemanticVersionRange(this.reference, tags);
285+
assert(version, `This GitLab CI configuration is invalid: component: \`${this.name}\` - The reference (${this.reference}) is invalid`);
286+
this._cache.version = version;
287+
} else {
288+
this._cache.version = null;
289+
}
290+
}
291+
}
292+
return this._cache.version;
293+
},
294+
get effectiveRef () {
295+
if (this._cache.effectiveRef === undefined) {
296+
this._cache.effectiveRef = this.version ?? this.reference;
297+
}
298+
return this._cache.effectiveRef;
299+
},
300+
get sha () {
301+
if (this._cache.sha === undefined) {
302+
if (this.isLocal) {
303+
this._cache.sha = this.gitData.commit.SHA;
304+
} else if (/^[0-9a-f]{40}$/.test(this.effectiveRef)) {
305+
// effectiveRef may already be a sha, if so return it directly
306+
this._cache.sha = this.effectiveRef;
307+
} else {
308+
const stdout = getGitRemoteInfo(this);
309+
const lines = stdout.split("\n");
310+
// annotated tags: prefer the deref'd commit sha (refs/tags/x^{})
311+
const match = lines.find(line => line.endsWith(`refs/tags/${this.effectiveRef}^{}`)) ??
312+
lines.find(line =>
313+
line.endsWith(`refs/tags/${this.effectiveRef}`) ||
314+
line.endsWith(`refs/heads/${this.effectiveRef}`),
315+
);
316+
assert(match, `Could not resolve commit SHA for ${this.effectiveRef} in ${this.projectPath}`);
317+
this._cache.sha = match.split("\t")[0];
318+
}
319+
}
320+
return this._cache.sha;
321+
},
275322
isLocal: isLocalComponent,
276323
};
277324
}
@@ -458,3 +505,13 @@ export async function resolveIncludeLocal (pattern: string, cwd: string) {
458505
const re2js = RE2JS.compile(`^${pattern}`);
459506
return repoFiles.filter((f: any) => re2js.matches(f));
460507
}
508+
509+
export function getGitRemoteInfo (ctx: GitRemoteInfoContext, ...args: string[]) {
510+
const cmdArgs = ["git", "ls-remote", ...args];
511+
if (ctx.gitData.remote.schema == "git" || ctx.gitData.remote.schema == "ssh") {
512+
cmdArgs.push(`git@${ctx.domain}:${ctx.projectPath}`);
513+
} else {
514+
cmdArgs.push(`${ctx.gitData.remote.schema}://${ctx.domain}:${ctx.port ?? 443}/${ctx.projectPath}.git`);
515+
}
516+
return Utils.syncSpawn(cmdArgs).stdout;
517+
}

src/parser.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,6 @@ export class Parser {
354354
if (isGitlabSpecFile(fileData[0])) {
355355
const inputsSpecification: any = fileData[0];
356356
const uninterpolatedConfigurations: any = fileData[1];
357-
358357
const interpolatedConfigurations = JSON.stringify(uninterpolatedConfigurations)
359358
.replaceAll(
360359
/(?<firstChar>.)?(?<secondChar>.)?\$\[\[\s*inputs.(?<interpolationKey>[\w-]+)\s*\|?\s*(?<interpolationFunctions>.*?)\s*\]\](?<lastChar>[^$])?/g // https://regexr.com/81c16
@@ -398,6 +397,11 @@ export class Parser {
398397
default:
399398
Utils.switchStatementExhaustiveCheck(inputType);
400399
}
400+
})
401+
.replaceAll( // https://docs.gitlab.com/ci/components/#use-component-context-in-components
402+
/\$\[\[\s*component\.(?<interpolationKey>name|reference|version|sha)\s*\]\]/g // regexr.com/8lotc
403+
, (_: string, interpolationKey: string) => {
404+
return getComponentValue(filePath, ctx, interpolationKey) || _;
401405
});
402406
return JSON.parse(interpolatedConfigurations);
403407
}
@@ -409,6 +413,12 @@ function isGitlabSpecFile (fileData: any) {
409413
return "spec" in fileData;
410414
}
411415

416+
function getComponentValue (filePath: string, ctx: any, interpolationKey: string) {
417+
const {component} = ctx;
418+
assert(component !== undefined, chalk`This GitLab CI configuration is invalid: \`{blueBright ${filePath}}\`: \`{blueBright component.${interpolationKey}}\` cannot be used outside a component.`);
419+
return component[interpolationKey];
420+
}
421+
412422
function validateInterpolationKey (ctx: any) {
413423
const {configFilePath, interpolationKey, inputsSpecification} = ctx;
414424
const invalidInterpolationKeyErr = chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: unknown interpolation key: \`${interpolationKey}\`.`;
@@ -427,12 +437,6 @@ function validateInput (ctx: any) {
427437
const {configFilePath, interpolationKey, inputsSpecification} = ctx;
428438
const inputValue = getInputValue(ctx);
429439

430-
const options = inputsSpecification.spec.inputs[interpolationKey]?.options;
431-
if (options) {
432-
assert(options.includes(inputValue),
433-
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: \`{blueBright ${inputValue}}\` cannot be used because it is not in the list of allowed options.`);
434-
}
435-
436440
const expectedInputType = getExpectedInputType(ctx);
437441
assert(INCLUDE_INPUTS_SUPPORTED_TYPES.includes(expectedInputType),
438442
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: header:spec:inputs:{blueBright ${interpolationKey}} input type unknown value: {blueBright ${expectedInputType}}.`);
@@ -441,6 +445,19 @@ function validateInput (ctx: any) {
441445
assert(inputType === expectedInputType,
442446
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: provided value is not a {blueBright ${expectedInputType}}.`);
443447

448+
const options = inputsSpecification.spec.inputs[interpolationKey]?.options;
449+
if (options) {
450+
if (inputType == "array") {
451+
for (const itemValue of inputValue) {
452+
assert(options.includes(itemValue),
453+
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: \`{blueBright ${itemValue}}\` cannot be used because it is not in the list of allowed options.`);
454+
}
455+
} else {
456+
assert(options.includes(inputValue),
457+
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: \`{blueBright ${inputValue}}\` cannot be used because it is not in the list of allowed options.`);
458+
}
459+
}
460+
444461
const regex = inputsSpecification.spec.inputs[interpolationKey]?.regex;
445462
if (regex) {
446463
let re: RegExp;

tests/test-cases/include-component/component-local/templates/my-component.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ spec:
55
default: test
66
---
77
component-job:
8-
script: echo job 1
8+
script:
9+
- echo job 1 from $[[ component.name ]] ($[[ component.version ]]/$[[ component.reference ]]).
10+
- Sha is $[[ component.sha ]]
911
stage: $[[ inputs.stage ]]

tests/test-cases/include-component/integration.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {handler} from "../../../src/handler.js";
33
import assert, {AssertionError} from "assert";
44
import {initSpawnSpy} from "../../mocks/utils.mock.js";
55
import {WhenStatics} from "../../mocks/when-statics.js";
6+
import {Utils} from "../../../src/utils.js";
67

78
beforeAll(() => {
89
initSpawnSpy(WhenStatics.all);
@@ -175,22 +176,23 @@ test.concurrent("include-component component (protocol: https) (~latest semver)"
175176
});
176177

177178
test.concurrent("include-component local component", async () => {
179+
const sha = Utils.syncSpawn(["git", "rev-parse", "HEAD"]).stdout.trimEnd();;
178180
const writeStreams = new WriteStreamsMock();
179181

180182
await handler({
181183
cwd: "tests/test-cases/include-component/component-local",
182184
preview: true,
183185
stateDir: ".gitlab-ci-local-include-component-local-component",
184186
}, writeStreams);
185-
186187
const expected = `---
187188
stages:
188189
- .pre
189190
- my-stage
190191
- .post
191192
component-job:
192193
script:
193-
- echo job 1
194+
- echo job 1 from my-component (${sha}/${sha}).
195+
- Sha is ${sha}
194196
stage: my-stage`;
195197

196198
expect(writeStreams.stdoutLines[0]).toEqual(expected);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
spec:
3+
inputs:
4+
options_input:
5+
type: array
6+
default: []
7+
options:
8+
- foo
9+
- bar
10+
- baz
11+
---
12+
scan-website:
13+
script:
14+
- echo $[[ inputs.options_input ]]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
include:
3+
- local: '/.gitlab-ci-input-template.yml'
4+
inputs:
5+
options_input: ["foo", "baz"]
6+
stages:
7+
- test
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
spec:
3+
inputs:
4+
options_input:
5+
type: array
6+
default: []
7+
options:
8+
- foo
9+
- bar
10+
- baz
11+
---
12+
scan-website:
13+
script:
14+
- echo $[[ inputs.options_input ]]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
include:
3+
- local: '/.gitlab-ci-input-template.yml'
4+
inputs:
5+
options_input: ["bar", "fizz"]
6+
stages:
7+
- test

tests/test-cases/include-inputs/integration.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,44 @@ test.concurrent("include-inputs options validation", async () => {
321321
throw new Error("Error is expected but not thrown/caught");
322322
});
323323

324+
test.concurrent("include-inputs options array1 validation", async () => {
325+
const writeStreams = new WriteStreamsMock();
326+
await handler({
327+
cwd: "tests/test-cases/include-inputs/input-templates/options-array1-validation",
328+
preview: true,
329+
}, writeStreams);
330+
331+
const expected = `---
332+
stages:
333+
- .pre
334+
- test
335+
- .post
336+
scan-website:
337+
script:
338+
- echo ["foo","baz"]`;
339+
340+
expect(writeStreams.stdoutLines[0]).toEqual(expected);
341+
});
342+
343+
test.concurrent("include-inputs options array2 validation", async () => {
344+
try {
345+
const writeStreams = new WriteStreamsMock();
346+
await handler({
347+
cwd: "tests/test-cases/include-inputs/input-templates/options-array2-validation",
348+
preview: true,
349+
}, writeStreams);
350+
} catch (e: any) {
351+
assert(e instanceof AssertionError, "e is not instanceof AssertionError");
352+
expect(e.message).toContain("This GitLab CI configuration is invalid:");
353+
expect(e.message).toContain(
354+
chalk`\`{blueBright options_input}\` input: \`{blueBright fizz}\` cannot be used because it is not in the list of allowed options.`,
355+
);
356+
return;
357+
}
358+
359+
throw new Error("Error is expected but not thrown/caught");
360+
});
361+
324362
test.concurrent("include-inputs too many functions in interpolation block", async () => {
325363
const writeStreams = new WriteStreamsMock();
326364
try {

0 commit comments

Comments
 (0)