diff --git a/Makefile b/Makefile index 075da0d27..2aa19a2ac 100644 --- a/Makefile +++ b/Makefile @@ -229,7 +229,7 @@ check-dependency-age: test-dependency-age-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_dependency_age.py --min-age-days $(DEPENDENCY_MIN_AGE_DAYS) .PHONY: check -check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age +check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age test-release-bundle # ============================================================================ # Git hooks @@ -566,3 +566,7 @@ e2e-tracing: @echo " (requires: KIND_JAEGER=true make kind-up)" @echo "" @pnpm --filter @openshift-online/hypershell-web-console test:e2e:live + +.PHONY: test-release-bundle +test-release-bundle: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_release_bundle.py diff --git a/pipelines/release-bundle/README.md b/pipelines/release-bundle/README.md new file mode 100644 index 000000000..97c9cb0d7 --- /dev/null +++ b/pipelines/release-bundle/README.md @@ -0,0 +1,119 @@ +# Release bundle + +This pipeline publishes the three images from a successful Konflux managed +release as one OCI artifact. It uses the existing API server build repository: + +```text +quay.io/redhat-user-workloads/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main +``` + +The tags start with `release-bundle-`. The artifact contains `bundle.json` with +media type `application/vnd.hypershell.release.v1+json`. Each component has its +released image reference, with a SHA-256 digest, and its source Git revision. +The component images are in `quay.io/redhat-services-prod`. + +The bundle is a release record. It is not a workload image or a Tekton task +bundle. Consumers must select the bundle tags and download the JSON layer. + +## Release checks + +The publisher requires all of these conditions: + +- The Release uses `hypershell-releaseplan` in `hcm-eng-prod-tenant`. +- The managed pipeline has status `True` and reason `Succeeded`. +- The Snapshot and release artifacts contain exactly the three expected components. +- Each released digest matches the Snapshot and is available in the release repository. +- Each source revision belongs to the history of `hypershell` main. +- Any event-type metadata identifies a push event. + +The final pipeline can run after a failed managed pipeline. It must check +`ManagedPipelineProcessed`; `Released` is not complete until the final pipeline +finishes. A failed check stops publication. + +Konflux can keep an earlier image for an unchanged component. The bundle keeps +all three source revisions. A Snapshot can also contain an earlier image while +another component build is still running. This pipeline preserves the accepted +Snapshot; it does not add a test that waits for all builds from one commit. + +The tag uses the Snapshot creation time and a hash of the Release UID. The OCI +creation time also uses the Snapshot time. A retry of an old release does not +receive a new creation time. Retries of the same Release produce the same content +and digest. Consumers must retain and use the bundle digest. + +## Enable the pipeline through a merge request + +Merge the source PR first. In `releng/konflux-release-data`, edit: + +```text +tenants-config/cluster/stone-prd-rh01/tenants/hcm-eng-prod-tenant/hypershell/appstudio.redhat.com.releaseplan.yaml +``` + +Set `spec.finalPipeline` to this pipeline through the Git resolver. Use +`https://github.com/openshift-online/hypershell.git`, revision `main`, and +`pipelines/release-bundle/pipeline.yaml`. Set `useEmptyDir: true` and +`serviceAccountName: build-pipeline-hypershell-api-server-main`. + +Each new run resolves the pipeline from `main`. The publisher reads the resolved +commit from `status.provenance.refSource.digest.sha1` on its PipelineRun and +fetches the Python script from that commit. A change to `main` during the run +cannot change the script version. Missing provenance stops publication. + +Bind this service account to the approved `konflux-viewer-bot-actions` ClusterRole +in the tenant namespace. The config repository rejects custom roles. The viewer +role permits reads of Releases, Snapshots, and other Konflux resources. It does +not grant writes. The publisher uses only `get` on Releases, Snapshots, and its +PipelineRun. Verify these reads in the first cluster run. + +The existing build account already has Quay write access. Reuse its credential +through Tekton credential initialization and the `select-oci-auth` helper. No new +Quay token or repository is required. Do not put a token in Git. + +The first run verifies the actual namespace permissions and registry credential. +If either is missing, the run fails and publishes no bundle. The source PR alone +does not enable the pipeline. + +## Kargo consumer + +Use one image subscription for the bundle repository. For example: + +```yaml +spec: + subscriptions: + - image: + repoURL: quay.io/redhat-user-workloads/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main + imageSelectionStrategy: Lexical + allowTagsRegexes: + - '^release-bundle-[0-9]{8}T[0-9]{12}Z-[0-9a-f]{16}$' +``` + +A promotion must download the selected Freight digest with `oci-download` and +select media type `application/vnd.hypershell.release.v1+json`. It can then read +the component references from the JSON and commit the three image digest changes +to `hypershell-gitops`. Argo CD in the destination cluster pulls that commit. + +Kargo selects the latest eligible tag at each poll. It does not guarantee a +separate deployment for every intermediate release. A later deployment step must +also prevent an older Snapshot from replacing a newer deployed image set. + +## First cluster test + +1. Merge the source PR and then the tenant config MR. +2. Let a main component build complete and pass the configured release checks. +3. Check the final PipelineRun. Its `bundle` result must contain an OCI digest. +4. Download that digest and check the three component digests and source revisions. +5. Configure Kargo to discover that artifact before enabling automatic promotion. + +No cluster login is required to submit these changes. The first Konflux run is +still required to prove the live credentials. The local checks cannot prove them. + +## Local checks + +Run `make test-release-bundle` and `make check`. + +References: + +- [Konflux tenant and final pipelines](https://konflux-ci.dev/docs/releasing/tenant-release-pipelines/) +- [Konflux Snapshots](https://konflux-ci.dev/docs/testing/integration/snapshots/) +- [Tekton credentials](https://tekton.dev/docs/pipelines/auth/) +- [Kargo Warehouses](https://docs.kargo.io/user-guide/how-to-guides/working-with-warehouses) +- [Kargo OCI download](https://docs.kargo.io/user-guide/reference-docs/promotion-steps/oci-download) diff --git a/pipelines/release-bundle/pipeline.yaml b/pipelines/release-bundle/pipeline.yaml new file mode 100644 index 000000000..8dc164a33 --- /dev/null +++ b/pipelines/release-bundle/pipeline.yaml @@ -0,0 +1,96 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: hypershell-release-bundle +spec: + description: Publish a bundle after the managed release succeeds. + params: + - name: release + type: string + - name: releasePlan + type: string + - name: snapshot + type: string + - name: taskGitUrl + type: string + - name: taskGitRevision + type: string + workspaces: + - name: release-workspace + results: + - name: bundle + description: The bundle image reference with its digest. + value: $(tasks.publish.results.bundle) + tasks: + - name: publish + params: + - name: release + value: $(params.release) + - name: snapshot + value: $(params.snapshot) + - name: pipeline-run + value: $(context.pipelineRun.name) + - name: pipeline-namespace + value: $(context.pipelineRun.namespace) + taskSpec: + params: + - name: release + type: string + - name: snapshot + type: string + - name: pipeline-run + type: string + - name: pipeline-namespace + type: string + results: + - name: bundle + type: string + steps: + - name: publish + image: quay.io/konflux-ci/release-service-utils@sha256:3cb03b14ac9d90ff27070036ce2b50712e65aa285daeb28852254a745bb25dfc + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + computeResources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + env: + - name: RELEASE + value: $(params.release) + - name: SNAPSHOT + value: $(params.snapshot) + - name: PIPELINE_RUN + value: $(params.pipeline-run) + - name: PIPELINE_NAMESPACE + value: $(params.pipeline-namespace) + - name: BUNDLE_RESULT + value: $(results.bundle.path) + script: | + #!/usr/bin/env bash + set -euo pipefail + # Use the commit that Tekton resolved, even if main has since changed. + pipeline_revision=$(kubectl get pipelineruns.tekton.dev "$PIPELINE_RUN" \ + -n "$PIPELINE_NAMESPACE" \ + -o jsonpath='{.status.provenance.refSource.digest.sha1}') + if [[ ! "$pipeline_revision" =~ ^[0-9a-f]{40}$ ]]; then + echo 'The PipelineRun has no valid resolved Git commit.' >&2 + exit 1 + fi + source_dir=$(mktemp -d) + trap 'rm -rf "$source_dir"' EXIT + git -C "$source_dir" init --quiet + git -C "$source_dir" remote add origin https://github.com/openshift-online/hypershell.git + git -C "$source_dir" fetch --quiet --filter=blob:none origin \ + main:refs/remotes/origin/main "$pipeline_revision" + git -C "$source_dir" checkout --quiet "$pipeline_revision" -- scripts/release_bundle.py + python3 "$source_dir/scripts/release_bundle.py" \ + --release "$RELEASE" --snapshot "$SNAPSHOT" \ + --source-directory "$source_dir" --result-path "$BUNDLE_RESULT" diff --git a/scripts/release_bundle.py b/scripts/release_bundle.py new file mode 100644 index 000000000..2a3f94d5e --- /dev/null +++ b/scripts/release_bundle.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Publish the image set from a successful Konflux managed release.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tempfile + +APPLICATION = "hypershell-main" +NAMESPACE = "hcm-eng-prod-tenant" +SOURCE_URL = "https://github.com/openshift-online/hypershell" +BUILD_PREFIX = f"quay.io/redhat-user-workloads/{NAMESPACE}/{APPLICATION}/" +RELEASE_PREFIX = f"quay.io/redhat-services-prod/{NAMESPACE}/{APPLICATION}/" +COMPONENTS = ( + "hypershell-api-server-main", + "hypershell-control-plane-main", + "hypershell-web-console-main", +) +BUNDLE_REPOSITORY = BUILD_PREFIX + COMPONENTS[0] +MEDIA_TYPE = "application/vnd.hypershell.release.v1+json" + + +def require(value, message): + if not value: + raise ValueError(message) + + +def indexed(items): + result = {item["name"]: item for item in items} + require(len(result) == len(items), "Duplicate component names") + require(set(result) == set(COMPONENTS), "The release must contain all three components") + return result + + +def make_bundle(release, snapshot): + """Reject incomplete or unsuccessful releases before registry operations.""" + metadata = release["metadata"] + require(metadata["namespace"] == NAMESPACE, "Unexpected release namespace") + require(snapshot["metadata"]["namespace"] == NAMESPACE, "Unexpected snapshot namespace") + require(release["spec"]["releasePlan"] == "hypershell-releaseplan", "Unexpected release plan") + require(release["spec"]["snapshot"] == snapshot["metadata"]["name"], "Snapshot mismatch") + require(snapshot["spec"]["application"] == APPLICATION, "Unexpected application") + conditions = {c["type"]: c for c in release.get("status", {}).get("conditions", [])} + managed = conditions.get("ManagedPipelineProcessed", {}) + require(managed.get("status") == "True" and managed.get("reason") == "Succeeded", + "The managed release did not succeed") + for obj in (release, snapshot): + for field in ("annotations", "labels"): + for key, value in obj["metadata"].get(field, {}).items(): + if key.endswith("/event-type"): + require(value == "push", "Only push snapshots can produce bundles") + require(not key.endswith("/pull-request"), "Pull request snapshots cannot produce bundles") + components = indexed(snapshot["spec"]["components"]) + images = indexed(release["status"].get("artifacts", {}).get("images", [])) + output = [] + for name in COMPONENTS: + component, image = components[name], images[name] + digest = image["shasum"] + require(re.fullmatch(r"sha256:[0-9a-f]{64}", digest), "Invalid image digest") + require(component["containerImage"] == BUILD_PREFIX + name + "@" + digest, + "Released digest does not match the snapshot") + repository = RELEASE_PREFIX + name + require(any(url.startswith(repository + ":") for url in image["urls"]), + "The release has no expected destination repository") + source = component["source"]["git"] + require(source["url"].removesuffix(".git") == SOURCE_URL, "Unexpected source repository") + require(re.fullmatch(r"[0-9a-f]{40}", source["revision"]), "Invalid source revision") + output.append({"name": name, "image": repository + "@" + digest, + "source": {"git": {"url": SOURCE_URL, "revision": source["revision"]}}}) + created = snapshot["metadata"]["creationTimestamp"] + stamp = datetime.fromisoformat(created.replace("Z", "+00:00")) + require(stamp.utcoffset() is not None, "Snapshot time must include a timezone") + stamp = stamp.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + identity = hashlib.sha256(metadata["uid"].encode()).hexdigest()[:16] + tag = f"release-bundle-{stamp}-{identity}" + return tag, { + "schemaVersion": 1, + "application": APPLICATION, + "created": created, + "release": {"namespace": NAMESPACE, "name": metadata["name"], "uid": metadata["uid"]}, + "snapshot": {"name": snapshot["metadata"]["name"], "uid": snapshot["metadata"]["uid"]}, + "components": output, + } + + +def run(*args, **kwargs): + return subprocess.check_output(args, text=True, **kwargs).strip() + + +def resource(kind, reference): + namespace, name = reference.split("/") + require(namespace == NAMESPACE, "Unexpected resource namespace") + require(re.fullmatch(r"[a-z0-9][a-z0-9.-]*", name), "Invalid resource name") + return json.loads(run("kubectl", "get", kind, name, "-n", namespace, "-o", "json")) + + +def publish(release, snapshot, source_directory, result_path): + tag, bundle = make_bundle(release, snapshot) + for component in bundle["components"]: + # The pipeline fetches main from the fixed public source repository. + run("git", "merge-base", "--is-ancestor", component["source"]["git"]["revision"], + "refs/remotes/origin/main", cwd=source_directory) + require(run("oras", "resolve", component["image"]) == component["image"].split("@")[1], + "Released image is not available by digest") + # Konflux credentials can be scoped to a repository. ORAS needs a host entry. + credentials = json.loads(run("select-oci-auth", BUNDLE_REPOSITORY)) + require(credentials.get("auths", {}).get("quay.io"), + "The build service account has no registry credentials") + target = BUNDLE_REPOSITORY + ":" + tag + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + auth = root / "auth.json" + auth.touch(mode=0o600) + auth.write_text(json.dumps(credentials)) + (root / "bundle.json").write_text(json.dumps(bundle, sort_keys=True) + "\n") + (root / "config.json").write_text(json.dumps({ + "created": bundle["created"], "architecture": "amd64", "os": "linux", + "config": {}, "rootfs": {"type": "layers", "diff_ids": []}, + }, sort_keys=True) + "\n") + # A fixed creation time makes retries of the same Release reproducible. + run("oras", "push", "--registry-config", str(auth), "--image-spec", "v1.0", + "--annotation", "org.opencontainers.image.created=" + bundle["created"], + "--config", "config.json:application/vnd.oci.image.config.v1+json", + "--export-manifest", "manifest.json", target, "bundle.json:" + MEDIA_TYPE, + cwd=root) + digest = "sha256:" + hashlib.sha256((root / "manifest.json").read_bytes()).hexdigest() + require(run("oras", "resolve", "--registry-config", str(auth), target) == digest, + "Published bundle digest mismatch") + Path(result_path).write_text(BUNDLE_REPOSITORY + "@" + digest) + print("Published " + target + "@" + digest) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release", required=True) + parser.add_argument("--snapshot", required=True) + parser.add_argument("--source-directory", required=True) + parser.add_argument("--result-path", required=True) + args = parser.parse_args() + publish(resource("releases.appstudio.redhat.com", args.release), + resource("snapshots.appstudio.redhat.com", args.snapshot), + args.source_directory, args.result_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_release_bundle.py b/scripts/test_release_bundle.py new file mode 100644 index 000000000..73ad4068f --- /dev/null +++ b/scripts/test_release_bundle.py @@ -0,0 +1,150 @@ +"""Check release gates and the bundle contents without cluster access.""" + +import copy +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +from scripts import release_bundle as bundle + + +def fixtures(): + components, images = [], [] + for index, name in enumerate(bundle.COMPONENTS): + digest = "sha256:" + str(index + 1) * 64 + components.append({"name": name, "containerImage": bundle.BUILD_PREFIX + name + "@" + digest, + "source": {"git": {"url": bundle.SOURCE_URL, "revision": str(index + 1) * 40}}}) + images.append({"name": name, "shasum": digest, + "urls": [bundle.RELEASE_PREFIX + name + ":latest"]}) + snapshot = {"metadata": {"name": "snapshot-one", "namespace": bundle.NAMESPACE, + "uid": "snapshot-uid", "creationTimestamp": "2026-09-15T12:00:00Z"}, + "spec": {"application": bundle.APPLICATION, "components": components}} + release = {"metadata": {"name": "release-one", "namespace": bundle.NAMESPACE, "uid": "release-uid"}, + "spec": {"snapshot": "snapshot-one", "releasePlan": "hypershell-releaseplan"}, + "status": {"conditions": [{"type": "ManagedPipelineProcessed", "status": "True", + "reason": "Succeeded"}], "artifacts": {"images": images}}} + return release, snapshot + + +class ReleaseBundleTests(unittest.TestCase): + def test_preserves_each_component_revision_and_released_digest(self): + release, snapshot = fixtures() + tag, result = bundle.make_bundle(release, snapshot) + self.assertTrue(tag.startswith("release-bundle-20260915T120000000000Z-")) + for item, source in zip(result["components"], snapshot["spec"]["components"]): + self.assertEqual(item["source"], source["source"]) + self.assertEqual(item["image"], source["containerImage"].replace(bundle.BUILD_PREFIX, bundle.RELEASE_PREFIX)) + self.assertEqual((tag, result), bundle.make_bundle(release, snapshot)) + + def test_failed_pending_and_skipped_releases_cannot_reach_registry(self): + for status, reason in (("False", "Failed"), ("Unknown", "Progressing"), ("True", "Skipped")): + release, snapshot = fixtures() + release["status"]["conditions"][0].update(status=status, reason=reason) + with self.subTest(status=status, reason=reason), patch.object(bundle, "run") as run: + with self.assertRaises(ValueError): + bundle.publish(release, snapshot, ".", "/unused") + run.assert_not_called() + + def test_missing_or_duplicate_components_fail(self): + for location in ("snapshot", "release"): + for duplicate in (False, True): + release, snapshot = fixtures() + items = snapshot["spec"]["components"] if location == "snapshot" else release["status"]["artifacts"]["images"] + if duplicate: + items.append(copy.deepcopy(items[0])) + else: + items.pop() + with self.subTest(location=location, duplicate=duplicate), self.assertRaises(ValueError): + bundle.make_bundle(release, snapshot) + + def test_wrong_image_or_source_fails(self): + mutations = ( + lambda r, s: r["status"]["artifacts"]["images"][0].update(shasum="sha256:" + "a" * 64), + lambda r, s: r["status"]["artifacts"]["images"][0].update(urls=["quay.io/other/repo:latest"]), + lambda r, s: s["spec"]["components"][0]["source"]["git"].update(revision="main"), + lambda r, s: s["spec"]["components"][0]["source"]["git"].update(url="https://example.com/repo"), + lambda r, s: r["spec"].update(snapshot="other"), + lambda r, s: s["spec"].update(application="other"), + ) + for mutate in mutations: + release, snapshot = fixtures() + mutate(release, snapshot) + with self.assertRaises(ValueError): + bundle.make_bundle(release, snapshot) + + def test_pull_request_and_merge_queue_events_fail(self): + for field in ("annotations", "labels"): + for event in ("pull_request", "merge_group"): + release, snapshot = fixtures() + snapshot["metadata"][field] = {"pac.test.appstudio.openshift.io/event-type": event} + with self.subTest(field=field, event=event), self.assertRaises(ValueError): + bundle.make_bundle(release, snapshot) + + def test_old_snapshot_does_not_get_a_new_timestamp_on_retry(self): + release, snapshot = fixtures() + first, _ = bundle.make_bundle(release, snapshot) + release["metadata"]["uid"] = "retry-uid" + retry, _ = bundle.make_bundle(release, snapshot) + self.assertEqual(first.split("-")[2], retry.split("-")[2]) + snapshot["metadata"]["creationTimestamp"] = "2026-09-16T12:00:00Z" + newer, _ = bundle.make_bundle(release, snapshot) + self.assertGreater(newer, retry) + + +class PipelineBootstrapTests(unittest.TestCase): + def run_bootstrap(self, revision, read_status=0): + pipeline = Path(__file__).resolve().parents[1] / "pipelines/release-bundle/pipeline.yaml" + script = textwrap.dedent(pipeline.read_text().split(" script: |\n", 1)[1]) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + commands = root / "commands" + stubs = { + "kubectl": 'printf "%s\\n" "$*" >> "$COMMAND_LOG"\n' + 'printf "%s" "$RESOLVED_REVISION"\nexit "$READ_STATUS"\n', + "git": 'printf "git %s\\n" "$*" >> "$COMMAND_LOG"\n', + "python3": 'printf "publisher started\\n" >> "$COMMAND_LOG"\n', + } + for name, body in stubs.items(): + executable = root / name + executable.write_text("#!/bin/sh\n" + body) + executable.chmod(0o755) + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ["PATH"], + COMMAND_LOG=str(commands), RESOLVED_REVISION=revision, + READ_STATUS=str(read_status), PIPELINE_RUN="final-one", + PIPELINE_NAMESPACE=bundle.NAMESPACE, RELEASE="release-one", + SNAPSHOT="snapshot-one", BUNDLE_RESULT=str(root / "result")) + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True) + return result, commands.read_text() + + def test_script_uses_pipeline_commit_instead_of_main(self): + revision = "a" * 40 + result, commands = self.run_bootstrap(revision) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("get pipelineruns.tekton.dev final-one -n " + bundle.NAMESPACE, commands) + self.assertIn("{.status.provenance.refSource.digest.sha1}", commands) + self.assertIn("main:refs/remotes/origin/main " + revision, commands) + self.assertIn("checkout --quiet " + revision + " -- scripts/release_bundle.py", commands) + self.assertIn("publisher started", commands) + + def test_missing_or_invalid_commit_stops_before_fetch(self): + for revision in ("", "main", "a" * 39, "$(touch /tmp/unexpected)"): + with self.subTest(revision=revision): + result, commands = self.run_bootstrap(revision) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no valid resolved Git commit", result.stderr) + self.assertNotIn("git ", commands) + self.assertNotIn("publisher started", commands) + + def test_pipeline_read_failure_stops_publication(self): + result, commands = self.run_bootstrap("a" * 40, read_status=1) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("git ", commands) + self.assertNotIn("publisher started", commands) + + +if __name__ == "__main__": + unittest.main()