UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

286 lines 12.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MAX_BUILD_SECRETS = exports.MAX_BUILD_ARG_BYTES = exports.MAX_BUILD_ARGS = exports.DEFAULT_DIR = exports.DEFAULT_PLATFORM = exports.DEFAULT_BUILDER = void 0; exports.parseImageName = parseImageName; exports.parseBuildArgs = parseBuildArgs; exports.parseBuildSecrets = parseBuildSecrets; exports.remoteOnlyViolations = remoteOnlyViolations; exports.localOnlyViolations = localOnlyViolations; exports.remoteComboViolation = remoteComboViolation; const links_1 = require("../util/links"); const objects_1 = require("../util/objects"); // ANCHOR - Constants exports.DEFAULT_BUILDER = 'heroku/builder:24_linux-amd64'; exports.DEFAULT_PLATFORM = 'linux/amd64'; exports.DEFAULT_DIR = '.'; // The caps the build service enforces on build args and secrets exports.MAX_BUILD_ARGS = 30; exports.MAX_BUILD_ARG_BYTES = 8 * 1024; exports.MAX_BUILD_SECRETS = 10; // What the build service accepts, so a remote build never sends what it refuses: // a mount id it can use as a filename and a `--secret id=` token, a field selector // that survives the same option, and an arg name that survives `--opt build-arg:`. const REMOTE_SECRET_ID_RE = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,64}$/; const REMOTE_SECRET_FIELD_RE = /^[^\s,=\0]+$/; const REMOTE_ARG_NAME_RE = /^[^\0\n\r]+$/; // The keys docker's --secret accepts; `source` is its alias for `src`. const SECRET_FIELDS = new Set(['id', 'src', 'source', 'env', 'type']); // How the error messages spell a secret source. const SECRET_URI_PREFIX = 'cpln://secret/'; // ANCHOR - Exported Functions /** * Splits a name:tag image reference into its parts. * * @param {string | undefined} raw - The raw --name value. * @returns {ImageNameParts | null} The parts, or null when the form is invalid. */ function parseImageName(raw) { if (!raw) { return null; } const parts = raw.split(':'); if (parts.length !== 2 || !parts[0] || !parts[1]) { return null; } return { name: parts[0], tag: parts[1] }; } /** * Parses --build-arg values, in docker's own form, into the args to set. * * @param {string | string[] | undefined} values - The --build-arg values as written. * @param {boolean} remote - Whether the build runs on the build service. * @param {NodeJS.ProcessEnv} env - The environment a bare NAME reads. * @returns {Record<string, string>} The args, empty when the flag was not given. */ function parseBuildArgs(values, remote, env = process.env) { const flags = (0, objects_1.toArray)(values); const buildArgs = {}; for (const raw of flags) { const eq = raw.indexOf('='); // docker takes the name exactly as written: no trimming, no charset rules. const name = eq === -1 ? raw : raw.slice(0, eq); if (name === '') { throw new Error(`ERROR: invalid key-value pair "${raw}": empty key`); } if (remote && !REMOTE_ARG_NAME_RE.test(name)) { throw new Error(`ERROR: --build-arg name ${JSON.stringify(name)} cannot be sent to the build service; remove the control characters.`); } // Bare NAME takes the caller's environment, as docker does. An unset name // sends nothing, so the Dockerfile's own ARG default still wins. if (eq === -1) { const fromEnv = env[name]; if (fromEnv === undefined) { continue; } buildArgs[name] = fromEnv; } else { buildArgs[name] = raw.slice(eq + 1); } } // The caps measure what is actually sent, so a name written twice counts once if (remote) { const names = Object.keys(buildArgs); if (names.length > exports.MAX_BUILD_ARGS) { throw new Error(`ERROR: at most ${exports.MAX_BUILD_ARGS} --build-arg values are supported.`); } let total = 0; for (const name of names) { total += Buffer.byteLength(name) + Buffer.byteLength(buildArgs[name]); } if (total > exports.MAX_BUILD_ARG_BYTES) { throw new Error(`ERROR: --build-arg values exceed ${exports.MAX_BUILD_ARG_BYTES} bytes total; put large inputs in the build context instead.`); } } return buildArgs; } /** * Parses --secret values, in docker's own form, into the org secrets to reveal * and the options docker parses itself. * * @param {string | string[] | undefined} values - The --secret values as written. * @param {boolean} remote - Whether the build runs on the build service. * @param {string} org - The org the build runs in, which a self link must name. * @returns {BuildSecrets} The org mounts and the docker options. */ function parseBuildSecrets(values, remote, org) { const flags = (0, objects_1.toArray)(values); // One id written twice follows docker: the last option wins, whichever list it is in. const mounts = new Map(); const dockerOptions = new Map(); for (const raw of flags) { const parsed = parseSecretOption(raw, org); mounts.delete(parsed.id); dockerOptions.delete(parsed.id); if (parsed.mount === null) { // Docker's own sources read this computer, which the build service cannot. if (remote) { throw new Error(`ERROR: --secret ${raw} reads this computer, which a remote build cannot. Name a secret in your org with src=${SECRET_URI_PREFIX}<name>.`); } dockerOptions.set(parsed.id, raw); continue; } if (remote && !REMOTE_SECRET_ID_RE.test(parsed.id)) { throw new Error(`ERROR: --secret id "${parsed.id}" cannot be sent to the build service; use up to 64 letters, digits, dots, dashes, or underscores.`); } // The field selector rides the submission inside a --secret option, so it must stay one token there. if (remote) { const field = (0, links_1.parseLink)(parsed.mount.uri).key; if (field !== '' && !REMOTE_SECRET_FIELD_RE.test(field)) { throw new Error(`ERROR: --secret field "${field}" cannot be sent to the build service; remove the spaces, commas, and equals signs.`); } } mounts.set(parsed.id, parsed.mount); } // The cap measures what is actually sent, so an id written twice counts once if (remote && mounts.size > exports.MAX_BUILD_SECRETS) { throw new Error(`ERROR: at most ${exports.MAX_BUILD_SECRETS} --secret values are supported.`); } return { mounts: [...mounts.values()], dockerOptions: [...dockerOptions.values()] }; } /** * Names the flags that were passed without --remote but only work with it. * * @param {BuildFlagView} args - The parsed build flags. * @returns {string[]} The offending flag names, empty when the combination is valid. */ function remoteOnlyViolations(args) { if (args.remote) { return []; } const offending = []; if (args.repo !== undefined) { offending.push('--repo'); } if (args.branch !== undefined) { offending.push('--branch'); } if (args.detach) { offending.push('--detach'); } return offending; } /** * Names the local-build flags that a remote build cannot honor. Flags carrying * defaults (--builder, --platform) are detected by departure from the default. * * @param {BuildFlagView} args - The parsed build flags. * @returns {string[]} The offending flag names, empty when the combination is valid. */ function localOnlyViolations(args) { if (!args.remote) { return []; } const offending = []; if (args.dockerfile !== undefined) { offending.push('--dockerfile'); } if (args.buildpack !== undefined) { offending.push('--buildpack'); } if (args.env !== undefined) { offending.push('--env'); } if (args.envFile !== undefined) { offending.push('--env-file'); } if (args.trustBuilder) { offending.push('--trust-builder'); } if (args.trustExtraBuildpacks) { offending.push('--trust-extra-buildpacks'); } if (args.builder !== exports.DEFAULT_BUILDER) { offending.push('--builder'); } if (args.platform !== exports.DEFAULT_PLATFORM) { offending.push('--platform'); } if (args.push) { offending.push('--push'); } return offending; } /** * Rejects contradictory --remote flag combinations. * * @param {BuildFlagView} args - The parsed build flags. * @returns {string | null} The abort message, or null when the combination is valid. */ function remoteComboViolation(args) { if (!args.remote) { return null; } if (args.repo !== undefined && args.repo.trim() === '') { return '--repo cannot be empty.'; } if (args.branch !== undefined && args.repo === undefined) { return '--branch requires --repo.'; } if (args.branch !== undefined && args.branch.trim() === '') { return '--branch cannot be empty.'; } if (args.repo !== undefined && args.dir !== undefined && args.dir !== exports.DEFAULT_DIR) { return '--dir cannot be combined with --repo, the repository is the build context.'; } return null; } // SECTION - Functions /** * Resolves one --secret value, written in docker's own form: * `id=<id>[,src=<source>][,env=<VAR>][,type=<file|env>]`. Keys are lowercased and * never trimmed, as buildx parses them. A src naming a secret in the org becomes * a mount; every other source is docker's own to read. * * @param {string} raw - The flag value as the user wrote it. * @param {string} org - The org the build runs in, which a self link must name. * @returns {ParsedSecretOption} The mount id, and the org mount when src names a secret. */ function parseSecretOption(raw, org) { var _a, _b, _c; const fields = {}; for (const field of raw.split(',')) { const separator = field.indexOf('='); const key = (separator === -1 ? field : field.slice(0, separator)).toLowerCase(); const value = separator === -1 ? '' : field.slice(separator + 1); if (!SECRET_FIELDS.has(key)) { const hint = (0, links_1.isCplnLink)(field) ? ` A secret in your org is a source: id=<id>,src=${SECRET_URI_PREFIX}<name>.` : ` Write it as id=<id>[,src=<source>][,env=<VAR>].`; throw new Error(`ERROR: --secret: unexpected key '${key}' in '${field}'.${hint}`); } fields[key === 'source' ? 'src' : key] = value; } const id = (_a = fields['id']) !== null && _a !== void 0 ? _a : ''; const env = (_b = fields['env']) !== null && _b !== void 0 ? _b : ''; const src = (_c = fields['src']) !== null && _c !== void 0 ? _c : ''; // docker requires an id: it names the mount the Dockerfile reads. if (id === '') { throw new Error(`ERROR: --secret: secret missing ID. Write it as id=<id>[,src=<source>] (got "${raw}").`); } // env names a variable on this computer, so a link cannot belong there. if ((0, links_1.isCplnLink)(env)) { throw new Error(`ERROR: --secret: env names an environment variable on this computer, not a secret. Write it as id=${id},src=${env}.`); } // An org source supplies the value, so env cannot also supply one; the // contradiction is reported before the link is even resolved. if (env !== '' && (0, links_1.isCplnLink)(src)) { throw new Error(`ERROR: --secret cannot combine src=${SECRET_URI_PREFIX}<name> with env=, since the org secret supplies the value (got "${raw}").`); } const link = (0, links_1.parseLink)(src); // A secret is org-scoped, so a gvc-scoped path cannot name one. if (link === null || link.kind !== 'secret' || link.gvc !== '') { // An invalid secret link is refused, never handed to docker as a file path // it would fail to read. if ((0, links_1.isCplnLink)(src) || link !== null) { throw new Error(`ERROR: --secret: '${src}' is not a valid secret link. Write it as ${SECRET_URI_PREFIX}<name> or ${SECRET_URI_PREFIX}<name>.<field>.`); } return { id, mount: null }; } if (link.org !== '' && link.org !== org) { throw new Error(`ERROR: --secret: '${src}' names org "${link.org}", but the build runs in "${org}"; a build reads secrets from its own org only.`); } return { id, mount: { id, uri: (0, links_1.cplnUriOf)(link) } }; } // !SECTION //# sourceMappingURL=build-flags.js.map