diff --git a/cmd/coreos-assembler.go b/cmd/coreos-assembler.go index 0bc23a829e136fdb5ced65f6c46c8891f7d84949..ac973c030d82a59852aa2d256f1d3156355f926b 100644 --- a/cmd/coreos-assembler.go +++ b/cmd/coreos-assembler.go @@ -14,8 +14,8 @@ import ( // commands we'd expect to use in the local dev path var buildCommands = []string{"init", "fetch", "build", "run", "prune", "clean", "list"} var advancedBuildCommands = []string{"buildfetch", "buildupload", "oc-adm-release", "push-container", "upload-oscontainer", "buildextend-extensions"} -var buildextendCommands = []string{"aliyun", "aws", "azure", "digitalocean", "exoscale", extensions-container, "gcp", "hashlist-experimental", "ibmcloud", "kubevirt", "legacy-oscontainer", "live", "metal", "metal4k", "nutanix", "openstack", "qemu", "secex", "virtualbox", "vmware", "vultr"} -var utilityCommands = []string{"aws-replicate", "compress", "koji-upload", "kola", "remote-build-container", "remote-prune", "remote-session", "sign", "tag", "update-variant"} +var buildextendCommands = []string{"aliyun", "aws", "azure", "digitalocean", "exoscale", "extensions-container", "gcp", "hashlist-experimental", "ibmcloud", "kubevirt", "legacy-oscontainer", "live", "metal", "metal4k", "nutanix", "openstack", "qemu", "secex", "virtualbox", "vmware", "vultr"} +var utilityCommands = []string{"aws-replicate", "compress", "koji-upload", "kola", "push-container-manifest", "remote-build-container", "remote-prune", "remote-session", "sign", "tag", "update-variant"} var otherCommands = []string{"shell", "meta"} func init() { diff --git a/src/cmd-push-container-manifest b/src/cmd-push-container-manifest new file mode 100755 index 0000000000000000000000000000000000000000..da5dd731ca8d96e1841cdbbc70402b4e6cc17dc8 --- /dev/null +++ b/src/cmd-push-container-manifest @@ -0,0 +1,187 @@ +#!/usr/bin/python3 + +# Push a container manifest (i.e. multi-arch) to a container registry based on +# arguments provided by the user. + +import argparse +import json +import os +import sys +from cosalib.container_manifest import create_and_push_container_manifest +from cosalib.builds import Builds +from cosalib.meta import GenericBuildMeta +from cosalib.cmdlib import runcmd +from cosalib.cmdlib import sha256sum_file + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def main(): + args = parse_args() + map_arch = {} + map_arch['arm64'] = 'aarch64' + map_arch['amd64'] = 'x86_64' + allow_missing_arches = False + + if args.authfile: + os.environ["REGISTRY_AUTH_FILE"] = args.authfile + if args.images: + # User provided images directly + create_and_push_container_manifest( + args.repo, args.tags, args.images, args.v2s2) + else: + # Picking up images from artifacts in meta.json + builds = Builds() + if args.build == 'latest': + args.build = builds.get_latest() + print(f"Targeting build: {args.build}") + build_arches = builds.get_build_arches(args.build) + if not args.arches: + # If the user didn't specify which arches to push then we'll default + # to all available and we won't error if some are missing. This can + # happen if there is an artifact that is only built for a subset of + # arches (i.e. kubevirt). + args.arches = build_arches + allow_missing_arches = True + + # Iterate over the requested architectures and: + # - Make sure the container images exist and are on disk + # - Store the buildmeta for the build/arch in the buildmetas dict + # - Store the path to the container image in the container_images list + images = [] + buildmetas = dict() + registry_digests = {} + upload = False + # Collect registry_digests of current manifest list in remote registry + inspect = skopeo_inspect(f'{args.repo}:{args.tags[0]}', args.authfile) + if inspect.returncode == 0: + manifests = json.loads(inspect.stdout) + for manifest in manifests['manifests']: + arch = manifest['platform']['architecture'] + if arch in map_arch: + arch = map_arch[arch] + registry_digests[arch] = manifest['digest'] + + for arch in args.arches: + if arch not in build_arches: + print(f"Requested architecture {arch} is not in {args.build}") + raise Exception + builddir = builds.get_build_dir(build_id=args.build, basearch=arch) + buildmeta = GenericBuildMeta(build=args.build, basearch=arch, + workdir=os.path.abspath(os.getcwd())) + if not buildmeta['images'].get(args.artifact): + print(f"No artifact {args.artifact} in {args.build}/{arch}") + if allow_missing_arches: + continue + else: + raise Exception + buildmetas[arch] = buildmeta + + # Checks if the meta digest matches each arch digest in the remote. + # If it doesn't match (or doesn't exist), we need to upload. + if buildmetas[arch].get(args.metajsonname): + meta_digest = buildmetas[arch][args.metajsonname]['digest'] + if meta_digest != registry_digests.get(arch): + upload = True + else: + # If there is no entry in the meta.json yet then we know + # we need to upload. + upload = True + + ociarchive = os.path.join(builddir, buildmeta['images'][args.artifact]['path']) + ocisha256sum = buildmeta['images'][args.artifact]['sha256'] + if not os.path.exists(ociarchive): + print(f"The file does not exist on disk: {ociarchive}") + raise Exception + if sha256sum_file(ociarchive) != ocisha256sum: + print(f"The file on disk {ociarchive} has an incorrect checksum") + raise Exception + images.append(f"oci-archive:{ociarchive}") + + if not upload and not args.force: + print("Remote already matches desired state; skipping push. Use --force to override.") + return + + # Create/Upload the manifest list to the container registry + manifest_info = create_and_push_container_manifest( + args.repo, args.tags, images, args.v2s2) + # if we pushed in v2s2 mode, we need to reload from the repo the actual + # final digests: https://github.com/containers/podman/issues/16603 + if args.v2s2: + inspect = skopeo_inspect(f'{args.repo}:{args.tags[0]}', args.authfile) + if inspect.returncode != 0: + print(f"Can't inspect {args.repo}:{args.tags[0]} even though we just pushed it?") + raise Exception + manifest_info = json.loads(inspect.stdout) + + # Update the `meta.json` files. Note the digest included is the + # arch-specific one for each individual arch, and not the manifest list + # digest. See: https://github.com/coreos/coreos-assembler/issues/3122. + assert len(manifest_info['manifests']) == len(buildmetas) + for manifest in manifest_info['manifests']: + arch = manifest['platform']['architecture'] + if arch in map_arch: + arch = map_arch[arch] + buildmetas[arch][args.metajsonname] = { + 'image': args.repo, + 'digest': manifest['digest'], + 'tags': args.tags + } + buildmetas[arch].write(artifact_name=args.metajsonname) + + +def parse_args(): + parser = argparse.ArgumentParser( + prog="NestOS Assembler Push Container Manifest", + description="Create and push a container manifest to a registry", + usage=""" +Examples: + export REGISTRY_AUTH_FILE=/path/to/auth.json + nosa push-container-manifest \\ + --repo hub.oepkgs.net/nestos/nestos-assembler --tag latest \\ + --image docker://hub.oepkgs.net/nestos/nestos-assembler:x86_64-4aafd3 \\ + --image docker://hub.oepkgs.net/nestos/nestos-assembler:aarch64-4aafd3 + + nosa push-container-manifest \\ + --repo hub.oepkgs.net/nestos/nestos --tag stable \\ + --image oci-archive://builds/22.03-LTS-SP3.20240412.0/x86_64/nestos-22.03-LTS-SP3.20240412.0-ostree.x86_64.ociarchive \\ + --image oci-archive://builds/22.03-LTS-SP3.20240412.0/aarch64/nestos-22.03-LTS-SP3.20240412.0-ostree.aarch64.ociarchive \\ + + nosa push-container-manifest \\ + --repo hub.oepkgs.net/nestos/nestos --tag stable --artifact=ostree \\ + --metajsonname=base-oscontainer --build=latest --arch=x86_64 --arch=aarch64""") + parser.add_argument("--repo", required=True, help="The registry repo to target for the manifest") + parser.add_argument("--tag", required=True, dest='tags', action='append', + help="The tag of the manifest to use") + parser.add_argument("--authfile", help="A file to use for registry auth") + parser.add_argument('--v2s2', action='store_true', + help='Use old image manifest version 2 schema 2 format') + parser.add_argument("--force", help="Force manifest overwriting", action='store_true') + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--image", dest='images', action='append', default=[], + help="""The images to add to the manifest. Can be specified multiple times like + --image docker://hub.oepkgs.net/nestos/nestos-assembler:x86_64-4aafd3 + --image oci-archive://path/to/nestos-22.03-LTS-SP3.20240412.0-ostree.x86_64.ociarchive""") + group.add_argument("--artifact", help="""The artifact""") + + # A few more arguments that are used for `--artifact` + parser.add_argument("--build", default="latest", help="Build ID") + parser.add_argument("--arch", dest='arches', action='append', default=[], + help="""Limit the architectures to upload to the specificed set + (otherwise it defaults to all available for that build). Can be + specified multiple times like: --arch x86_64 --arch aarch64""") + parser.add_argument("--metajsonname", + help="The name under which to store the container information in meta.json") + return parser.parse_args() + + +def skopeo_inspect(fqin, authfile): + args = ['skopeo', 'inspect', '--raw'] + if authfile: + args += ['--authfile', authfile] + return runcmd((args + [f'docker://{fqin}']), capture_output=True, check=False) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/cosalib/container_manifest.py b/src/cosalib/container_manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..56538174ca55f13630372e4c795cce0651fa7c74 --- /dev/null +++ b/src/cosalib/container_manifest.py @@ -0,0 +1,62 @@ +import json + +from cosalib.cmdlib import runcmd + + +def create_local_container_manifest(repo, tag, images) -> dict: + ''' + Create local manifest list and return the final manifest JSON + @param images list of image specifications (including transport) + @param repo str registry repository + @param tag str manifest tag + ''' + cmd = ["podman", "manifest", "create", f"{repo}:{tag}"] + runcmd(cmd) + for image in images: + cmd = ["podman", "manifest", "add", f"{repo}:{tag}", image] + runcmd(cmd) + manifest_info = runcmd(["podman", "manifest", "inspect", f"{repo}:{tag}"], + capture_output=True).stdout + return json.loads(manifest_info) + + +def delete_local_container_manifest(repo, tag): + ''' + Delete local manifest list + @param repo str registry repository + @param tag str manifest tag + ''' + cmd = ["podman", "manifest", "rm", f"{repo}:{tag}"] + runcmd(cmd) + + +def push_container_manifest(repo, tags, v2s2=False): + ''' + Push manifest to registry + @param repo str registry repository + @param tags list of tags to push + @param v2s2 boolean use to force v2s2 format + ''' + base_cmd = ["podman", "manifest", "push", "--all", f"{repo}:{tags[0]}"] + if v2s2: + # `--remove-signatures -f v2s2` is a workaround for when you try + # to create a manifest with 2 different mediaType. It seems to be + # a Quay issue. + base_cmd.extend(["--remove-signatures", "-f", "v2s2"]) + runcmd(base_cmd + [f"{repo}:{tags[0]}"]) + for tag in tags[1:]: + runcmd(base_cmd + [f"{repo}:{tag}"]) + + +def create_and_push_container_manifest(repo, tags, images, v2s2) -> dict: + ''' + Do it all! Create, push, cleanup, and return the final manifest JSON. + @param repo str registry repository + @param tags list of tags + @param images list of image specifications (including transport) + @param v2s2 boolean use to force v2s2 format + ''' + manifest_info = create_local_container_manifest(repo, tags[0], images) + push_container_manifest(repo, tags, v2s2) + delete_local_container_manifest(repo, tags[0]) + return manifest_info