Repository navigation
feat(release): publish Konflux release bundles #291
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
Changes from all commits
9a92a97
1f801dd
ae1f469
2943363
05a8b4c
afdbe4a
ea9088b
29d0027
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor] Defense in depth The The surrounding gates ( |
||
| 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() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Minor] Container Security
This publish step defines no explicit
securityContext. HyperShell's convention is a restricted SecurityContext on all containers (runAsNonRoot: true,allowPrivilegeEscalation: false,capabilities.drop: ["ALL"],seccompProfile: RuntimeDefault). As written, non-root/no-privilege-escalation is only guaranteed if the Konflux tenant namespace enforces therestrictedPod Security Admission profile and the release ServiceAccount cannot override it.Consider setting the step
securityContextexplicitly, or add a short comment stating the tenant namespace enforcesrestrictedPSA, so the guarantee is visible in-repo. Confidence: Medium.