Skip to content

Commit

Permalink
Support cross mounting
Browse files Browse the repository at this point in the history
  • Loading branch information
eoftedal committed Jan 16, 2024
1 parent 3febed1 commit 31a11b0
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 26 deletions.
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,29 @@ Options:
--toImage <name:tag> Required: Image name of target image - [path/]image:tag
--folder <full path> Required: Base folder of node application (contains package.json)
--file <path> Optional: Name of configuration file (defaults to containerify.json if found on path)
--doCrossMount <true/false> Cross mount image layers from the base image (only works if fromImage and toImage are in the same registry) (default: false)
--fromRegistry <registry url> Optional: URL of registry to pull base image from - Default: https://registry-1.docker.io/v2/
--fromToken <token> Optional: Authentication token for from registry
--toRegistry <registry url> Optional: URL of registry to push base image to - Default: https://registry-1.docker.io/v2/
--optimisticToRegistryCheck Optional: Treat redirects as layer existing in remote registry. Potentially unsafe, but could save bandwidth.
--optimisticToRegistryCheck Treat redirects as layer existing in remote registry. Potentially unsafe, but can save bandwidth.
--toToken <token> Optional: Authentication token for target registry
--toTar <path> Optional: Export to tar file
--toDocker Optional: Export to local docker registry
--registry <path> Optional: Convenience argument for setting both from and to registry
--platform <platform> Optional: Preferred platform, e.g. linux/amd64 or arm64
--token <path> Optional: Convenience argument for setting token for both from and to registry
--user <user> Optional: User account to run process in container - default: 1000
--workdir <directory> Optional: Workdir where node app will be added and run from - default: /app
--entrypoint <entrypoint> Optional: Entrypoint when starting container - default: npm start
--user <user> Optional: User account to run process in container - default: 1000 (empty for customContent)
--workdir <directory> Optional: Workdir where node app will be added and run from - default: /app (empty for customContent)
--entrypoint <entrypoint> Optional: Entrypoint when starting container - default: npm start (empty for customContent)
--labels <labels> Optional: Comma-separated list of key value pairs to use as labels
--label <label> Optional: Single label (name=value). This option can be used multiple times.
--envs <envs> Optional: Comma-separated list of key value pairs to use av environment variables.
--env <env> Optional: Single environment variable (name=value). This option can be used multiple times.
--setTimeStamp <timestamp> Optional: Set a specific ISO 8601 timestamp on all entries (e.g. git commit hash). Default: 1970 in tar files, and current time on
manifest/config
--setTimeStamp <timestamp> Optional: Set a specific ISO 8601 timestamp on all entries (e.g. git commit hash). Default: 1970 in tar files, and current time on manifest/config
--verbose Verbose logging
--allowInsecureRegistries Allow insecure registries (with self-signed/untrusted cert)
--customContent <dirs/files> Optional: Skip normal node_modules and applayer and include specified root folder files/directories instead
--customContent <dirs/files> Optional: Skip normal node_modules and applayer and include specified root folder files/directories instead. You can specify as
local-path:absolute-container-path if you want to place it in a specific location
--extraContent <dirs/files> Optional: Add specific content. Specify as local-path:absolute-container-path,local-path2:absolute-container-path2 etc
--layerOwner <gid:uid> Optional: Set specific gid and uid on files in the added layers
--buildFolder <path> Optional: Use a specific build folder when creating the image
Expand Down
12 changes: 10 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const possibleArgs = {
"--toImage <name:tag>": "Required: Image name of target image - [path/]image:tag",
"--folder <full path>": "Required: Base folder of node application (contains package.json)",
"--file <path>": "Optional: Name of configuration file (defaults to containerify.json if found on path)",
"--doCrossMount <true/false>":
"Cross mount image layers from the base image (only works if fromImage and toImage are in the same registry) (default: false)",
"--fromRegistry <registry url>":
"Optional: URL of registry to pull base image from - Default: https://registry-1.docker.io/v2/",
"--fromToken <token>": "Optional: Authentication token for from registry",
Expand Down Expand Up @@ -92,6 +94,7 @@ const defaultOptions = {
workdir: "/app",
user: "1000",
entrypoint: "npm start",
doCrossMount: false,
};

if (cliOptions.file && !fs.existsSync(cliOptions.file)) {
Expand Down Expand Up @@ -182,6 +185,11 @@ exitWithErrorIf(!!options.registry && !!options.toRegistry, "Do not set both --r
exitWithErrorIf(!!options.token && !!options.fromToken, "Do not set both --token and --fromToken");
exitWithErrorIf(!!options.token && !!options.toToken, "Do not set both --token and --toToken");

exitWithErrorIf(
!!options.doCrossMount && options.toRegistry != options.fromRegistry,
"Cross mounting only works if fromRegistry and toRegistry are the same",
);

if (options.setTimeStamp) {
try {
options.setTimeStamp = new Date(options.setTimeStamp).toISOString();
Expand Down Expand Up @@ -259,7 +267,7 @@ async function run(options: Options) {
const fromRegistry = options.fromRegistry
? createRegistry(options.fromRegistry, options.fromToken ?? "", allowInsecure)
: createDockerRegistry(allowInsecure, options.fromToken);
await fromRegistry.download(
const originalManifest = await fromRegistry.download(
options.fromImage,
fromdir,
getPreferredPlatform(options.platform),
Expand All @@ -286,7 +294,7 @@ async function run(options: Options) {
allowInsecure,
options.optimisticToRegistryCheck,
);
await toRegistry.upload(options.toImage, todir);
await toRegistry.upload(options.toImage, todir, options.doCrossMount, originalManifest, options.fromImage);
}
logger.debug("Deleting " + tmpdir + " ...");
await fse.remove(tmpdir);
Expand Down
110 changes: 94 additions & 16 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,28 @@ function prepareToken(token: string) {
return "Bearer " + token;
}

type Registry = {
download: (imageStr: string, folder: string, preferredPlatform: Platform, cacheFolder?: string) => Promise<Manifest>;
upload: (
imageStr: string,
folder: string,
doCrossMount: boolean,
originalManifest: Manifest,
originalRepository: string,
) => Promise<void>;
registryBaseUrl: string;
};

type Mount = { mount: string; from: string };
type UploadURL = { uploadUrl: string };
type UploadURLorMounted = UploadURL | { mountSuccess: true };

export function createRegistry(
registryBaseUrl: string,
token: string,
allowInsecure: InsecureRegistrySupport,
optimisticToRegistryCheck = false,
) {
): Registry {
const auth = prepareToken(token);

async function exists(image: Image, layer: Layer) {
Expand All @@ -256,9 +272,13 @@ export function createRegistry(
await uploadContent(uploadUrl, file, layer, allowInsecure, auth, "application/octet-stream");
}

async function getUploadUrl(image: Image): Promise<string> {
async function getUploadUrl(
image: Image,
mountParamters: Mount | undefined = undefined,
): Promise<UploadURLorMounted> {
return new Promise((resolve, reject) => {
const url = `${registryBaseUrl}${image.path}/blobs/uploads/`;
const parameters = new URLSearchParams(mountParamters);
const url = `${registryBaseUrl}${image.path}/blobs/uploads/${parameters.size > 0 ? "?" + parameters : ""}`;
const options: https.RequestOptions = URL.parse(url);
options.method = "POST";
options.headers = { authorization: auth };
Expand All @@ -268,14 +288,23 @@ export function createRegistry(
const { location } = res.headers;
if (location) {
if (location.startsWith("http")) {
resolve(location);
resolve({ uploadUrl: location });
} else {
const regURL = URL.parse(registryBaseUrl);

resolve(`${regURL.protocol}//${regURL.hostname}${regURL.port ? ":" + regURL.port : ""}${location}`);
resolve({
uploadUrl: `${regURL.protocol}//${regURL.hostname}${regURL.port ? ":" + regURL.port : ""}${location}`,
});
}
}
reject("Missing location for 202");
} else if (mountParamters && res.statusCode == 201) {
const returnedDigest = res.headers["docker-content-digest"];
if (returnedDigest && returnedDigest != mountParamters.mount) {
reject(
`ERROR: Layer mounted with wrong digest: Expected ${mountParamters.mount} but got ${returnedDigest}`,
);
}
resolve({ mountSuccess: true });
} else {
waitForResponseEnd(res, (data) => {
reject(
Expand Down Expand Up @@ -372,7 +401,13 @@ export function createRegistry(
return file;
}

async function upload(imageStr: string, folder: string) {
async function upload(
imageStr: string,
folder: string,
doCrossMount: boolean,
originalManifest: Manifest,
originalRepository: string,
) {
const image = parseImage(imageStr);
const manifestFile = path.join(folder, "manifest.json");
const manifest = (await fse.readJson(manifestFile)) as Manifest;
Expand All @@ -390,15 +425,33 @@ export function createRegistry(
logger.info("Uploading layers...");
await Promise.all(
layersForUpload.map(async (l) => {
const url = await getUploadUrl(image);
await uploadLayerContent(url, l.layer, folder);
if (doCrossMount && originalManifest.layers.find((x) => x.digest == l.layer.digest)) {
const mount = await getUploadUrl(image, { mount: l.layer.digest, from: originalRepository });
if ("mountSuccess" in mount) {
logger.info(`Cross mounted layer ${l.layer.digest} from '${originalRepository}'`);
return;
}
await uploadLayerContent(mount.uploadUrl, l.layer, folder);
} else {
const url = await getUploadUrl(image);
if ("mountSuccess" in url) throw new Error("Mounting not supported for this upload");
await uploadLayerContent(url.uploadUrl, l.layer, folder);
}
}),
);

logger.info("Uploading config...");
const configUploadUrl = await getUploadUrl(image);
if ("mountSuccess" in configUploadUrl) throw new Error("Mounting not supported for config upload");
const configFile = path.join(folder, getHash(manifest.config.digest) + ".json");
await uploadContent(configUploadUrl, configFile, manifest.config, allowInsecure, auth, "application/octet-stream");
await uploadContent(
configUploadUrl.uploadUrl,
configFile,
manifest.config,
allowInsecure,
auth,
"application/octet-stream",
);

logger.info("Uploading manifest...");
const manifestSize = await fileutil.sizeOf(manifestFile);
Expand All @@ -412,7 +465,12 @@ export function createRegistry(
);
}

async function download(imageStr: string, folder: string, preferredPlatform: Platform, cacheFolder?: string) {
async function download(
imageStr: string,
folder: string,
preferredPlatform: Platform,
cacheFolder?: string,
): Promise<Manifest> {
const image = parseImage(imageStr);

logger.info("Downloading manifest...");
Expand All @@ -437,15 +495,17 @@ export function createRegistry(
await Promise.all(manifest.layers.map((layer) => dlLayer(image, layer, folder, allowInsecure, cacheFolder)));

logger.info("Image downloaded.");
return manifest;
}

return {
download: download,
upload: upload,
registryBaseUrl,
};
}

export function createDockerRegistry(allowInsecure: InsecureRegistrySupport, auth?: string) {
export function createDockerRegistry(allowInsecure: InsecureRegistrySupport, auth?: string): Registry {
const registryBaseUrl = "https://registry-1.docker.io/v2/";

async function getToken(image: Image) {
Expand All @@ -457,19 +517,37 @@ export function createDockerRegistry(allowInsecure: InsecureRegistrySupport, aut
return resp.token;
}

async function download(imageStr: string, folder: string, platform: Platform, cacheFolder?: string) {
async function download(
imageStr: string,
folder: string,
platform: Platform,
cacheFolder?: string,
): Promise<Manifest> {
const image = parseImage(imageStr);
if (!auth) auth = await getToken(image);
await createRegistry(registryBaseUrl, auth, allowInsecure).download(imageStr, folder, platform, cacheFolder);
return await createRegistry(registryBaseUrl, auth, allowInsecure).download(imageStr, folder, platform, cacheFolder);
}

async function upload(imageStr: string, folder: string) {
async function upload(
imageStr: string,
folder: string,
doCrossMount: boolean,
originalManifest: Manifest,
originalRepository: string,
) {
if (!auth) throw new Error("Need auth token to upload to Docker");
await createRegistry(registryBaseUrl, auth, allowInsecure).upload(imageStr, folder);
await createRegistry(registryBaseUrl, auth, allowInsecure).upload(
imageStr,
folder,
doCrossMount,
originalManifest,
originalRepository,
);
}

return {
download: download,
upload: upload,
registryBaseUrl,
};
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export type Options = {
fromRegistry?: string;
fromToken?: string;
toRegistry?: string;
doCrossMount: boolean;
optimisticToRegistryCheck?: boolean;
toToken?: string;
toTar?: string;
Expand Down
2 changes: 1 addition & 1 deletion tests/localtest/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ docker tag node:alpine localhost:5443/node > /dev/null
docker push localhost:5443/node > /dev/null

printf "* Running containerify to pull from and push result to the local containerify test registry...\n"
../../lib/cli.js --fromImage node --registry https://localhost:5443/v2/ --toImage containerify-integration-test:localtest --folder ../integration/app --setTimeStamp "2023-03-07T12:53:10.471Z" --allowInsecureRegistries --token "Basic $BASICAUTH"
../../lib/cli.js --fromImage node --doCrossMount true --registry https://localhost:5443/v2/ --toImage containerify-integration-test:localtest --folder ../integration/app --setTimeStamp "2023-03-07T12:53:10.471Z" --allowInsecureRegistries --token "Basic $BASICAUTH"


printf "\n* Pulling image from registry to local docker daemon...\n"
Expand Down

0 comments on commit 31a11b0

Please sign in to comment.