@catladder/pipeline
Version:
Panter workflow for cloud CI/CD and DevOps
462 lines (427 loc) • 12.8 kB
text/typescript
import { isEmpty, isObject, merge } from "lodash";
import { getInjectVarsScript } from "../../bash/getInjectVarsScript";
import { BASE_RETRY } from "../../defaults";
import type {
AgentContext,
ComponentContext,
Context,
GitlabJobDef,
GitlabRule,
WorkspaceContext,
} from "../../types";
import type { CatladderJob, CatladderJobNeed } from "../../types/jobs";
import { notNil } from "../../utils";
import { collapseableSection } from "../../utils/gitlab";
import { removeUndefined } from "../../utils/removeUndefined";
import type { AllCatladderJobs } from "../createAllJobs";
import { getBashVariable } from "../../bash/BashExpression";
export type GitlabJobWithContext = {
gitlabJob: GitlabJobDef;
context: Context | AgentContext | null;
};
export type AllGitlabJobs = (GitlabJobWithContext & { name: string })[];
export const GITLAB_ENVIRONMENT_URL_VARIABLE = "CL_GITLAB_ENVIRONMENT_URL";
const getFullJobName = ({
type,
name,
baseName,
allJobs,
env,
}: {
type: "component" | "workspace" | "agent";
name: string;
baseName: string;
allJobs: AllCatladderJobs;
env?: string | null;
}) => {
const shouldAddIcon = allJobs.workspaces.length > 0;
const icon = type === "component" ? "🔹" : type === "agent" ? "🤖" : "🔸";
const prefix = shouldAddIcon ? icon + " " : "";
if (env) {
return `${prefix}${baseName} ${name} | ${env} `;
}
return `${prefix}${baseName} ${name}`;
};
const getFullReferencedJobNameFromComponent = (
referencedJobName: string,
componentName: string,
env: string,
allJobs: AllCatladderJobs,
) => {
const referencedJob = allJobs.components
.find((j) => j.context.name === componentName && j.context.env === env)
?.jobs?.find((j) => j.name === referencedJobName);
if (!referencedJob) {
throw new Error(
`unknown job referenced: '${referencedJobName}' from '${env}:${componentName}'`,
);
}
const envToSet = referencedJob.envMode !== "none" ? env : null;
return getFullJobName({
type: "component",
name: referencedJobName,
baseName: componentName,
env: envToSet,
allJobs,
});
};
const getFullReferencedJobNameFromWorkspace = (
referencedJobName: string,
workspaceName: string,
env: string,
allJobs: AllCatladderJobs,
) => {
const referencedJob = allJobs.workspaces
.find((w) => w.context.name === workspaceName)
?.jobs?.find((j) => j.name === referencedJobName);
if (!referencedJob) {
throw new Error(
`unknown job referenced: '${referencedJobName}' from workspace ${env}:${workspaceName}'`,
);
}
const envToSet = referencedJob.envMode !== "none" ? env : null;
return getFullJobName({
type: "workspace",
name: referencedJobName,
baseName: workspaceName,
env: envToSet,
allJobs,
});
};
const getJobName = (need: CatladderJobNeed) =>
isObject(need) ? need.job : need;
export const makeGitlabJob = (
context: Context | AgentContext,
job: CatladderJob<string>,
allJobs: AllCatladderJobs,
baseRules?: GitlabRule[],
): [fullName: string, job: GitlabJobDef] => {
const {
environment,
envMode,
needsStages,
name,
needs,
jobTags,
script,
variables,
runnerVariables,
when,
...rest
} = job;
const stage =
envMode === "stagePerEnv" && context.type !== "agent"
? `${job.stage} ${context.env}`
: job.stage;
const deduplicatedGitlabNeeds: GitlabJobDef["needs"] = getGitlabNeeds(
context,
job,
allJobs,
);
const fullJobName = getFullJobName({
type: context.type,
name,
baseName: context.name,
env:
envMode !== "none" && context.type !== "agent" ? context.env : undefined,
allJobs,
});
// backwards compatibility, some may still use KUBERNETES_CPU_REQUEST, KUBERNETES_MEMORY_REQUEST, etc. in variables.
// those should now be set in the runnerVariables as they don't work in the variables key of the catladder job, becuase those get injected
const PIPELINE_RUNNER_VARIABLES = [
"KUBERNETES_CPU_REQUEST",
"KUBERNETES_MEMORY_REQUEST",
"KUBERNETES_CPU_LIMIT",
"KUBERNETES_MEMORY_LIMIT",
];
// remove those from variables and add them to runnerVariables
const varsInjectScripts = collapseableSection(
"injectvars",
"Injecting variables",
)([
...getInjectVarsScript(
// remove legacy variables
Object.fromEntries(
Object.entries(variables ?? {}).filter(
([key]) => !PIPELINE_RUNNER_VARIABLES.includes(key),
),
),
),
]);
const legacyRunnerVariables = Object.fromEntries(
Object.entries(variables ?? {}).filter(([key]) =>
PIPELINE_RUNNER_VARIABLES.includes(key),
),
);
if (Object.keys(legacyRunnerVariables).length > 0) {
console.warn(
`Legacy variables detected in ${fullJobName}: ${Object.keys(
legacyRunnerVariables,
).join(", ")}. Please move them to the runnerVariables key.`,
);
}
const rules = [
...(job.rules ?? []),
...(baseRules
? baseRules.map((rule) => ({
when: when,
...rule,
}))
: when
? [{ when }]
: []),
];
const gitlabJob: GitlabJobDef = {
retry: BASE_RETRY,
interruptible: true,
...rest,
rules: rules.length > 0 ? rules : undefined,
variables: {
...legacyRunnerVariables,
...runnerVariables,
},
script: [...varsInjectScripts, ...(script?.filter(notNil) ?? [])],
tags: jobTags,
stage,
// sort in a predictable manner for snapshot tests
needs: deduplicatedGitlabNeeds,
};
const modified = addGitlabEnvironment(
context,
environment,
gitlabJob,
allJobs,
);
return [fullJobName, removeUndefined(modified)];
};
const addGitlabEnvironment = (
context: Context | AgentContext,
catladderJobEnvironment: CatladderJob["environment"],
job: GitlabJobDef,
allJobs: AllCatladderJobs,
): GitlabJobDef => {
if (!catladderJobEnvironment) {
return job;
}
if (context.type !== "component") {
// don't add enviornment for workspace and agent jobs atm.
return job;
}
const { env, name, environment } = context;
const { envVars, envType } = environment;
const { on_stop, ...restEnvironment } = catladderJobEnvironment;
// those can be dynamic, so we therefore have to do this: https://docs.gitlab.com/ee/ci/environments/#set-a-dynamic-environment-url
const dotEnvFile = "gitlab_environment.env";
const createsJobEnv =
!catladderJobEnvironment.action ||
catladderJobEnvironment.action === "start";
const artifacts = merge(
job.artifacts ?? {},
createsJobEnv
? {
reports: {
dotenv: dotEnvFile,
},
}
: {},
);
const scriptToAdd = [
`echo "${GITLAB_ENVIRONMENT_URL_VARIABLE}=${getBashVariable("ROOT_URL")}" >> ${dotEnvFile}`,
];
// this is NOT a bashVariable since it NEEDS to be used as a string in gitlab
const gitlabEnvironmentName =
envType === "review"
? `${env}/$CI_COMMIT_REF_NAME/${name}` // FIXME: should be replaced with mr name as well
: `${env}/${name}`;
return {
...job,
environment: {
name: gitlabEnvironmentName,
...(createsJobEnv ? { url: `$${GITLAB_ENVIRONMENT_URL_VARIABLE}` } : {}),
...(on_stop
? {
on_stop: getFullReferencedJobNameFromComponent(
on_stop,
name,
env,
allJobs,
),
}
: {}),
...restEnvironment,
},
...(!isEmpty(artifacts) ? { artifacts } : {}),
script: [...(job.script ?? []), ...(createsJobEnv ? scriptToAdd : [])],
};
};
export const createGitlabJobs = async (
allJobs: AllCatladderJobs,
baseRules?: GitlabRule[],
): Promise<AllGitlabJobs> => {
// TODO: add workspace jobs
return [
...allJobs.workspaces,
...allJobs.components,
...allJobs.agents,
].flatMap(({ context, jobs }) => {
return jobs.map((job) => {
const [fullJobName, gitlabJob] = makeGitlabJob(
context,
job,
allJobs,
baseRules,
);
return {
name: fullJobName,
gitlabJob,
context,
};
});
});
};
function getGitlabNeeds(
context: Context | AgentContext,
job: CatladderJob<string>,
allJobs: AllCatladderJobs,
): GitlabJobDef["needs"] {
const needs =
context.type === "workspace"
? getGitlabNeedsForWorkspaceJob(context, job, allJobs)
: context.type === "agent"
? (job.needs ?? null)
: getGitlabNeedsForComponentJob(context, job, allJobs);
return needs ? deduplicateNeeds(needs) : undefined;
}
function deduplicateNeeds(needs: GitlabJobDef["needs"]): GitlabJobDef["needs"] {
return needs
? [...new Map(needs.map((n) => [isObject(n) ? n.job : n, n])).values()]
: undefined;
}
function getGitlabNeedsForComponentJob(
context: ComponentContext,
{ needsStages, needs }: CatladderJob<string>,
allJobs: AllCatladderJobs,
): GitlabJobDef["needs"] {
const needsFromStages = needsStages?.flatMap<CatladderJobNeed>((n) => {
const componentName = context.name;
if (!n.workspaceName) {
const allJobNamesFromThatStage =
allJobs.components
.filter(
(j) =>
j.context.name === componentName && j.context.env === context.env,
)
.flatMap((j) => j.jobs)
?.filter((j) => j.stage === n.stage)
?.map((j) => j.name) ?? [];
return allJobNamesFromThatStage.map((job) => ({
job,
artifacts: n.artifacts ?? false,
componentName,
}));
} else {
const allJobNamesFromThatStage =
allJobs.workspaces
.find(
(w) =>
w.context.name === n.workspaceName &&
w.context.env === context.env,
)
?.jobs?.flatMap((j) => j)
?.filter((j) => j.stage === n.stage)
?.map((j) => j.name) ?? [];
return allJobNamesFromThatStage.map((job) => ({
job,
artifacts: n.artifacts ?? false,
workspaceName: n.workspaceName,
}));
}
});
const cleanedNeeds: CatladderJob["needs"] = [
...(needsFromStages ?? []),
...(needs ?? []),
];
return cleanedNeeds
?.map((n) =>
isObject(n)
? "workspaceName" in n
? {
job: getFullReferencedJobNameFromWorkspace(
n.job,
n.workspaceName,
context.env,
allJobs,
),
artifacts: n.artifacts,
}
: {
job: getFullReferencedJobNameFromComponent(
n.job,
n.componentName ?? context.name,
context.env,
allJobs,
),
artifacts: n.artifacts,
}
: getFullReferencedJobNameFromComponent(
n,
context.name,
context.env,
allJobs,
),
) // sort in a predictable manner for snapshot tests
.sort((a, b) => getJobName(a).localeCompare(getJobName(b)));
}
/**
*
*unclear whether we actually need this. So far jobs in a workspace don't have needs to other jobs from the same workspace
*/
function getGitlabNeedsForWorkspaceJob(
context: WorkspaceContext,
{ needsStages, needs }: CatladderJob<string>,
allJobs: AllCatladderJobs,
): GitlabJobDef["needs"] {
const needsFromStages = needsStages?.flatMap<CatladderJobNeed>((n) => {
const workspaceName = n.workspaceName ?? context.name;
const allJobNamesFromThatStage =
allJobs.workspaces
.filter(
(j) =>
j.context.name === workspaceName && j.context.env === context.env,
)
.flatMap((j) => j.jobs)
?.filter((j) => j.stage === n.stage)
?.map((j) => j.name) ?? [];
return allJobNamesFromThatStage.map((job) => ({
job,
artifacts: n.artifacts ?? false,
workspaceName: workspaceName,
}));
});
const cleanedNeeds: CatladderJob["needs"] = [
...(needsFromStages ?? []),
...(needs ?? []),
];
return cleanedNeeds
?.map((n) =>
isObject(n)
? {
job: getFullReferencedJobNameFromWorkspace(
n.job,
"workspaceName" in n && n.workspaceName
? n.workspaceName
: context.name,
context.env,
allJobs,
),
artifacts: n.artifacts,
}
: getFullReferencedJobNameFromWorkspace(
n,
context.name,
context.env,
allJobs,
),
) // sort in a predictable manner for snapshot tests
.sort((a, b) => getJobName(a).localeCompare(getJobName(b)));
}