@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
372 lines (358 loc) • 17.6 kB
JavaScript
/**
* Image management module for pull, build, creation of docker images and loading them into Kubernetes clusters
* @module src/cli/image.js
* @namespace UnderpostImage
*/
import fs from 'fs-extra';
import os from 'os';
import nodePath from 'path';
import crypto from 'crypto';
import { loggerFactory } from '../server/logger.js';
import Underpost from '../index.js';
import { getNpmRootPath } from '../server/conf.js';
import { shellExec } from '../server/process.js';
const logger = loggerFactory(import.meta);
/**
* @class UnderpostImage
* @description Manages Docker image operations, including pulling, building, and loading images into Kubernetes clusters.
* This class provides a set of static methods to handle image operations, including pulling base images,
* building custom images, and loading them into specified Kubernetes clusters (Kind, Kubeadm, or K3s).
* @memberof UnderpostImage
*/
class UnderpostImage {
static API = {
/**
* @method pullBaseImages
* @description Ensures the base image prerequisites for the runtime Dockerfiles
* are present on the host (currently `docker.io/rockylinux/rockylinux:9`). This
* only pulls — it does NOT build. Builds run with `podman build --pull=never`,
* so the base must exist locally beforehand; that is the sole purpose of this
* step. Combine with `--build` in the same command to pull-then-build.
* @memberof UnderpostImage
*/
pullBaseImages() {
shellExec(`sudo podman pull docker.io/rockylinux/rockylinux:9`);
},
/**
* @method build
* @description Builds a Docker image using Podman, optionally saves it as a tar archive,
* and loads it into a specified target (Kind, Kubeadm, or K3s cluster, or the local
* Docker store for Docker Compose).
* @param {object} options - Options for building and loading images.
* @param {string} [options.path=''] - The path to the directory containing the Dockerfile.
* @param {string} [options.imageName=''] - The name and tag for the image (e.g., 'my-app:latest').
* @param {string} [options.version=''] - Version tag for the image.
* @param {string} [options.imageOutPath=''] - Directory to save the image tar file.
* @param {string} [options.dockerfileName=''] - Name of the Dockerfile (defaults to 'Dockerfile').
* @param {boolean} [options.podmanSave=false] - If true, save the image as a tar archive using Podman.
* @param {boolean} [options.kind=false] - If true, load the image archive into a Kind cluster.
* @param {boolean} [options.kubeadm=false] - If true, load the image archive into a Kubeadm cluster (uses 'ctr').
* @param {boolean} [options.k3s=false] - If true, load the image archive into a K3s cluster (uses 'k3s ctr').
* @param {boolean} [options.dockerCompose=false] - If true, load the image archive into the local Docker store for Docker Compose.
* @param {boolean} [options.reset=false] - If true, perform a no-cache build.
* @param {boolean} [options.dev=false] - If true, use development mode.
* @memberof UnderpostImage
*/
build(
options = {
path: '',
imageName: '',
version: '',
imageOutPath: '',
dockerfileName: '',
podmanSave: false,
kind: false,
kubeadm: false,
k3s: false,
dockerCompose: false,
reset: false,
dev: false,
},
) {
let {
path,
imageName,
version,
imageOutPath,
dockerfileName,
podmanSave,
kind,
kubeadm,
k3s,
dockerCompose,
reset,
dev,
} = options;
if (!path) path = '.';
if (!imageName) imageName = `rockylinux9-underpost:${Underpost.version}`;
if (!imageOutPath) imageOutPath = '.';
if (imageName.match('/')) imageName = imageName.split('/')[1];
if (!version) version = 'latest';
version = imageName && imageName.match(':') ? '' : `:${version}`;
const podManImg = `localhost/${imageName}${version}`;
if (imageOutPath && typeof imageOutPath === 'string' && !fs.existsSync(imageOutPath))
fs.mkdirSync(imageOutPath, { recursive: true });
const tarFile = `${imageOutPath}/${imageName.replace(':', '_')}.tar`;
let cache = '';
if (reset === true) cache += ' --rm --no-cache';
// Forward GitHub credentials from the host environment into the build as
// podman/BuildKit secrets (`--secret id=...,src=...`), matching the
// `RUN --mount=type=secret,id=github_*` contract in the runtime
// Dockerfiles (e.g. src/runtime/engine-cyberia). Secrets are written to
// short-lived 0600 temp files and removed right after the build — they are
// never passed as build-args (which would persist in image history) nor
// baked into any layer.
const secretTmpFiles = [];
const secretFlags = [];
const addBuildSecret = (id, value) => {
if (!value) return;
const file = nodePath.join(os.tmpdir(), `underpost-secret-${id}-${crypto.randomBytes(6).toString('hex')}`);
fs.writeFileSync(file, String(value), { mode: 0o600 });
secretTmpFiles.push(file);
secretFlags.push(`--secret id=${id},src=${file}`);
};
// addBuildSecret('github_token', process.env.GITHUB_TOKEN);
addBuildSecret('github_username', process.env.GITHUB_USERNAME);
// Cloudinary creds power build-time asset pulls (`node bin fs --pull`).
// addBuildSecret('cloudinary_cloud_name', process.env.CLOUDINARY_CLOUD_NAME);
// addBuildSecret('cloudinary_api_key', process.env.CLOUDINARY_API_KEY);
// addBuildSecret('cloudinary_api_secret', process.env.CLOUDINARY_API_SECRET);
const secretArgs = secretFlags.length ? ` ${secretFlags.join(' ')}` : '';
if (secretFlags.length)
logger.info('Passing host GitHub credentials as build secrets', { ids: secretFlags.length });
if (path)
try {
shellExec(
`cd ${path} && sudo podman build -f ./${
dockerfileName && typeof dockerfileName === 'string' ? dockerfileName : 'Dockerfile'
} -t ${imageName} --pull=never --cap-add=CAP_AUDIT_WRITE${cache}${secretArgs} --network host`,
);
} finally {
for (const file of secretTmpFiles) {
try {
fs.removeSync(file);
} catch {
/* best-effort cleanup */
}
}
}
// Loading into any target requires the tar archive, so imply the save when
// one is set (kind/kubeadm/k3s/docker-compose) even if --podman-save was omitted.
const loadTarget = kind === true || kubeadm === true || k3s === true || dockerCompose === true;
if (podmanSave === true || loadTarget) {
if (fs.existsSync(tarFile)) fs.removeSync(tarFile);
shellExec(`podman save -o ${tarFile} ${podManImg}`);
}
if (kind === true) shellExec(`sudo kind load image-archive ${tarFile}`);
else if (kubeadm === true) shellExec(`sudo ctr -n k8s.io images import ${tarFile}`);
else if (k3s === true) shellExec(`sudo k3s ctr images import ${tarFile}`);
// Independent of any cluster target: make the local image available to the
// Docker daemon so `docker compose` can resolve it (e.g. ENGINE_CYBERIA_IMAGE).
if (dockerCompose === true) shellExec(`sudo docker load -i ${tarFile}`);
},
/**
* @method importTar
* @description Loads a pre-built image tar archive into each enabled target
* without building anything. Mirrors the load step of {@link build}, but
* the archive is supplied directly via `--import-tar <tar-path>` and every
* enabled target flag is honored (the same archive is loaded into each), so
* `--kind --docker-compose` loads it into both.
* @param {object} options - CLI options.
* @param {string} options.importTar - Path to the image tar archive (e.g. `./image-v1.0.0.tar`).
* @param {boolean} [options.kind] - Load into the Kind cluster (`kind load image-archive`).
* @param {boolean} [options.kubeadm] - Import into kubeadm containerd (`ctr -n k8s.io images import`).
* @param {boolean} [options.k3s] - Import into k3s containerd (`k3s ctr images import`).
* @param {boolean} [options.dockerCompose] - Load into the local Docker daemon (`docker load`) for Docker Compose.
* @returns {void}
* @memberof UnderpostImage
*/
importTar(options = { importTar: '', kind: false, kubeadm: false, k3s: false, dockerCompose: false }) {
const { importTar, kind, kubeadm, k3s, dockerCompose } = options;
if (!importTar || typeof importTar !== 'string' || !fs.existsSync(importTar)) {
logger.error('image --import-tar: archive not found', { importTar });
return;
}
const targets = [];
if (kind === true) {
shellExec(`sudo kind load image-archive ${importTar}`);
targets.push('kind');
}
if (kubeadm === true) {
shellExec(`sudo ctr -n k8s.io images import ${importTar}`);
targets.push('kubeadm');
}
if (k3s === true) {
shellExec(`sudo k3s ctr images import ${importTar}`);
targets.push('k3s');
}
if (dockerCompose === true) {
shellExec(`sudo docker load -i ${importTar}`);
targets.push('docker-compose');
}
if (targets.length === 0)
logger.warn(
'image --import-tar: no target enabled; combine with --kind, --kubeadm, --k3s and/or --docker-compose',
);
else logger.info('image --import-tar: archive loaded', { importTar, targets });
},
/**
* @method getCurrentLoaded
* @description Retrieves the currently loaded images in the Kubernetes cluster.
* @param {string} [node='kind-worker'] - Node name to check for loaded images.
* @param {object} options - Options for the image retrieval.
* @param {boolean} options.spec - Whether to retrieve images from the pod specifications.
* @param {string} options.namespace - Kubernetes namespace to filter pods.
* @returns {Array<object>} - Array of objects containing pod names and their corresponding images.
* @memberof UnderpostImage
*/
getCurrentLoaded(node = 'kind-worker', options = { spec: false, namespace: '' }) {
if (options.spec) {
const raw = shellExec(
`kubectl get pods ${options.namespace ? `--namespace ${options.namespace}` : `--all-namespaces`} -o=jsonpath='{range .items[*]}{"\\n"}{.metadata.namespace}{"/"}{.metadata.name}{":\\t"}{range .spec.containers[*]}{.image}{", "}{end}{end}'`,
{
stdout: true,
silent: true,
},
);
return raw
.split(`\n`)
.map((lines) => ({
pod: lines.split('\t')[0].replaceAll(':', '').trim(),
image: lines.split('\t')[1] ? lines.split('\t')[1].replaceAll(',', '').trim() : null,
}))
.filter((o) => o.image);
}
const raw = shellExec(node === 'kind-worker' ? `docker exec -i ${node} crictl images` : `crictl images`, {
stdout: true,
silent: true,
});
const heads = raw
.split(`\n`)[0]
.split(' ')
.filter((_r) => _r.trim());
const pods = raw
.split(`\n`)
.filter((r) => !r.match('IMAGE'))
.map((r) => r.split(' ').filter((_r) => _r.trim()));
const result = [];
for (const row of pods) {
if (row.length === 0) continue;
const pod = {};
let index = -1;
for (const head of heads) {
if (head in pod) continue;
index++;
pod[head] = row[index];
}
result.push(pod);
}
return result;
},
/**
* @method list
* @description Lists currently loaded Docker images in the specified Kubernetes cluster node.
* @param {object} options - Options for listing loaded images.
* @param {string} [options.nodeName='kind-worker'] - The name of the node to query.
* @param {string} [options.namespace=''] - The namespace to filter images (if applicable).
* @param {boolean} [options.spec=false] - If true, include detailed specifications of each image.
* @param {boolean} [options.log=false] - If true, log the list of images to the console.
* @param {boolean} [options.k3s=false] - If true, list images from a K3s cluster.
* @param {boolean} [options.kubeadm=false] - If true, list images from a Kubeadm cluster.
* @param {boolean} [options.kind=false] - If true, list images from a Kind cluster.
* @returns {Array} - An array of loaded images information.
* @memberof UnderpostImage
*/
list(options = { nodeName: '', namespace: '', spec: false, log: false, k3s: false, kubeadm: false, kind: false }) {
if ((options.kubeadm === true || options.k3s === true) && !options.nodeName)
options.nodeName = shellExec('echo $HOSTNAME', { stdout: true, silent: true }).trim();
const list = Underpost.image.getCurrentLoaded(options.nodeName ? options.nodeName : 'kind-worker', options);
if (options.log) console.table(list);
return list;
},
/**
* @method rm
* @description Removes a specified Docker image from the specified Kubernetes cluster type (Kind, Kubeadm, or K3s).
* @param {object} options - Options for removing the image.
* @param {string} [options.imageName=''] - The name and tag of the image to be removed.
* @param {boolean} [options.k3s=false] - If true, remove the image from a K3s cluster.
* @param {boolean} [options.kubeadm=false] - If true, remove the image from a Kubeadm cluster.
* @param {boolean} [options.kind=false] - If true, remove the image from a Kind cluster.
* @memberof UnderpostImage
*/
rm(options = { imageName: '', k3s: false, kubeadm: false, kind: false }) {
let { imageName, k3s, kubeadm, kind } = options;
if (kind === true) {
shellExec(`docker exec -i kind-control-plane crictl rmi ${imageName}`);
shellExec(`docker exec -i kind-worker crictl rmi ${imageName}`);
} else if (kubeadm === true) {
shellExec(`crictl rmi ${imageName}`);
} else if (k3s === true) {
shellExec(`sudo k3s ctr images rm ${imageName}`);
}
},
/**
* @method pullDockerHubImage
* @description Pulls a Docker image from Docker Hub and loads it into the specified Kubernetes cluster type (Kind, Kubeadm, or K3s).
* @param {object} options - Options for pulling and loading the image.
* @param {boolean} [options.k3s=false] - If true, load the image into a K3s cluster.
* @param {boolean} [options.kubeadm=false] - If true, load the image into a Kubeadm cluster.
* @param {boolean} [options.kind=false] - If true, load the image into a Kind cluster.
* @param {string} [options.dockerhubImage=''] - The name of the Docker Hub image to be pulled.
* @param {string} [options.version=''] - The version tag of the image to be pulled.
* @memberof UnderpostImage
*/
pullDockerHubImage(options = { k3s: false, kubeadm: false, kind: false, dockerhubImage: '', version: '' }) {
if (options.dockerhubImage && options.dockerhubImage.startsWith('localhost')) {
logger.warn(`[image] pullDockerHubImage skipped — local image cannot be pulled from Docker Hub`, {
dockerhubImage: options.dockerhubImage,
});
return;
}
if (options.dockerhubImage === 'underpost') {
options.dockerhubImage = 'underpost/underpost-engine';
if (!options.version) options.version = Underpost.version;
}
if (!options.version) options.version = 'latest';
const version = options.dockerhubImage && options.dockerhubImage.match(':') ? '' : `:${options.version}`;
const image = `${options.dockerhubImage}${version}`;
const targetKind = options.kind === true;
const targetK3s = options.k3s === true;
const targetKubeadm = options.kubeadm === true || (!targetKind && !targetK3s);
const requestedRepo = image.replace(/:[^/]+$/, '');
const requestedTag = image.match(/:([^/]+)$/)?.[1] || 'latest';
const normalizeRepo = (repo = '') =>
repo
.trim()
.replace(/^localhost\//, '')
.replace(/^docker\.io\//, '')
.replace(/^library\//, '');
const currentImages = UnderpostImage.API.list({
kind: targetKind,
kubeadm: targetKubeadm,
k3s: targetK3s,
log: false,
});
const existsInCluster = currentImages.some((row) => {
const rowImageRaw = String(row.IMAGE || row.image || '').trim();
if (!rowImageRaw) return false;
const rowImage = rowImageRaw.replace(/:[^/]+$/, '');
const rowTag = String(row.TAG || rowImageRaw.match(/:([^/]+)$/)?.[1] || '').trim();
return normalizeRepo(rowImage) === normalizeRepo(requestedRepo) && rowTag === requestedTag;
});
if (existsInCluster) {
logger.info(`[image] pull skipped. Image already loaded`, {
image,
clusterType: targetKind ? 'kind' : targetK3s ? 'k3s' : 'kubeadm',
});
return;
}
if (targetKind) {
shellExec(`docker pull ${image}`);
shellExec(`sudo kind load docker-image ${image}`);
} else {
shellExec(`sudo crictl pull ${image}`);
}
},
};
}
export default UnderpostImage;