UNPKG

@catladder/cli

Version:

Panter cli tool for cloud CI/CD and DevOps

319 lines (284 loc) 9.04 kB
import { getSecretVarName } from "@catladder/pipeline"; import { has, isObject } from "lodash"; import memoizee from "memoizee"; import fetch from "node-fetch"; import open from "open"; import type { CommandInstance } from "vorpal"; import { getPreference, hasPreference, setPreference } from "./preferences"; import { getGitRemoteHostAndPath } from "../git/gitProjectInformation"; const TOKEN_KEY = "gitlab-personal-access-token"; export const hasGitlabToken = async () => await hasPreference(TOKEN_KEY); export const setupGitlabToken = async (vorpal: CommandInstance) => { vorpal.log(""); vorpal.log("☝ in order to access the api, we need a personal access token"); vorpal.log("Its best to create one specifically for catladder"); vorpal.log("Scopes needed: api"); vorpal.log(""); vorpal.log("☝ we open up the settings page for you!"); vorpal.log(""); const [{ shouldContinue }, { gitRemoteHost }] = await Promise.all([ vorpal.prompt({ default: true, message: "Ok", name: "shouldContinue", type: "prompt", }), getGitRemoteHostAndPath(), ]); open(`https://${gitRemoteHost}/-/user_settings/personal_access_tokens`); vorpal.log("Please type in gitlab's personal access token"); const { personalToken } = await vorpal.prompt({ type: "string", name: "personalToken", default: "", message: "Your personal access token ", }); if (personalToken) { await setPreference(TOKEN_KEY, personalToken); } }; export const getGitlabToken = async (vorpal: CommandInstance | null) => { if (!(await hasGitlabToken())) { if (!vorpal) { console.error( "⚠️ gitlab token missing, please run catladder to set it up", ); process.exit(1); } await setupGitlabToken(vorpal); } return getPreference(TOKEN_KEY); }; type Method = "GET" | "PUT" | "POST" | "DELETE"; export const doGitlabRequest = async <T = any>( vorpal: CommandInstance | null, path: string, data: any = undefined, method: Method = "GET", ): Promise<T> => { const [rootToken, { gitRemoteHost }] = await Promise.all([ getGitlabToken(vorpal), getGitRemoteHostAndPath(), ]); //const method = data ? (update ? "PUT" : "POST") : "GET"; const result = await fetch(`https://${gitRemoteHost}/api/v4/${path}`, { method, headers: { "Content-Type": "application/json", "Private-Token": rootToken, }, body: data ? JSON.stringify(data) : undefined, }); if (result.status >= 200 && result.status < 400) { if (result.headers.get("content-type") === "application/json") { return result.json(); } return null; } if (result.status === 404) { throw new Error("not found"); } throw new Error( `Could not send request to gitlab api ${path}: ${result.status} "${ result.statusText }".\nResponse: ${JSON.stringify(await result.json(), null, 2)}`, ); }; export const getProjectInfo = async ( vorpal: CommandInstance | null, ): Promise<{ id: string; web_url: string }> => { const { gitRemotePath } = await getGitRemoteHostAndPath(); const project = await doGitlabRequest( vorpal, `projects/${encodeURIComponent(gitRemotePath)}`, ); return project; }; type GitlabVariable = { variable_type: string; key: string; value: string; protected: boolean; masked: boolean; environment_scope: string; }; export const getAllVariables = memoizee( async ( vorpal: CommandInstance | null, n = 5, // how many requests to do in parallel, 5 seems a good value for many projects, ideally we would do one request in parallel per component ): Promise<Array<GitlabVariable>> => { const { id } = await getProjectInfo(vorpal); let all: Array<GitlabVariable> = []; let result: Array<Array<GitlabVariable>> = []; let page = 1; do { // Create an array of promises for N pages const promises = Array.from({ length: n }, (_, i) => { return doGitlabRequest( vorpal, `projects/${id}/variables?per_page=100&page=${page + i}`, ); }); // Wait for all promises to resolve result = await Promise.all(promises); // Increment the page by N page += n; // Flatten the result array and add it to 'all' all = [...all, ...result.flat()]; // Continue only if the last page had results } while (result.length > 0 && result[result.length - 1].length > 0); return all; }, { promise: true }, ); export const getVariableValueByRawName = async ( vorpal: CommandInstance, rawName: string, ) => { const allVariables = await getAllVariables(vorpal); return allVariables.find((v) => v.key === rawName)?.value; }; const maskableRegex = new RegExp("^[a-zA-Z0-9_+=/@:.~-]{8,}$"); // SEE https://gitlab.com/gitlab-org/gitlab-foss/-/blob/master/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js#L20 const isMaskable = (value: string): boolean => maskableRegex.test(value); const createVariable = async ( vorpal: CommandInstance, projectId: string, key: string, value: string, masked = true, environment_scope = "*", ) => { return await doGitlabRequest( vorpal, `projects/${projectId}/variables`, { key, value, masked: masked && isMaskable(value), environment_scope, }, "POST", ); }; const updateVariable = async ( vorpal: CommandInstance, projectId: string, key: string, value: string, masked = true, ) => { return await doGitlabRequest( vorpal, `projects/${projectId}/variables/${key}`, { value, masked: masked && isMaskable(value), }, "PUT", ); }; const deleteVariable = async ( vorpal: CommandInstance, projectId: string, key: string, ) => { return await doGitlabRequest( vorpal, `projects/${projectId}/variables/${key}`, undefined, "DELETE", ); }; const getAllCatladderEnvVarsInGitlab = async (vorpal: CommandInstance) => { const allVariables = await getAllVariables(vorpal).then((v) => v.reduce<{ [key: string]: { value?: string; backups: number[]; }; }>((acc, variable) => { const { key } = variable; if (key.startsWith("CL_")) { const matchBackup = key.match(/(CL_.*)_backup_([0-9]+)/); if (matchBackup) { const key = matchBackup[1]; const timestamp = Number(matchBackup[2]); const backups = [...(acc[key]?.backups ?? []), timestamp]; return { ...acc, [key]: { ...(acc[key] ?? {}), // add value backups, }, }; } return { ...acc, [key]: { backups: [], ...(acc[key] ?? {}), // may add backups value: variable.value, }, }; } return acc; }, {}), ); return allVariables; }; const getBackupKey = (fullKey: string, timestamp: number) => `${fullKey}_backup_${timestamp}`; export const clearBackups = async (vorpal: CommandInstance, keep: number) => { const existingVariables = await getAllCatladderEnvVarsInGitlab(vorpal); const { id } = await getProjectInfo(vorpal); for (const [key, { backups }] of Object.entries(existingVariables)) { const backupsSorted = backups.sort((a, b) => b - a); //const toKeep = backupsSorted.slice(0, keep); const toDelete = backupsSorted.slice(keep); for (const timestamp of toDelete) { await deleteVariable(vorpal, id, getBackupKey(key, timestamp)); } } }; export const upsertAllVariables = async ( vorpal: CommandInstance, variables: Record<string, any>, env: string, componentName: string, backup = true, masked = true, // FIXME: would be better to have this per variable ): Promise<void> => { const { id } = await getProjectInfo(vorpal); // we list all existing variables. We also gather backup versions, but its currently unused const existingVariables = await getAllCatladderEnvVarsInGitlab(vorpal); for (const [key, value] of Object.entries(variables ?? {})) { const fullKey = getSecretVarName(env, componentName, key); const valueSanitized = isObject(value) ? JSON.stringify(value) : `${value}`; const exists = has(existingVariables, fullKey); const oldValue = existingVariables[fullKey]?.value; const changed = oldValue !== valueSanitized; if (changed) { if (exists) { vorpal.log(`changed: ${key}`); await updateVariable(vorpal, id, fullKey, valueSanitized, masked); // write backup if (backup) { await createVariable( vorpal, id, getBackupKey(fullKey, new Date().getTime()), oldValue, masked, "_backup", ); } } else { vorpal.log(`new : ${key}`); await createVariable(vorpal, id, fullKey, valueSanitized, masked); } } else { vorpal.log(`skip : ${key}`); } } getAllVariables.clear(); };