UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

687 lines 30.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ImageCmd = void 0; exports.configureDockerAuth = configureDockerAuth; const chalk = require("chalk"); const fs = require("fs"); const os = require("os"); const path = require("path"); const options_1 = require("./options"); const child_process_1 = require("child_process"); const generic_1 = require("./generic"); const resolver_1 = require("./resolver"); const query_1 = require("./query"); const command_1 = require("../cli/command"); const client_1 = require("../docker/client"); const orchestrator_1 = require("../image/remote/orchestrator"); const renderer_1 = require("../image/remote/progress/renderer"); const secret_1 = require("../resources/secret"); const errors_1 = require("../image/remote/errors"); const build_flags_1 = require("../image/build-flags"); const links_1 = require("../util/links"); const url_1 = require("url"); const download_1 = require("../pack/download"); const logger_1 = require("../util/logger"); const functions_1 = require("../util/functions"); const io_1 = require("../util/io"); const objects_1 = require("../util/objects"); class ImageCmd extends command_1.Command { constructor() { super(...arguments); this.command = 'image'; this.describe = 'Manage images and configure Docker login'; } builder(yargs) { const imgResolver = (0, resolver_1.kindResolver)('image'); const resolver = { homeLink: imgResolver.homeLink, parentLink: imgResolver.parentLink, kind: imgResolver.kind, resourceLink(ref, ctx) { if (!ref) { return imgResolver.resourceLink(ref, ctx); } // XXX this is not good const marker = '.cpln.io/'; const i = ref === null || ref === void 0 ? void 0 : ref.indexOf(marker); if (i >= 0) { ref = ref.substring(i + marker.length); } return imgResolver.resourceLink(ref, ctx); }, }; const schema = { props: [...query_1.defaultProps, 'repository', 'tag', 'digest'], }; const opts = [options_1.withOrgOptions, options_1.withStandardOptions]; let prepare = { async prepare() { let registry = (await this.session.discovery).endpoints.registry; if (!registry) { return; } registry = registry.replace('https://', ''); registry = registry.replace('{org}', this.session.context.org); this.formatHints = { registry: registry, }; }, }; const commandName = 'image'; const commandNamePlural = 'images'; const commandNameA = 'an image'; return (yargs .demandCommand() .version(false) .help() // generic .command(new generic_1.Get(commandNamePlural, resolver, ...opts).with(prepare).toYargs()) .command(new generic_1.Edit(commandName, resolver, ...opts).with(prepare).toYargs()) .command(new generic_1.Patch(commandName, resolver, ...opts).with(prepare).toYargs()) .command(new generic_1.Delete(commandNamePlural, resolver, ...opts).with(prepare).toYargs()) .command(new generic_1.Query(commandNamePlural, resolver, schema, ...opts).with(prepare).toYargs()) .command(new generic_1.ListPermissions(commandNameA, resolver, ...opts).with(prepare).toYargs()) .command(new generic_1.ViewAccessReport(commandName, resolver, ...opts).toYargs()) .command(new generic_1.Audit(commandName, resolver, ...opts).toYargs()) .command(new generic_1.Tag(commandNamePlural, resolver, ...opts).with(prepare).toYargs()) // specific .command(new DockerLogin().toYargs()) .command(new Build().toYargs()) .command(new CopyImage().toYargs())); } async handle() { } } exports.ImageCmd = ImageCmd; class DockerLogin extends command_1.Command { constructor() { super(); this.command = 'docker-login'; this.describe = "Perform a Docker login to the organization's private registry"; } builder(yargs) { return (0, functions_1.pipe)((yargs) => { return yargs.options({ 'ignore-output': { describe: 'Ignore the output of this command.', }, }); }, // options_1.withOrgOptions, options_1.withStandardOptions)(yargs); } async handle(args) { const orgLink = (0, resolver_1.resolveToLink)('org', undefined, this.session.context); const org = await this.client.get(orgLink); const profile = this.session.profile; const registry = configureDockerAuth((await this.session.discovery).endpoints.registry, org.name); if (args.ignoreOutput) { return; } if (this.session.profile.name != 'anonymous') { this.session.err(`Docker will authenticate to "${registry}" using docker-credential-cpln in profile "${profile.name}".`); this.session.err(`As you may change the default profile you are advised to set the profile before using docker:`); this.session.err(`\n CPLN_PROFILE="${profile.name}"\n`); } else { this.session.err(`Docker will authenticate to "${registry}" using docker-credential-cpln`); } } } function configureDockerAuth(regEndpoint, orgName, push) { var _a; const endpoint = new url_1.URL(regEndpoint); let registry = endpoint.hostname.replace('{org}', orgName); if (endpoint.port) { registry += ':' + endpoint.port; } // Check if the docker-credential-cpln binary is reachable only if push is specified if (push) { try { (0, child_process_1.execSync)('docker-credential-cpln version'); } catch (error) { throw new Error('The docker-credential-cpln command is not accessible. Please verify your installation and system PATH, or try reinstalling the cpln CLI. For installation instructions, visit: https://docs.controlplane.com/reference/cli'); } } // cat ~/.docker/config.json const dockerCfgPath = `${os.homedir()}/.docker/config.json`; // load config file let cfg; if (fs.existsSync(dockerCfgPath)) { cfg = JSON.parse(fs.readFileSync(dockerCfgPath, 'utf-8')); cfg.credHelpers = (_a = cfg.credHelpers) !== null && _a !== void 0 ? _a : {}; } else { cfg = { credHelpers: {}, }; } // update config only if that would change the file if (cfg.credHelpers[registry] != 'cpln') { cfg.credHelpers[registry] = 'cpln'; } else { return registry; } // try creating the parent folder of the config file just in case fs.mkdirSync(`${os.homedir()}/.docker`, { recursive: true, }); // persist config fs.writeFileSync(dockerCfgPath, JSON.stringify(cfg, null, 2), { encoding: 'utf-8', }); return registry; } function withBuildOptions(yargs) { return yargs .parserConfiguration({ // yargs replaces the root parser config, so its keys are repeated here 'sort-commands': true, 'strip-aliased': true, 'parse-numbers': false, 'populate--': true, 'boolean-negation': false, }) .options({ name: { type: 'string', requiresArg: true, demandOption: true, alias: 'n', description: 'Name and tag for the image', }, dockerfile: { //requiresArg: true, description: 'Path to Dockerfile (e.g.: PATH/Dockerfile). If set, the builder option is not used', }, builder: { default: build_flags_1.DEFAULT_BUILDER, alias: 'B', description: 'Builder image to use (e.g., heroku/builder:24_linux-amd64, gcr.io/buildpacks/builder:google-22, paketobuildpacks/builder-jammy-full)', }, buildpack: { multiple: true, alias: 'b', description: 'Buildpack to use. One of: buildpack ID and version (<buildpack>@<version>), path to a buildpack directory (not supported on Windows), path/URL to a .tar or .tgz file, or a packaged buildpack image (<hostname>/<repo>[:<tag>]). Repeat for each buildpack in order, or supply once by comma-separated list', }, env: { multiple: true, alias: 'e', description: "Build-time environment variable in the form 'VAR=VALUE' or 'VAR' (value taken from current environment). Repeat for each env var. NOTE: These are NOT available at image runtime.", }, 'env-file': { multiple: true, description: "Build-time environment variables file. One variable per line, of the form 'VAR=VALUE' or 'VAR'. NOTE: These are NOT available at image runtime.", }, 'build-arg': { multiple: true, requiresArg: true, description: "Set build-time variables (format: 'NAME=value', or 'NAME' to take the value from the environment). Repeat for each arg. NOTE: Not used by buildpack builds.", }, secret: { multiple: true, requiresArg: true, description: "Secret to expose to the build in the form 'id=<id>[,src=<source>]'. src takes a local file path, or cpln://secret/<name>[.<field>] for a secret in your org. Local sources (a file path, an env variable) don't work with --remote. Repeat for each secret. Requires a Dockerfile build.", }, 'trust-builder': { boolean: true, default: false, description: 'Trust the provided builder. All lifecycle phases will be run in a single container for better performance.', }, 'trust-extra-buildpacks': { boolean: true, default: false, description: 'Trust buildpacks that are provided in addition to the buildpacks on the builder', }, dir: { type: 'string', requiresArg: true, default: build_flags_1.DEFAULT_DIR, description: 'Directory containing the application', }, 'no-cache': { boolean: true, default: false, description: 'Builds the image without using any cached layers.', }, push: { boolean: true, default: false, description: "Push the new image to the org's private registry", }, platform: { requiresArg: true, default: build_flags_1.DEFAULT_PLATFORM, alias: 'p', description: 'Target platform(s) for the build (e.g., linux/amd64, linux/amd64,linux/arm64)', }, remote: { boolean: true, default: false, description: "Build on the Control Plane build service instead of local Docker, then push the image to the org's private registry. Builds the --dir folder, or a git repository when --repo is set. The folder context honors .dockerignore, or .gitignore when no .dockerignore is present.", }, repo: { type: 'string', requiresArg: true, description: "HTTPS URL of a GitHub or GitLab repository to build instead of a local folder. Requires --remote; private repositories build through the org's git connection, which the CLI sets up on first use.", }, branch: { type: 'string', requiresArg: true, description: "Branch to build. Requires --repo, and defaults to the repository's default branch.", }, detach: { boolean: true, default: false, description: 'Start the build and return immediately instead of waiting for it to finish. Requires --remote.', }, }); } class Build extends command_1.Command { constructor() { super(); this.command = 'build'; this.describe = 'Build and containerize an application into an image. If using buildpacks, everything after -- will be passed down to the pack executable.'; } builder(yargs) { return (0, functions_1.pipe)( // withBuildOptions, options_1.withOrgOptions, options_1.withStandardOptions)(yargs); } async handle(args) { var _a; this.requireOrg(); const parts = (0, build_flags_1.parseImageName)(args.name); if (!parts) { this.session.abort({ message: 'Image name must have a tag (i.e. my-image:tag)' }); } const link = (0, resolver_1.kindResolver)('image').resourceLink(`${parts.name}:${parts.tag}`, this.session.context); const remoteOnly = (0, build_flags_1.remoteOnlyViolations)(args); if (remoteOnly.length > 0) { this.session.abort({ message: `${remoteOnly.join(', ')} require${remoteOnly.length === 1 ? 's' : ''} --remote.` }); } const isRemote = args.remote === true; const buildArgs = (0, build_flags_1.parseBuildArgs)(args.buildArg, isRemote); const secrets = (0, build_flags_1.parseBuildSecrets)(args.secret, isRemote, this.org); // Without a Dockerfile the build runs on buildpacks, which consume neither // build args nor secrets; secrets abort, args warn as docker warns. Only a // folder's context is visible here, so a repo build is the service's to check. if (args.repo === undefined && !this.contextHasDockerfile(args)) { if (secrets.mounts.length > 0 || secrets.dockerOptions.length > 0) { this.session.abort({ message: 'ERROR: --secret requires a Dockerfile build; buildpack builds cannot consume secrets. Add a Dockerfile, or drop --secret.', }); } this.warnBuildArgsUnusedByBuildpack(Object.keys(buildArgs)); } if (isRemote) { return this.handleRemote(args, parts, link, buildArgs, secrets); } this.requireDocker(); // Resolve user paths args.dir = (0, io_1.resolvePath)(args.dir); if (typeof args.dockerfile === 'string') { args.dockerfile = (0, io_1.resolvePath)(args.dockerfile); } const dockerfilePath = this.resolveDockerfile(args); const registry = configureDockerAuth((await this.session.discovery).endpoints.registry, this.org, args.push); const image = `${registry}/${args.name}`; // Warn if multi-platform build without --push (let docker/pack fail with their own error) if (((_a = args.platform) === null || _a === void 0 ? void 0 : _a.includes(',')) && !args.push) { this.warnMultiPlatformWithoutPush(); } const docker = new client_1.DockerClient(this.session.profile); const prepared = dockerfilePath ? await this.prepareBuildSecrets(secrets) : { secrets: [], env: {} }; try { if (dockerfilePath) { await docker.build({ dockerfilePath, path: args.dir, nameAndTag: image, platform: args.platform, push: !!args.push, noCache: args.noCache, buildArgs, secrets: prepared.secrets, }, prepared.env); } else { await this.buildWithPack(args, image, docker); } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.session.abort({ message }); } this.session.outFormat({ image, link }); } /** * Aborts with a --remote hint when Docker is unavailable for a local build. */ requireDocker() { const availability = client_1.DockerClient.checkAvailability(); if (availability === 'ok') { return; } const reason = availability === 'missing' ? 'Docker is not installed' : 'Docker is installed but the daemon is not running'; this.session.abort({ message: `${reason}. Re-run with --remote to build without Docker (the image will be pushed to the org registry).`, }); } /** * Builds the image remotely (no local Docker) from a local folder or a git repo, * then pushes it. Private repos authenticate through the org's provider connection. * * @param {Arguments<AllOptions & BuildOptions>} args - The parsed build options. * @param {ImageNameParts} parts - The validated image name and tag. * @param {string} link - The self link of the image the build produces. * @param {Record<string, string>} buildArgs - The parsed build args. * @param {BuildSecrets} secrets - The parsed secrets. * @returns {Promise<void>} */ async handleRemote(args, parts, link, buildArgs, secrets) { const combo = (0, build_flags_1.remoteComboViolation)(args); const localOnly = (0, build_flags_1.localOnlyViolations)(args); if (combo) { this.session.abort({ message: combo }); } if (localOnly.length > 0) { const envHint = localOnly.includes('--env') ? ' For Dockerfile ARG values, use --build-arg.' : ''; this.session.abort({ message: `${localOnly.join(', ')} ${localOnly.length === 1 ? 'is' : 'are'} not supported with --remote; the build service detects the build automatically.${envHint}`, }); } const request = args.repo ? { kind: 'repo', org: this.org, imageName: parts.name, imageTag: parts.tag, repoUrl: args.repo, branch: args.branch, detach: args.detach, noCache: args.noCache, buildArgs: Object.keys(buildArgs).length > 0 ? buildArgs : undefined, secrets: secrets.mounts.length > 0 ? secrets.mounts : undefined, } : { kind: 'folder', org: this.org, imageName: parts.name, imageTag: parts.tag, dir: (0, io_1.resolvePath)(args.dir), detach: args.detach, noCache: args.noCache, buildArgs: Object.keys(buildArgs).length > 0 ? buildArgs : undefined, secrets: secrets.mounts.length > 0 ? secrets.mounts : undefined, }; const startedAt = Date.now(); const isTty = Boolean(process.stderr.isTTY); const color = isTty && args.color !== false && process.env.NO_COLOR === undefined; const renderer = (0, renderer_1.makeRemoteBuildProgress)({ err: (line) => this.session.err(line), isTty, color, }); const orchestrator = new orchestrator_1.RemoteBuildOrchestrator(this.session, renderer); // Show activity from the first moment, before the build reaches its first milestone. renderer.onStart(); try { const outcome = await orchestrator.buildAndPush(request); renderer.finish(); if (outcome.completed) { const pushed = `Pushed ${outcome.imageRef} in ${(0, renderer_1.formatElapsed)(Date.now() - startedAt)}`; this.session.err(new chalk.Instance({ level: color ? 1 : 0 }).green(pushed)); this.session.err(''); this.session.outFormat({ image: outcome.imageRef, link }); } else { this.session.err(''); this.session.outFormat({ buildId: outcome.buildId, status: 'building', image: outcome.imageRef, link }); } } catch (e) { renderer.finish(); this.session.abort({ message: (0, errors_1.formatFailure)(e) }); } } /** * Resolves the Dockerfile path of the build: the --dockerfile value, the * conventional name inside --dir for a bare flag, or the auto-detected root * Dockerfile. * * @param {BuildOptions} args - The parsed build options, paths already resolved. * @returns {string | undefined} The Dockerfile path, or undefined for a buildpack build. */ resolveDockerfile(args) { // An explicit --dockerfile path wins as written if (typeof args.dockerfile === 'string') { return args.dockerfile; } const conventionalPath = path.join(args.dir, 'Dockerfile'); // A bare --dockerfile flag means the conventional name, present or not if (args.dockerfile === true) { return conventionalPath; } return fs.existsSync(conventionalPath) ? conventionalPath : undefined; } /** * Whether the build context carries a Dockerfile: the --dockerfile flag, or a * root Dockerfile in the --dir folder. Reads a resolved copy of the paths, so * the args themselves stay as written. * * @param {BuildOptions} args - The parsed build options. * @returns {boolean} True when the build has a Dockerfile to consume its build args and secrets. */ contextHasDockerfile(args) { // Truthiness matches the build's own branch, so an empty --dockerfile counts as absent return Boolean(this.resolveDockerfile({ ...args, dir: (0, io_1.resolvePath)(args.dir) })); } async buildWithPack(args, image, docker) { const packPath = await (0, download_1.installPack)(this.env.profileManager.storeRoot); await packBuild({ packPath, opts: args, image, profileName: (0, client_1.exportableProfileName)(this.session.profile) }); if (args.push) { await docker.push(image); } } /** * Prepares the --secret material of a docker build: docker's own values as * written, and each org mount revealed into an environment-sourced value so * no plaintext reaches the filesystem. * * @param {BuildSecrets} secrets - The parsed --secret values. * @returns {Promise<DockerBuildSecrets>} The --secret values, and the environment they read. */ async prepareBuildSecrets(secrets) { const prepared = { secrets: [...secrets.dockerOptions], env: {} }; const byName = new Map(); for (const [index, mount] of secrets.mounts.entries()) { // The mount's URI is canonical by construction, so it always parses const target = (0, links_1.parseLink)(mount.uri); const variable = `CPLN_SECRET_${index}`; // One instance per secret, so a secret read by many mounts is fetched once if (!byName.has(target.name)) { byName.set(target.name, new secret_1.SecretResource(target.name, this.session, this.client)); } prepared.env[variable] = await byName.get(target.name).getValueFromKey(target.key); prepared.secrets.push(`id=${mount.id},env=${variable}`); } return prepared; } /** * Warns that a buildpack build ignores the build args it was given, naming them. * @param {string[]} names - The args that were given. * @returns {void} */ warnBuildArgsUnusedByBuildpack(names) { if (names.length === 0) { return; } this.session.err(`WARNING: One or more build-args [${names.join(' ')}] were not consumed: no Dockerfile was detected.`, 'yellow'); } warnMultiPlatformWithoutPush() { this.session.err(`Multi-platform build detected without --push.\n` + `Multi-architecture images cannot be loaded into the local Docker daemon.\n` + `The local Docker daemon only supports single-architecture images, but multi-platform\n` + `builds produce a manifest list that cannot be loaded locally.\n\n` + `To resolve this, either:\n` + ` • Re-run with --push to publish the image to the registry\n` + ` • Specify a single platform (e.g., --platform linux/amd64) for local use\n`); } } /** * Runs a pack build for the given target image. * * @param {PackBuildInvocation} invocation - The pack executable, build options, target image, and profile. * @returns {Promise<void>} Resolves when the pack build completes successfully. */ async function packBuild(invocation) { const { packPath, opts, image, profileName } = invocation; const packArgs = [ // Pack Args 'build', '--builder', opts.builder, '--path', opts.dir, '--platform', opts.platform, ]; // Add --buildpack option(s) if specified if (opts.buildpack) { opts.buildpack = (0, objects_1.toArray)(opts.buildpack); for (const bp of opts.buildpack) { packArgs.push('--buildpack', bp); } } // Add --env option(s) if specified if (opts.env) { opts.env = (0, objects_1.toArray)(opts.env); for (const envVar of opts.env) { packArgs.push('--env', envVar); } } // Add --env-file option(s) if specified if (opts.envFile) { opts.envFile = (0, objects_1.toArray)(opts.envFile); for (const envFile of opts.envFile) { packArgs.push('--env-file', envFile); } } // Add --trust-builder if specified if (opts.trustBuilder) { packArgs.push('--trust-builder'); } // Add --trust-extra-buildpacks if specified if (opts.trustExtraBuildpacks) { packArgs.push('--trust-extra-buildpacks'); } // Add --no-cache option if specified if (opts.noCache) { packArgs.push('--clear-cache'); } // Indicate the end of options if (opts['--']) { packArgs.push(...opts['--']); } // Add target image for the build packArgs.push(image); logger_1.logger.debug(`exec: ${packPath} ${packArgs.join(' ')}`); // Export the profile so pack resolves registry credentials as the session's profile const proc = (0, child_process_1.spawn)(packPath, packArgs, { stdio: ['inherit', process.stderr, process.stderr], env: (0, client_1.buildCplnEnv)(profileName), }); return (0, client_1.waitForExit)(proc, 'pack'); } function withCopyOptions(yargs) { return yargs.options({ 'to-name': { requiresArg: true, description: 'Name and tag for the image', }, 'to-org': { requiresArg: true, description: 'Target org to copy the image to', }, 'to-profile': { requiresArg: true, description: 'Profile to use for accessing the "to-org" argument', }, cleanup: { boolean: true, default: false, description: 'Cleans up the pulled and retagged image', }, }); } class CopyImage extends command_1.Command { constructor() { super(); this.command = 'copy <ref>'; this.describe = 'Copy an image from one org to another. This will make sure that docker-login has been run against the source and destination org, then will pull, tag and push the image to the destination org.'; } builder(yargs) { return (0, functions_1.pipe)( // generic_1.withSingleRef, withCopyOptions, options_1.withAllOptions)(yargs); } async handle(args) { const sourceOrg = this.session.context.org; if (!sourceOrg) { this.session.abort({ exitCode: 1, message: 'No source org was detected, either provide "org" argument, or set it on your profile.', }); } const destName = args.toName || args.ref; let destOrg = ''; let destProfile = this.session.profile; if (args.toProfile) { destProfile = this.env.profileManager.find(args.toProfile); if (!destProfile) { this.session.abort({ message: `ERROR: The profile "${args.toProfile}" given with "to-profile" was not found.` }); } destOrg = args.toOrg || destProfile.context.org || ''; if (!destOrg) { this.session.abort({ exitCode: 1, message: `No destination org was detected, either provide "to-org" argument, or set it on the profile given with "to-profile" argument.`, }); } } else { destOrg = args.toOrg || sourceOrg; if (!destOrg) { this.session.abort({ exitCode: 1, message: `No destination org was detected, provide either "to-org" or "to-profile" arguments.` }); } } if (destOrg === sourceOrg && !args.toName) { this.session.abort({ exitCode: 1, message: `"to-name" argument must be provided to copy an image inside the same org.` }); } if (destOrg === sourceOrg && args.ref === args.toName) { this.session.abort({ exitCode: 1, message: `Current image name and the copied image name cannot be the same inside the same org.` }); } const reg = (await this.session.discovery).endpoints.registry; const sourceRegistry = configureDockerAuth(reg, sourceOrg); const destRegistry = configureDockerAuth(reg, destOrg); const sourceImage = `${sourceRegistry}/${args.ref}`; const destImage = `${destRegistry}/${destName}`; const docker = new client_1.DockerClient(this.session.profile); // The destination may authenticate as another profile, so the push gets its own client const destDocker = new client_1.DockerClient(destProfile); logger_1.logger.debug(`Copying ${sourceImage} to ${destImage}`); logger_1.logger.debug(`Pulling ${sourceImage}`); await docker.pull(sourceImage); logger_1.logger.debug(`Tagging as ${destImage}`); await docker.tag(sourceImage, destImage); logger_1.logger.debug(`Pushing ${destImage}`); await destDocker.push(destImage); if (args.cleanup) { logger_1.logger.debug(`Cleaning up ${sourceImage}, ${destImage}`); await docker.remove(sourceImage, destImage); } } } //# sourceMappingURL=image.js.map