@controlplane/cli
Version:
Control Plane Corporation CLI
467 lines • 19.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ImageCmd = void 0;
exports.configureDockerAuth = configureDockerAuth;
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 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({
'boolean-negation': false,
})
.options({
name: {
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: 'heroku/builder:24_linux-amd64',
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.",
},
'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: {
requiresArg: true,
default: '.',
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: 'linux/amd64',
alias: 'p',
description: 'Target platform(s) for the build (e.g., linux/amd64, linux/amd64,linux/arm64)',
},
});
}
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;
// 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 org = await this.client.get((0, resolver_1.resolveToLink)('org', undefined, this.session.context));
const registry = configureDockerAuth((await this.session.discovery).endpoints.registry, org.name, args.push);
this.validateImageName(args.name);
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 dockerfile = this.resolveDockerfile(args);
const docker = new client_1.DockerClient(this.session.profile);
try {
if (dockerfile) {
await docker.build({
dockerfile,
dir: args.dir,
image,
platform: args.platform,
push: !!args.push,
noCache: args.noCache,
});
}
else {
await this.buildWithPack(args, image, docker);
}
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.session.abort({ exitCode: 1, message });
}
this.session.outFormat({ image, link: `/org/${org.name}/image/${args.name}` });
}
validateImageName(name) {
const parts = name === null || name === void 0 ? void 0 : name.split(':');
if (!parts || !parts[0] || !parts[1]) {
this.session.abort({ exitCode: 1, message: 'Image name must have a tag (i.e. my-image:tag)' });
}
}
resolveDockerfile(args) {
if (typeof args.dockerfile === 'string' || args.dockerfile === true) {
return args.dockerfile;
}
const defaultPath = path.join(args.dir, 'Dockerfile');
if (fs.existsSync(defaultPath)) {
return defaultPath;
}
return undefined;
}
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);
}
}
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 = '';
if (args.toProfile) {
const toProfile = this.env.profileManager.find(args.toProfile);
destOrg = args.toOrg || (toProfile === null || toProfile === void 0 ? void 0 : toProfile.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);
logger_1.logger.debug(`Copying ${sourceImage} to ${destImage}`);
logger_1.logger.debug(`Pulling ${sourceImage}`);
await docker.pull(sourceImage, this.session.context.profile);
logger_1.logger.debug(`Tagging as ${destImage}`);
await docker.tag(sourceImage, destImage);
logger_1.logger.debug(`Pushing ${destImage}`);
await docker.push(destImage, args.toProfile || this.session.context.profile);
if (args.cleanup) {
logger_1.logger.debug(`Cleaning up ${sourceImage}, ${destImage}`);
await docker.remove(sourceImage, destImage);
}
}
}
//# sourceMappingURL=image.js.map