@controlplane/cli
Version:
Control Plane Corporation CLI
286 lines • 12.5 kB
JavaScript
;
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