Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for cross repository blob mount #35

Merged
merged 3 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = {
eoftedal marked this conversation as resolved.
Show resolved Hide resolved
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,
eoftedal marked this conversation as resolved.
Show resolved Hide resolved
): 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"
eoftedal marked this conversation as resolved.
Show resolved Hide resolved


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