Skip to content
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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
119 changes: 119 additions & 0 deletions pipelines/release-bundle/README.md
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)
96 changes: 96 additions & 0 deletions pipelines/release-bundle/pipeline.yaml
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

Copy link
Copy Markdown
Collaborator

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 the restricted Pod Security Admission profile and the release ServiceAccount cannot override it.

Consider setting the step securityContext explicitly, or add a short comment stating the tenant namespace enforces restricted PSA, so the guarantee is visible in-repo. Confidence: Medium.

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"
152 changes: 152 additions & 0 deletions scripts/release_bundle.py
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"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Defense in depth

The push-event assertion only fires when a */event-type key is present: if key.endswith("/event-type"): require(value == "push", ...). A Release that references a Snapshot with no event-type annotation at all would pass this gate (the */pull-request check below also would not trigger).

The surrounding gates (hypershell-releaseplan, ManagedPipelineProcessed=Succeeded) make abuse unlikely, but a positive assertion that a push event-type exists would be stronger. Consider tracking whether any event-type key was seen and rejecting the bundle if none was. Confidence: Low.

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()
Loading
Loading