diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9690043e..8edf1475 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -77,18 +77,26 @@ jobs: api_server=false control_plane=false web_console=false + release_version_changed=false + + # A release version selects all three component builds only after the + # Release PR merges to main. Pull request and merge queue events do not + # use this rule. + if [[ "${EVENT_NAME}" == "push" ]] && grep -qx "VERSION" <<<"${changed_files}"; then + release_version_changed=true + fi # These patterns MUST mirror each component's event-specific Konflux # CEL trigger in .tekton/hypershell--main-${pipeline_suffix}.yaml. # Only components whose path-filtered pipelines actually run may use # the event's commit-tagged image and wait for the corresponding check. - if grep -qE "^components/api-server/|^\\.tekton/hypershell-api-server-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then + if [[ "${release_version_changed}" == "true" ]] || grep -qE "^components/api-server/|^\\.tekton/hypershell-api-server-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then api_server=true fi - if grep -qE "^components/control-plane/|^\\.tekton/hypershell-control-plane-main-${pipeline_suffix}\\.yaml$|^Dockerfile$" <<<"${changed_files}"; then + if [[ "${release_version_changed}" == "true" ]] || grep -qE "^components/control-plane/|^\\.tekton/hypershell-control-plane-main-${pipeline_suffix}\\.yaml$|^Dockerfile$" <<<"${changed_files}"; then control_plane=true fi - if grep -qE "^components/web-console/|^packages/gateway-management-ui/|^\\.tekton/hypershell-web-console-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then + if [[ "${release_version_changed}" == "true" ]] || grep -qE "^components/web-console/|^packages/gateway-management-ui/|^\\.tekton/hypershell-web-console-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then web_console=true fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f8b14ea..6117ebc3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,6 +18,18 @@ concurrency: cancel-in-progress: true jobs: + lint-pull-request-title: + name: Conventional pull request title + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check pull request title + env: + PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} + run: python3 scripts/release_policy.py check-title "${PULL_REQUEST_TITLE}" + detect-changes: name: Detect changed components runs-on: ubuntu-24.04 @@ -60,6 +72,9 @@ jobs: with: go-version-file: hypershell/components/api-server/go.mod cache-dependency-path: hypershell/components/api-server/go.sum + - name: Verify linked build metadata + working-directory: hypershell/components/api-server + run: make check-build-metadata - name: Check formatting working-directory: hypershell/components/api-server run: | @@ -249,6 +264,7 @@ jobs: name: Lint CI gate if: ${{ always() && !cancelled() }} needs: + - lint-pull-request-title - detect-changes - lint-api-server - lint-cli @@ -262,6 +278,7 @@ jobs: - name: Check lint results env: DETECTION_RESULT: ${{ needs.detect-changes.result }} + PULL_REQUEST_TITLE_RESULT: ${{ needs.lint-pull-request-title.result }} API_SERVER_RESULT: ${{ needs.lint-api-server.result }} CLI_RESULT: ${{ needs.lint-cli.result }} CONTROL_PLANE_RESULT: ${{ needs.lint-control-plane.result }} @@ -270,7 +287,7 @@ jobs: SDK_TYPESCRIPT_RESULT: ${{ needs.lint-sdk-typescript.result }} WEB_CONSOLE_RESULT: ${{ needs.lint-web-console.result }} run: | - for result in "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do + for result in "${PULL_REQUEST_TITLE_RESULT}" "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do if [[ "${result}" == failure || "${result}" == cancelled ]]; then echo "One or more lint jobs failed or were cancelled." exit 1 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..83e9ea78 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,58 @@ +name: Release Please + +on: + workflow_run: + workflows: + - E2E + types: + - completed + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: release-please-${{ github.repository }} + cancel-in-progress: false + +jobs: + release-please: + name: Update or publish the source release + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main') + runs-on: ubuntu-24.04 + steps: + - name: Check out the tested revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Decide whether Release Please must run + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ github.event.workflow_run.head_sha || github.sha }} + run: | + open_count="$(gh pr list --base main --label 'autorelease: pending' --state open --json number --jq 'length')" + if [[ "${open_count}" -gt 0 ]]; then + open_release_pr=true + else + open_release_pr=false + fi + decision="$(python3 scripts/release_policy.py should-run --head-ref "${HEAD_REF}" --open-release-pr "${open_release_pr}")" + echo "run=${decision}" >> "${GITHUB_OUTPUT}" + + - name: Update the Release PR or publish the release + if: steps.gate.outputs.run == 'true' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..e18ee077 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/.tekton/hypershell-api-server-main-merge-queue.yaml b/.tekton/hypershell-api-server-main-merge-queue.yaml index ce00b8c7..370c37f1 100644 --- a/.tekton/hypershell-api-server-main-merge-queue.yaml +++ b/.tekton/hypershell-api-server-main-merge-queue.yaml @@ -28,9 +28,14 @@ spec: - name: image-expires-after value: 5d - name: dockerfile - value: Dockerfile + value: components/api-server/Dockerfile - name: path-context - value: components/api-server + value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-api-server-main-pull-request.yaml b/.tekton/hypershell-api-server-main-pull-request.yaml index b8d43297..6acec65d 100644 --- a/.tekton/hypershell-api-server-main-pull-request.yaml +++ b/.tekton/hypershell-api-server-main-pull-request.yaml @@ -28,9 +28,14 @@ spec: - name: image-expires-after value: 5d - name: dockerfile - value: Dockerfile + value: components/api-server/Dockerfile - name: path-context - value: components/api-server + value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-api-server-main-push.yaml b/.tekton/hypershell-api-server-main-push.yaml index a12ce078..7c388b86 100644 --- a/.tekton/hypershell-api-server-main-push.yaml +++ b/.tekton/hypershell-api-server-main-push.yaml @@ -9,7 +9,7 @@ metadata: pipelinesascode.tekton.dev/max-keep-runs: "3" pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "components/api-server/***".pathChanged() || ".tekton/hypershell-api-server-main-push.yaml".pathChanged() - ) + || "VERSION".pathChanged() ) labels: appstudio.openshift.io/application: hypershell-main appstudio.openshift.io/component: hypershell-api-server-main @@ -25,9 +25,14 @@ spec: - name: output-image value: quay.io/redhat-user-workloads/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main:{{revision}} - name: dockerfile - value: Dockerfile + value: components/api-server/Dockerfile - name: path-context - value: components/api-server + value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-control-plane-main-merge-queue.yaml b/.tekton/hypershell-control-plane-main-merge-queue.yaml index f5348de5..f1d58277 100644 --- a/.tekton/hypershell-control-plane-main-merge-queue.yaml +++ b/.tekton/hypershell-control-plane-main-merge-queue.yaml @@ -31,6 +31,11 @@ spec: value: components/control-plane/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-control-plane-main-pull-request.yaml b/.tekton/hypershell-control-plane-main-pull-request.yaml index 0011db64..bfa85096 100644 --- a/.tekton/hypershell-control-plane-main-pull-request.yaml +++ b/.tekton/hypershell-control-plane-main-pull-request.yaml @@ -31,6 +31,11 @@ spec: value: components/control-plane/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-control-plane-main-push.yaml b/.tekton/hypershell-control-plane-main-push.yaml index f0644b30..6341b14e 100644 --- a/.tekton/hypershell-control-plane-main-push.yaml +++ b/.tekton/hypershell-control-plane-main-push.yaml @@ -9,7 +9,7 @@ metadata: pipelinesascode.tekton.dev/max-keep-runs: "3" pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "components/control-plane/***".pathChanged() || ".tekton/hypershell-control-plane-main-push.yaml".pathChanged() - || "Dockerfile".pathChanged() ) + || "Dockerfile".pathChanged() || "VERSION".pathChanged() ) labels: appstudio.openshift.io/application: hypershell-main appstudio.openshift.io/component: hypershell-control-plane-main @@ -28,6 +28,11 @@ spec: value: components/control-plane/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-web-console-main-merge-queue.yaml b/.tekton/hypershell-web-console-main-merge-queue.yaml index 57f86c5f..7651daca 100644 --- a/.tekton/hypershell-web-console-main-merge-queue.yaml +++ b/.tekton/hypershell-web-console-main-merge-queue.yaml @@ -31,6 +31,11 @@ spec: value: components/web-console/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-web-console-main-pull-request.yaml b/.tekton/hypershell-web-console-main-pull-request.yaml index d8be1d7e..fdf3e3dc 100644 --- a/.tekton/hypershell-web-console-main-pull-request.yaml +++ b/.tekton/hypershell-web-console-main-pull-request.yaml @@ -31,6 +31,11 @@ spec: value: components/web-console/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-web-console-main-push.yaml b/.tekton/hypershell-web-console-main-push.yaml index f92d8bb8..00eea1a7 100644 --- a/.tekton/hypershell-web-console-main-push.yaml +++ b/.tekton/hypershell-web-console-main-push.yaml @@ -9,7 +9,8 @@ metadata: pipelinesascode.tekton.dev/max-keep-runs: "3" pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "components/web-console/***".pathChanged() || "packages/gateway-management-ui/***".pathChanged() - || ".tekton/hypershell-web-console-main-push.yaml".pathChanged() ) + || ".tekton/hypershell-web-console-main-push.yaml".pathChanged() || "VERSION".pathChanged() + ) labels: appstudio.openshift.io/application: hypershell-main appstudio.openshift.io/component: hypershell-web-console-main @@ -28,6 +29,11 @@ spec: value: components/web-console/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0ad54191 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# HyperShell Agent Guidance + +Read [CLAUDE.md](CLAUDE.md) for the repository structure, commands, and +development conventions. + +## Pull Requests + +Use a Conventional Commits title for each pull request: + +```text +(): +``` + +If the title must contain a Jira key, put the key after the colon: + +```text +feat(release): [HYPERSHELL-123] add managed source releases +``` + +Do not put the Jira key before the conventional type. The repository uses the +pull request title as the squash commit subject. Release Please must be able to +parse the type at the start of that subject. See [docs/releasing.md](docs/releasing.md) +for the allowed types and release rules. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c80939ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.0 + +This version is the source-release automation baseline. diff --git a/CLAUDE.md b/CLAUDE.md index 0aad3909..9a722bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,25 @@ make hooks-install The pre-commit and pre-push hooks run the repository policy checks. Run the same checks manually with `make check`. +## Pull Requests + +Use a Conventional Commits title for each pull request: + +```text +(): +``` + +If the title must contain a Jira key, put the key after the colon: + +```text +feat(release): [HYPERSHELL-123] add managed source releases +``` + +Do not put the Jira key before the conventional type. The repository uses the +pull request title as the squash commit subject. Release Please must be able to +parse the type at the start of that subject. See [release guidance](docs/releasing.md) +for the allowed types and release rules. + ## Structure - `components/api-server/` - Go REST + gRPC API microservice (rh-trex-ai framework), PostgreSQL-backed diff --git a/Makefile b/Makefile index f4225254..9e942f20 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,12 @@ PNPM?=pnpm IMAGE_REGISTRY?=quay.io/redhat-services-prod/hcm-eng-prod-tenant/hypershell-main IMAGE_TAG?=latest -# Build version (embedded in api-server binary via ldflags) -git_sha:=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) -git_dirty:=$(shell git diff --quiet 2>/dev/null || echo -modified) -build_version:=$(git_sha)$(git_dirty) -build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') +# Build identity for supported local image builds +vcs_ref:=$(shell git rev-parse HEAD 2>/dev/null || echo unknown) +build_prefix:=dev +build_version:=$(shell HYPERSHELL_VCS_REF=$(vcs_ref) scripts/build-version.sh local 2>/dev/null || echo dev-unknown) +build_suffix:=$(if $(filter %-modified,$(build_version)),-modified,) +build_time:=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') # Computed baseline references (registry images used in Kind manifests) api_server_ref=$(IMAGE_REGISTRY)/hypershell-api-server-main:$(IMAGE_TAG) @@ -128,6 +129,8 @@ help: @echo " check-dependency-pins Verify dependency version pins" @echo " check-dependency-age Verify dependency minimum age" @echo " check-ci-components Verify CI component registration" + @echo " check-release-policy Verify source-release controls and files" + @echo " check-image-build-policy Verify image identity and selective build rules" @echo "" @echo " Hooks" @echo " hooks-install Install Git hooks (lefthook)" @@ -155,12 +158,18 @@ install-js: verify-pnpm .PHONY: build-api-server build-api-server: $(CONTAINER_ENGINE) build -t $(api_server_local) \ - --build-arg GIT_VERSION=$(build_version) --build-arg BUILD_TIME="$(build_time)" \ - components/api-server + --build-arg BUILD_PREFIX=$(build_prefix) \ + --build-arg BUILD_SUFFIX=$(build_suffix) \ + --build-arg VCS_REF=$(vcs_ref) \ + --build-arg BUILD_TIME="$(build_time)" \ + -f components/api-server/Dockerfile . .PHONY: build-controller build-controller: $(CONTAINER_ENGINE) build -t $(control_plane_local) \ + --build-arg BUILD_PREFIX=$(build_prefix) \ + --build-arg BUILD_SUFFIX=$(build_suffix) \ + --build-arg VCS_REF=$(vcs_ref) \ -f components/control-plane/Dockerfile . .PHONY: build-cli @@ -170,6 +179,9 @@ build-cli: .PHONY: build-web-console build-web-console: $(CONTAINER_ENGINE) build -t $(web_console_local) \ + --build-arg BUILD_PREFIX=$(build_prefix) \ + --build-arg BUILD_SUFFIX=$(build_suffix) \ + --build-arg VCS_REF=$(vcs_ref) \ -f components/web-console/Dockerfile . # ============================================================================ @@ -204,8 +216,24 @@ test-dependency-age-policy: check-dependency-age: test-dependency-age-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_dependency_age.py --min-age-days $(DEPENDENCY_MIN_AGE_DAYS) +.PHONY: test-release-policy +test-release-policy: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_release_policy.py + +.PHONY: check-release-policy +check-release-policy: test-release-policy + PYTHONDONTWRITEBYTECODE=1 python3 scripts/release_policy.py check-files + +.PHONY: test-image-build-policy +test-image-build-policy: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_build_version.py scripts/test_check_image_build_policy.py + +.PHONY: check-image-build-policy +check-image-build-policy: test-image-build-policy + PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_image_build_policy.py + .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 check-release-policy check-image-build-policy # ============================================================================ # Git hooks @@ -299,7 +327,7 @@ export IMAGE_REGISTRY IMAGE_TAG KIND_CONFIG export api_server_ref control_plane_ref web_console_ref export API_SERVER_IMAGE CONTROL_PLANE_IMAGE WEB_CONSOLE_IMAGE export api_server_local control_plane_local web_console_local -export build_version build_time +export vcs_ref build_prefix build_suffix build_version build_time export API_HOSTNAME CONSOLE_HOSTNAME HEALTH_HOSTNAME KEYCLOAK_HOSTNAME KEYCLOAK_OIDC_ISSUER export KIND_DNS_PORT diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..77d6f4ca --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.0 diff --git a/build-version.env b/build-version.env new file mode 100644 index 00000000..c4571f3f --- /dev/null +++ b/build-version.env @@ -0,0 +1,3 @@ +# x-release-please-start-version +BUILD_PREFIX=v0.0.0 +# x-release-please-end diff --git a/components/api-server/Dockerfile b/components/api-server/Dockerfile index 624a6945..463b828b 100644 --- a/components/api-server/Dockerfile +++ b/components/api-server/Dockerfile @@ -1,27 +1,61 @@ # syntax=docker/dockerfile:1 +ARG BUILD_PREFIX=dev +ARG BUILD_SUFFIX= +ARG VCS_REF + FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder WORKDIR /workspace -COPY go.mod go.sum ./ +COPY components/api-server/go.mod components/api-server/go.sum ./ RUN go mod edit -dropreplace github.com/openshift-online/rh-trex-ai && go mod download -COPY cmd/ cmd/ -COPY pkg/ pkg/ -COPY plugins/ plugins/ -COPY openapi/ openapi/ +COPY components/api-server/cmd/ cmd/ +COPY components/api-server/pkg/ pkg/ +COPY components/api-server/plugins/ plugins/ +COPY components/api-server/openapi/ openapi/ -ARG GIT_VERSION=unknown +ARG BUILD_PREFIX +ARG BUILD_SUFFIX +ARG VCS_REF ARG BUILD_TIME=unknown -RUN CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ - -X github.com/openshift-online/hypershell/components/api-server/pkg/api.Version=${GIT_VERSION} \ - -X 'github.com/openshift-online/hypershell/components/api-server/pkg/api.BuildTime=${BUILD_TIME}'" \ - -o hypershell ./cmd/hypershell +# Validate the full revision before any 40-to-7-character shortening. +RUN set -eu; \ + if [ "${#VCS_REF}" -ne 40 ]; then \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + fi; \ + case "${VCS_REF}" in \ + *[!0-9a-f]*) \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + ;; \ + esac + +# Remove the final 33 characters from the 40-character VCS_REF. +# This keeps the first seven characters. +RUN set -eu; \ + short_ref="${VCS_REF%?????????????????????????????????}"; \ + build_version="${BUILD_PREFIX}-${short_ref}${BUILD_SUFFIX}"; \ + build_time="${BUILD_TIME}"; \ + if [ "${build_time}" = "unknown" ]; then build_time="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"; fi; \ + CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ + -X github.com/openshift-online/rh-trex-ai/pkg/api.Version=${build_version} \ + -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=${build_time}'" \ + -o hypershell ./cmd/hypershell FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 +ARG BUILD_PREFIX +ARG BUILD_SUFFIX +ARG VCS_REF + COPY --from=builder /workspace/hypershell /usr/local/bin/ +# The builder validates VCS_REF before this expression keeps seven characters. +ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ + HYPERSHELL_BUILD_REVISION="${VCS_REF}" + EXPOSE 8000 EXPOSE 9000 @@ -30,4 +64,5 @@ CMD ["serve"] LABEL org.opencontainers.image.title="HyperShell API Server" \ org.opencontainers.image.description="REST + gRPC API server for HyperShell fleet management" \ - org.opencontainers.image.version="0.0.1" + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ + org.opencontainers.image.revision="${VCS_REF}" diff --git a/components/api-server/Makefile b/components/api-server/Makefile index e8f0be3b..3dca87b1 100644 --- a/components/api-server/Makefile +++ b/components/api-server/Makefile @@ -12,11 +12,10 @@ SDK_GO_MODULE=github.com/openshift-online/hypershell/components/sdk-go CLI_MODULE=github.com/openshift-online/hypershell/components/cli # Version information for ldflags -git_sha:=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -git_dirty:=$(shell git diff --quiet 2>/dev/null || echo "-modified") -build_version:=$(git_sha)$(git_dirty) -build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') -ldflags=-X github.com/openshift-online/hypershell/components/api-server/pkg/api.Version=$(build_version) -X 'github.com/openshift-online/hypershell/components/api-server/pkg/api.BuildTime=$(build_time)' +git_sha:=$(shell git rev-parse HEAD 2>/dev/null || echo "unknown") +build_version:=$(shell HYPERSHELL_VCS_REF=$(git_sha) ../../scripts/build-version.sh local 2>/dev/null || echo "dev-unknown") +build_time:=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +ldflags=-X github.com/openshift-online/rh-trex-ai/pkg/api.Version=$(build_version) -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=$(build_time)' .PHONY: binary binary: @@ -27,6 +26,14 @@ binary: install: go install -ldflags="$(ldflags)" ./cmd/hypershell +.PHONY: check-build-metadata +check-build-metadata: + HYPERSHELL_EXPECTED_BUILD_VERSION="$(build_version)" \ + HYPERSHELL_EXPECTED_BUILD_TIME="$(build_time)" \ + go test -tags=linked_metadata -count=1 \ + -run '^TestLinkedBuildIdentity$$' \ + -ldflags="$(ldflags)" ./pkg/api + .PHONY: run run: binary ./$(BINARY_NAME) migrate @@ -38,7 +45,7 @@ run-no-auth: binary ./$(BINARY_NAME) serve --enable-authz=false --enable-jwt=false .PHONY: test -test: install +test: install check-build-metadata go test -v ./... .PHONY: test-integration diff --git a/components/api-server/cmd/hypershell/environments/e_development_oidc.go b/components/api-server/cmd/hypershell/environments/e_development_oidc.go index e4749e07..df49b087 100644 --- a/components/api-server/cmd/hypershell/environments/e_development_oidc.go +++ b/components/api-server/cmd/hypershell/environments/e_development_oidc.go @@ -44,7 +44,7 @@ func (e *DevOidcEnvImpl) Flags() map[string]string { "enable-https": "false", "enable-metrics-https": "false", "api-server-hostname": "localhost", - "auth-bypass-paths": "/healthcheck,/metrics,/api/hypershell/v1/openapi,/openapi", + "auth-bypass-paths": "/healthcheck,/metrics,/api/hypershell/v1/metadata,/api/hypershell/v1/openapi,/openapi", "auth-bypass-methods": "/grpc.health.v1.Health/,/grpc.reflection.v1alpha.ServerReflection/,/hypershell.v1.GatewayService/WatchGateways,/hypershell.v1.GatewayReleaseService/WatchGatewayReleases,/hypershell.v1.ManagedClusterService/WatchManagedClusters,/hypershell.v1.ManagedDatabaseService/WatchManagedDatabases,/hypershell.v1.GatewayNetworkService/WatchGatewayNetworks", } } diff --git a/components/api-server/cmd/hypershell/environments/e_development_test.go b/components/api-server/cmd/hypershell/environments/e_development_test.go index 54b7aabb..880b4fbe 100644 --- a/components/api-server/cmd/hypershell/environments/e_development_test.go +++ b/components/api-server/cmd/hypershell/environments/e_development_test.go @@ -45,3 +45,10 @@ func TestDevOverrideConfigDisablesJWT(t *testing.T) { t.Error("development environment must disable HTTPS") } } + +func TestDevOIDCMetadataBypass(t *testing.T) { + paths := (&DevOidcEnvImpl{}).Flags()["auth-bypass-paths"] + if !strings.Contains(paths, "/api/hypershell/v1/metadata") { + t.Fatalf("auth bypass paths do not contain the metadata endpoint: %q", paths) + } +} diff --git a/components/api-server/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index 28f7c4e1..446b6f69 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -18,14 +18,17 @@ paths: /api/hypershell/v1/metadata: get: summary: Service metadata + description: Returns the API server build identity without a database query. operationId: getMetadata + security: [] + x-sdk-exclude: true responses: '200': description: Service metadata content: application/json: schema: - $ref: '#/components/schemas/ObjectReference' + $ref: '#/components/schemas/ServiceMetadata' /api/hypershell/v1/managed_clusters: $ref: 'openapi.managedClusters.yaml#/paths/~1api~1hypershell~1v1~1managed_clusters' /api/hypershell/v1/managed_clusters/{id}: @@ -63,6 +66,31 @@ paths: # AUTO-ADD NEW PATHS components: schemas: + ServiceMetadata: + type: object + required: + - id + - href + - kind + - version + - build_time + properties: + id: + type: string + description: Service identifier + href: + type: string + description: Metadata request path + kind: + type: string + enum: + - API + version: + type: string + description: API server image build version + build_time: + type: string + description: Time when the API server binary was built List: allOf: - type: object diff --git a/components/api-server/pkg/api/api.go b/components/api-server/pkg/api/api.go index 50f27d57..2ce26ec9 100644 --- a/components/api-server/pkg/api/api.go +++ b/components/api-server/pkg/api/api.go @@ -18,7 +18,3 @@ const ( // Re-export TRex functions var NewID = trexapi.NewID -var ( - Version string - BuildTime string -) diff --git a/components/api-server/pkg/api/linked_metadata_test.go b/components/api-server/pkg/api/linked_metadata_test.go new file mode 100644 index 00000000..2f0b77ae --- /dev/null +++ b/components/api-server/pkg/api/linked_metadata_test.go @@ -0,0 +1,28 @@ +//go:build linked_metadata + +package api + +import ( + "os" + "testing" + + trexapi "github.com/openshift-online/rh-trex-ai/pkg/api" +) + +func TestLinkedBuildIdentity(t *testing.T) { + expectedVersion := os.Getenv("HYPERSHELL_EXPECTED_BUILD_VERSION") + if expectedVersion == "" { + t.Fatal("HYPERSHELL_EXPECTED_BUILD_VERSION is not set") + } + expectedBuildTime := os.Getenv("HYPERSHELL_EXPECTED_BUILD_TIME") + if expectedBuildTime == "" { + t.Fatal("HYPERSHELL_EXPECTED_BUILD_TIME is not set") + } + + if trexapi.Version != expectedVersion { + t.Errorf("linked version = %q, want %q", trexapi.Version, expectedVersion) + } + if trexapi.BuildTime != expectedBuildTime { + t.Errorf("linked build time = %q, want %q", trexapi.BuildTime, expectedBuildTime) + } +} diff --git a/components/api-server/pkg/api/metadata_test.go b/components/api-server/pkg/api/metadata_test.go new file mode 100644 index 00000000..9d690fc6 --- /dev/null +++ b/components/api-server/pkg/api/metadata_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + trexapi "github.com/openshift-online/rh-trex-ai/pkg/api" + "github.com/openshift-online/rh-trex-ai/pkg/handlers" +) + +func TestMetadataHandlerReportsBuildIdentity(t *testing.T) { + previousVersion := trexapi.Version + previousBuildTime := trexapi.BuildTime + defer func() { + trexapi.Version = previousVersion + trexapi.BuildTime = previousBuildTime + handlers.SetMetadataID("hypershell") + }() + + trexapi.Version = "v1.6.0-1234567" + trexapi.BuildTime = "2026-09-02T15:00:00Z" + handlers.SetMetadataID("hypershell") + + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/metadata", nil) + response := httptest.NewRecorder() + handlers.NewMetadataHandler().Get(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("metadata status = %d, want %d", response.Code, http.StatusOK) + } + if got := response.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("metadata content type = %q, want application/json", got) + } + + var metadata trexapi.Metadata + if err := json.Unmarshal(response.Body.Bytes(), &metadata); err != nil { + t.Fatalf("decode metadata response: %v", err) + } + if metadata.ID != "hypershell" { + t.Errorf("metadata id = %q, want hypershell", metadata.ID) + } + if metadata.HREF != "/api/hypershell/v1/metadata" { + t.Errorf("metadata href = %q", metadata.HREF) + } + if metadata.Kind != "API" { + t.Errorf("metadata kind = %q, want API", metadata.Kind) + } + if metadata.Version != "v1.6.0-1234567" { + t.Errorf("metadata version = %q", metadata.Version) + } + if metadata.BuildTime != "2026-09-02T15:00:00Z" { + t.Errorf("metadata build time = %q", metadata.BuildTime) + } +} diff --git a/components/api-server/pkg/api/openapi/.openapi-generator/FILES b/components/api-server/pkg/api/openapi/.openapi-generator/FILES index 706c9c43..51890144 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -41,6 +41,7 @@ docs/Role.md docs/RoleBinding.md docs/RoleBindingList.md docs/RoleList.md +docs/ServiceMetadata.md git_push.sh go.mod go.sum @@ -78,6 +79,7 @@ model_role.go model_role_binding.go model_role_binding_list.go model_role_list.go +model_service_metadata.go response.go test/api_default_test.go utils.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index 7a8f5d1a..4dc0f912 100644 --- a/components/api-server/pkg/api/openapi/README.md +++ b/components/api-server/pkg/api/openapi/README.md @@ -153,6 +153,7 @@ Class | Method | HTTP request | Description - [RoleBinding](docs/RoleBinding.md) - [RoleBindingList](docs/RoleBindingList.md) - [RoleList](docs/RoleList.md) + - [ServiceMetadata](docs/ServiceMetadata.md) ## Documentation For Authorization diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 65e687da..91b99990 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -19,15 +19,18 @@ tags: paths: /api/hypershell/v1/metadata: get: + description: Returns the API server build identity without a database query. operationId: getMetadata responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ObjectReference" + $ref: "#/components/schemas/ServiceMetadata" description: Service metadata + security: [] summary: Service metadata + x-sdk-exclude: true /api/hypershell/v1/managed_clusters: get: operationId: listManagedClusters @@ -2119,6 +2122,37 @@ components: $ref: "#/components/schemas/Error" description: Keycloak provisioning or lifecycle verification is unavailable schemas: + ServiceMetadata: + example: + kind: API + build_time: build_time + id: id + href: href + version: version + properties: + id: + description: Service identifier + type: string + href: + description: Metadata request path + type: string + kind: + enum: + - API + type: string + version: + description: API server image build version + type: string + build_time: + description: Time when the API server binary was built + type: string + required: + - build_time + - href + - id + - kind + - version + type: object List: allOf: - properties: @@ -2133,12 +2167,6 @@ components: type: object - $ref: "#/components/schemas/ObjectReference" ObjectReference: - example: - updated_at: 2000-01-23T04:56:07.000+00:00 - kind: kind - created_at: 2000-01-23T04:56:07.000+00:00 - id: id - href: href properties: id: type: string diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index 7621d56d..6467b161 100644 --- a/components/api-server/pkg/api/openapi/api_default.go +++ b/components/api-server/pkg/api/openapi/api_default.go @@ -2922,13 +2922,15 @@ type ApiGetMetadataRequest struct { ApiService *DefaultAPIService } -func (r ApiGetMetadataRequest) Execute() (*ObjectReference, *http.Response, error) { +func (r ApiGetMetadataRequest) Execute() (*ServiceMetadata, *http.Response, error) { return r.ApiService.GetMetadataExecute(r) } /* GetMetadata Service metadata +Returns the API server build identity without a database query. + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). @return ApiGetMetadataRequest */ @@ -2941,13 +2943,13 @@ func (a *DefaultAPIService) GetMetadata(ctx context.Context) ApiGetMetadataReque // Execute executes the request // -// @return ObjectReference -func (a *DefaultAPIService) GetMetadataExecute(r ApiGetMetadataRequest) (*ObjectReference, *http.Response, error) { +// @return ServiceMetadata +func (a *DefaultAPIService) GetMetadataExecute(r ApiGetMetadataRequest) (*ServiceMetadata, *http.Response, error) { var ( localVarHTTPMethod = http.MethodGet localVarPostBody interface{} formFiles []formFile - localVarReturnValue *ObjectReference + localVarReturnValue *ServiceMetadata ) localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GetMetadata") diff --git a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md index 2a96f7a7..2705fd87 100644 --- a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md +++ b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md @@ -1378,10 +1378,12 @@ Name | Type | Description | Notes ## GetMetadata -> ObjectReference GetMetadata(ctx).Execute() +> ServiceMetadata GetMetadata(ctx).Execute() Service metadata + + ### Example ```go @@ -1403,7 +1405,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.GetMetadata``: %v\n", err) fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) } - // response from `GetMetadata`: ObjectReference + // response from `GetMetadata`: ServiceMetadata fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.GetMetadata`: %v\n", resp) } ``` @@ -1419,11 +1421,11 @@ Other parameters are passed through a pointer to a apiGetMetadataRequest struct ### Return type -[**ObjectReference**](ObjectReference.md) +[**ServiceMetadata**](ServiceMetadata.md) ### Authorization -[Bearer](../README.md#Bearer) +No authorization required ### HTTP request headers diff --git a/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md b/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md new file mode 100644 index 00000000..99ddbc97 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md @@ -0,0 +1,135 @@ +# ServiceMetadata + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Id** | **string** | Service identifier | +**Href** | **string** | Metadata request path | +**Kind** | **string** | | +**Version** | **string** | API server image build version | +**BuildTime** | **string** | Time when the API server binary was built | + +## Methods + +### NewServiceMetadata + +`func NewServiceMetadata(id string, href string, kind string, version string, buildTime string, ) *ServiceMetadata` + +NewServiceMetadata instantiates a new ServiceMetadata object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewServiceMetadataWithDefaults + +`func NewServiceMetadataWithDefaults() *ServiceMetadata` + +NewServiceMetadataWithDefaults instantiates a new ServiceMetadata object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetId + +`func (o *ServiceMetadata) GetId() string` + +GetId returns the Id field if non-nil, zero value otherwise. + +### GetIdOk + +`func (o *ServiceMetadata) GetIdOk() (*string, bool)` + +GetIdOk returns a tuple with the Id field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetId + +`func (o *ServiceMetadata) SetId(v string)` + +SetId sets Id field to given value. + + +### GetHref + +`func (o *ServiceMetadata) GetHref() string` + +GetHref returns the Href field if non-nil, zero value otherwise. + +### GetHrefOk + +`func (o *ServiceMetadata) GetHrefOk() (*string, bool)` + +GetHrefOk returns a tuple with the Href field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetHref + +`func (o *ServiceMetadata) SetHref(v string)` + +SetHref sets Href field to given value. + + +### GetKind + +`func (o *ServiceMetadata) GetKind() string` + +GetKind returns the Kind field if non-nil, zero value otherwise. + +### GetKindOk + +`func (o *ServiceMetadata) GetKindOk() (*string, bool)` + +GetKindOk returns a tuple with the Kind field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetKind + +`func (o *ServiceMetadata) SetKind(v string)` + +SetKind sets Kind field to given value. + + +### GetVersion + +`func (o *ServiceMetadata) GetVersion() string` + +GetVersion returns the Version field if non-nil, zero value otherwise. + +### GetVersionOk + +`func (o *ServiceMetadata) GetVersionOk() (*string, bool)` + +GetVersionOk returns a tuple with the Version field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetVersion + +`func (o *ServiceMetadata) SetVersion(v string)` + +SetVersion sets Version field to given value. + + +### GetBuildTime + +`func (o *ServiceMetadata) GetBuildTime() string` + +GetBuildTime returns the BuildTime field if non-nil, zero value otherwise. + +### GetBuildTimeOk + +`func (o *ServiceMetadata) GetBuildTimeOk() (*string, bool)` + +GetBuildTimeOk returns a tuple with the BuildTime field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetBuildTime + +`func (o *ServiceMetadata) SetBuildTime(v string)` + +SetBuildTime sets BuildTime field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/components/api-server/pkg/api/openapi/model_service_metadata.go b/components/api-server/pkg/api/openapi/model_service_metadata.go new file mode 100644 index 00000000..b0e83abd --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_service_metadata.go @@ -0,0 +1,272 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the ServiceMetadata type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ServiceMetadata{} + +// ServiceMetadata struct for ServiceMetadata +type ServiceMetadata struct { + // Service identifier + Id string `json:"id"` + // Metadata request path + Href string `json:"href"` + Kind string `json:"kind"` + // API server image build version + Version string `json:"version"` + // Time when the API server binary was built + BuildTime string `json:"build_time"` +} + +type _ServiceMetadata ServiceMetadata + +// NewServiceMetadata instantiates a new ServiceMetadata object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewServiceMetadata(id string, href string, kind string, version string, buildTime string) *ServiceMetadata { + this := ServiceMetadata{} + this.Id = id + this.Href = href + this.Kind = kind + this.Version = version + this.BuildTime = buildTime + return &this +} + +// NewServiceMetadataWithDefaults instantiates a new ServiceMetadata object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewServiceMetadataWithDefaults() *ServiceMetadata { + this := ServiceMetadata{} + return &this +} + +// GetId returns the Id field value +func (o *ServiceMetadata) GetId() string { + if o == nil { + var ret string + return ret + } + + return o.Id +} + +// GetIdOk returns a tuple with the Id field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Id, true +} + +// SetId sets field value +func (o *ServiceMetadata) SetId(v string) { + o.Id = v +} + +// GetHref returns the Href field value +func (o *ServiceMetadata) GetHref() string { + if o == nil { + var ret string + return ret + } + + return o.Href +} + +// GetHrefOk returns a tuple with the Href field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetHrefOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Href, true +} + +// SetHref sets field value +func (o *ServiceMetadata) SetHref(v string) { + o.Href = v +} + +// GetKind returns the Kind field value +func (o *ServiceMetadata) GetKind() string { + if o == nil { + var ret string + return ret + } + + return o.Kind +} + +// GetKindOk returns a tuple with the Kind field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetKindOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Kind, true +} + +// SetKind sets field value +func (o *ServiceMetadata) SetKind(v string) { + o.Kind = v +} + +// GetVersion returns the Version field value +func (o *ServiceMetadata) GetVersion() string { + if o == nil { + var ret string + return ret + } + + return o.Version +} + +// GetVersionOk returns a tuple with the Version field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetVersionOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Version, true +} + +// SetVersion sets field value +func (o *ServiceMetadata) SetVersion(v string) { + o.Version = v +} + +// GetBuildTime returns the BuildTime field value +func (o *ServiceMetadata) GetBuildTime() string { + if o == nil { + var ret string + return ret + } + + return o.BuildTime +} + +// GetBuildTimeOk returns a tuple with the BuildTime field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetBuildTimeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.BuildTime, true +} + +// SetBuildTime sets field value +func (o *ServiceMetadata) SetBuildTime(v string) { + o.BuildTime = v +} + +func (o ServiceMetadata) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ServiceMetadata) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["id"] = o.Id + toSerialize["href"] = o.Href + toSerialize["kind"] = o.Kind + toSerialize["version"] = o.Version + toSerialize["build_time"] = o.BuildTime + return toSerialize, nil +} + +func (o *ServiceMetadata) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "id", + "href", + "kind", + "version", + "build_time", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varServiceMetadata := _ServiceMetadata{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varServiceMetadata) + + if err != nil { + return err + } + + *o = ServiceMetadata(varServiceMetadata) + + return err +} + +type NullableServiceMetadata struct { + value *ServiceMetadata + isSet bool +} + +func (v NullableServiceMetadata) Get() *ServiceMetadata { + return v.value +} + +func (v *NullableServiceMetadata) Set(val *ServiceMetadata) { + v.value = val + v.isSet = true +} + +func (v NullableServiceMetadata) IsSet() bool { + return v.isSet +} + +func (v *NullableServiceMetadata) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableServiceMetadata(val *ServiceMetadata) *NullableServiceMetadata { + return &NullableServiceMetadata{value: val, isSet: true} +} + +func (v NullableServiceMetadata) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableServiceMetadata) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi_embed_test.go b/components/api-server/pkg/api/openapi_embed_test.go index f2437bbe..a7304ef9 100644 --- a/components/api-server/pkg/api/openapi_embed_test.go +++ b/components/api-server/pkg/api/openapi_embed_test.go @@ -8,6 +8,27 @@ import ( type openAPIOperation struct { OperationID string `yaml:"operationId"` + Responses map[string]openAPIResponse + Security *[]map[string][]string `yaml:"security"` +} + +type openAPIResponse struct { + Content map[string]openAPIContent +} + +type openAPIContent struct { + Schema openAPISchemaReference +} + +type openAPISchemaReference struct { + Reference string `yaml:"$ref"` +} + +type openAPISchema struct { + Required []string + Properties map[string]struct { + Type string + } } type openAPIPathItem struct { @@ -24,8 +45,11 @@ func TestGetOpenAPISpecReturnsCompleteContract(t *testing.T) { } var spec struct { - OpenAPI string `yaml:"openapi"` - Paths map[string]openAPIPathItem `yaml:"paths"` + OpenAPI string `yaml:"openapi"` + Paths map[string]openAPIPathItem `yaml:"paths"` + Components struct { + Schemas map[string]openAPISchema + } } if err := yaml.Unmarshal(data, &spec); err != nil { t.Fatalf("parse embedded OpenAPI document: %v", err) @@ -78,4 +102,36 @@ func TestGetOpenAPISpecReturnsCompleteContract(t *testing.T) { t.Errorf("DELETE %s operationId = %q, want %q", path, operation.OperationID, expectedOperationID) } } + + metadataOperation := spec.Paths["/api/hypershell/v1/metadata"].Get + if metadataOperation == nil { + t.Fatal("GET metadata operation is missing") + } + if metadataOperation.Security == nil || len(*metadataOperation.Security) != 0 { + t.Error("GET metadata must have an explicit empty security requirement") + } + response, exists := metadataOperation.Responses["200"] + if !exists { + t.Fatal("GET metadata has no 200 response") + } + if got := response.Content["application/json"].Schema.Reference; got != "#/components/schemas/ServiceMetadata" { + t.Errorf("GET metadata response schema = %q", got) + } + + metadataSchema, exists := spec.Components.Schemas["ServiceMetadata"] + if !exists { + t.Fatal("ServiceMetadata schema is missing") + } + required := make(map[string]bool, len(metadataSchema.Required)) + for _, name := range metadataSchema.Required { + required[name] = true + } + for _, name := range []string{"id", "href", "kind", "version", "build_time"} { + if !required[name] { + t.Errorf("ServiceMetadata does not require %q", name) + } + if property, exists := metadataSchema.Properties[name]; !exists || property.Type != "string" { + t.Errorf("ServiceMetadata property %q must be a string", name) + } + } } diff --git a/components/control-plane/Dockerfile b/components/control-plane/Dockerfile index aa43f90d..d5c7a011 100644 --- a/components/control-plane/Dockerfile +++ b/components/control-plane/Dockerfile @@ -1,4 +1,8 @@ # syntax=docker/dockerfile:1 +ARG BUILD_PREFIX=dev +ARG BUILD_SUFFIX= +ARG VCS_REF + FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder WORKDIR /workspace @@ -16,15 +20,40 @@ COPY components/api-server/proto/ components/api-server/proto/ COPY components/control-plane/cmd/ components/control-plane/cmd/ COPY components/control-plane/internal/ components/control-plane/internal/ +ARG VCS_REF +# Validate the full revision before any 40-to-7-character shortening. +RUN set -eu; \ + if [ "${#VCS_REF}" -ne 40 ]; then \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + fi; \ + case "${VCS_REF}" in \ + *[!0-9a-f]*) \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + ;; \ + esac + RUN cd components/control-plane && CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w" -o /workspace/hypershell-controller ./cmd/hypershell-controller FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 +ARG BUILD_PREFIX +ARG BUILD_SUFFIX +ARG VCS_REF + COPY --from=builder /workspace/hypershell-controller /usr/local/bin/ COPY components/control-plane/manifests/ /manifests/ +# The builder validates VCS_REF before this expression removes 33 characters. +# Remove the final 33 characters from the 40-character VCS_REF. +# This keeps the first seven characters. +ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ + HYPERSHELL_BUILD_REVISION="${VCS_REF}" + ENTRYPOINT ["/usr/local/bin/hypershell-controller"] LABEL org.opencontainers.image.title="HyperShell Controller" \ org.opencontainers.image.description="Control plane that reconciles HyperShell resources via gRPC watch streams" \ - org.opencontainers.image.version="0.0.1" + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ + org.opencontainers.image.revision="${VCS_REF}" diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 847fa5d5..ab2386c5 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index 00bb5525..81015fe7 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/gateway_network_api.go b/components/sdk-go/client/gateway_network_api.go index 75d2411a..3e110fa3 100644 --- a/components/sdk-go/client/gateway_network_api.go +++ b/components/sdk-go/client/gateway_network_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/gateway_release_api.go b/components/sdk-go/client/gateway_release_api.go index 72c64bf8..8f967e33 100644 --- a/components/sdk-go/client/gateway_release_api.go +++ b/components/sdk-go/client/gateway_release_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index a2792ca0..80786483 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 646d6726..cf87d551 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/managed_database_api.go b/components/sdk-go/client/managed_database_api.go index 6c86ed2c..0495f1a6 100644 --- a/components/sdk-go/client/managed_database_api.go +++ b/components/sdk-go/client/managed_database_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index 0cc42149..7c217078 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index 8cc27d75..bb77761d 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 76a4cd1f..473b2d94 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index 33610fdb..4108a4b3 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 1541d672..af974d4d 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/gateway_network.go b/components/sdk-go/types/gateway_network.go index c57107ce..e5176b20 100644 --- a/components/sdk-go/types/gateway_network.go +++ b/components/sdk-go/types/gateway_network.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/gateway_release.go b/components/sdk-go/types/gateway_release.go index 9303e0de..9533d251 100644 --- a/components/sdk-go/types/gateway_release.go +++ b/components/sdk-go/types/gateway_release.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index 3e76f6cb..534d66a6 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index d85e3d70..f4f379f4 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/managed_database.go b/components/sdk-go/types/managed_database.go index 8407ac9e..01a13543 100644 --- a/components/sdk-go/types/managed_database.go +++ b/components/sdk-go/types/managed_database.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index 5364c0d2..893b3ad4 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 10bca77a..41571623 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index 5a863d2d..33f70681 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 3049004e..042ef2c0 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 3093afc8..210d0d5a 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig } from './base.js'; import { GatewayAPI } from './gateway_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index de4e3184..dc7f5be3 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 4e71256d..05462413 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network.ts b/components/sdk-typescript/src/gateway_network.ts index 97fe414a..36532449 100644 --- a/components/sdk-typescript/src/gateway_network.ts +++ b/components/sdk-typescript/src/gateway_network.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network_api.ts b/components/sdk-typescript/src/gateway_network_api.ts index d08e74a2..301ace32 100644 --- a/components/sdk-typescript/src/gateway_network_api.ts +++ b/components/sdk-typescript/src/gateway_network_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release.ts b/components/sdk-typescript/src/gateway_release.ts index c915a33f..8752ccd5 100644 --- a/components/sdk-typescript/src/gateway_release.ts +++ b/components/sdk-typescript/src/gateway_release.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release_api.ts b/components/sdk-typescript/src/gateway_release_api.ts index f990e48a..a8016b1f 100644 --- a/components/sdk-typescript/src/gateway_release_api.ts +++ b/components/sdk-typescript/src/gateway_release_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index ee5fcee8..7a0bd5da 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index a648937d..e557d225 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index f83d1b15..ec980d32 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database.ts b/components/sdk-typescript/src/managed_database.ts index a156b437..6bf78a01 100644 --- a/components/sdk-typescript/src/managed_database.ts +++ b/components/sdk-typescript/src/managed_database.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database_api.ts b/components/sdk-typescript/src/managed_database_api.ts index e3b31217..2cef6654 100644 --- a/components/sdk-typescript/src/managed_database_api.ts +++ b/components/sdk-typescript/src/managed_database_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index fdb26eb3..78b47d45 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 3e2d5f93..378080f7 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index f433f5cf..f0e6dd4f 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index 1ba0e062..da0ff020 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index d995b20d..c5a35247 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 9678dcbc..bb8e979b 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/web-console/Dockerfile b/components/web-console/Dockerfile index 52f9c821..2bd48a7d 100644 --- a/components/web-console/Dockerfile +++ b/components/web-console/Dockerfile @@ -1,4 +1,8 @@ # syntax=docker/dockerfile:1 +ARG BUILD_PREFIX=dev +ARG BUILD_SUFFIX= +ARG VCS_REF + FROM registry.access.redhat.com/hi/nodejs:24.18.1-builder@sha256:e2a04f7443db3d27751ed6e9d74ee5c3a02217dbf6c8769e468ef3b1b2a3e6fa AS build USER root @@ -24,17 +28,41 @@ COPY --chown=${CONTAINER_DEFAULT_USER} packages/gateway-management-ui packages/g COPY --chown=${CONTAINER_DEFAULT_USER} components/web-console components/web-console COPY --chown=${CONTAINER_DEFAULT_USER} images/brand images/brand +ARG VCS_REF +# Validate the full revision before any 40-to-7-character shortening. +RUN set -eu; \ + if [ "${#VCS_REF}" -ne 40 ]; then \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + fi; \ + case "${VCS_REF}" in \ + *[!0-9a-f]*) \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + ;; \ + esac + RUN pnpm run build:web \ && pnpm --filter @openshift-online/hypershell-web-console-bff deploy --prod /tmp/web-console \ && cp -R components/web-console/build/client /tmp/web-console/public FROM registry.access.redhat.com/hi/nodejs:24.18.1@sha256:07b0f6cf5dabef30b1efa030eea020095b1ea227c088dbcbe0ebd35bb24b7ad9 +ARG BUILD_PREFIX +ARG BUILD_SUFFIX +ARG VCS_REF + +# The build stage validates VCS_REF before this expression removes 33 characters. +# Remove the final 33 characters from the 40-character VCS_REF. +# This keeps the first seven characters. LABEL org.opencontainers.image.title="HyperShell web console" \ org.opencontainers.image.description="HyperShell web console BFF and static application" \ - org.opencontainers.image.version="0.0.1" + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ + org.opencontainers.image.revision="${VCS_REF}" ENV HOST=0.0.0.0 \ + HYPERSHELL_BUILD_REVISION="${VCS_REF}" \ + HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ HYPERSHELL_API_ORIGIN=http://127.0.0.1:8000 \ HYPERSHELL_API_TIMEOUT_MS=30000 \ NODE_ENV=production \ diff --git a/components/web-console/app/adapters/api/api-version.test.ts b/components/web-console/app/adapters/api/api-version.test.ts new file mode 100644 index 00000000..0096f190 --- /dev/null +++ b/components/web-console/app/adapters/api/api-version.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createApiVersionAdapter } from "./api-version"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +describe("API version adapter", () => { + it("reads the API image version and preserves cancellation", async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValue( + jsonResponse({ + build_time: "2026-09-02T15:00:00Z", + href: "/api/hypershell/v1/metadata", + id: "hypershell", + kind: "API", + version: "v1.6.0-7654321", + }), + ); + const abortController = new AbortController(); + + await expect( + createApiVersionAdapter(fetchImplementation).readVersion( + abortController.signal, + ), + ).resolves.toBe("v1.6.0-7654321"); + expect(fetchImplementation).toHaveBeenCalledWith( + "/api/hypershell/v1/metadata", + { + credentials: "same-origin", + headers: { accept: "application/json" }, + signal: abortController.signal, + }, + ); + }); + + it("rejects a failed metadata response", async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValue(jsonResponse({ error: "unavailable" }, 503)); + + await expect( + createApiVersionAdapter(fetchImplementation).readVersion(), + ).rejects.toThrow("API metadata request failed with 503"); + }); + + it.each([null, {}, { version: "" }, { version: 42 }])( + "rejects metadata without a version: %j", + async (body) => { + const fetchImplementation = vi + .fn() + .mockResolvedValue(jsonResponse(body)); + + await expect( + createApiVersionAdapter(fetchImplementation).readVersion(), + ).rejects.toThrow(/API metadata response/u); + }, + ); +}); diff --git a/components/web-console/app/adapters/api/api-version.ts b/components/web-console/app/adapters/api/api-version.ts new file mode 100644 index 00000000..170ecd05 --- /dev/null +++ b/components/web-console/app/adapters/api/api-version.ts @@ -0,0 +1,38 @@ +/** The API build identity that the user menu needs. */ +export interface ApiVersionReader { + readVersion(signal?: AbortSignal): Promise; +} + +const metadataPath = "/api/hypershell/v1/metadata"; + +function versionFromMetadata(body: unknown): string { + if (typeof body !== "object" || body === null) { + throw new Error("API metadata response is not an object"); + } + const version = (body as { version?: unknown }).version; + if (typeof version !== "string" || version.trim() === "") { + throw new Error("API metadata response has no version"); + } + return version.trim(); +} + +/** Reads the API image version through the same-origin BFF proxy. */ +export function createApiVersionAdapter( + fetchImplementation: typeof globalThis.fetch = globalThis.fetch, +): ApiVersionReader { + return { + async readVersion(signal) { + const response = await fetchImplementation(metadataPath, { + credentials: "same-origin", + headers: { accept: "application/json" }, + ...(signal ? { signal } : {}), + }); + if (!response.ok) { + throw new Error( + `API metadata request failed with ${String(response.status)}`, + ); + } + return versionFromMetadata(await response.json()); + }, + }; +} diff --git a/components/web-console/app/composition/api-version-composition.ts b/components/web-console/app/composition/api-version-composition.ts new file mode 100644 index 00000000..5fd5e123 --- /dev/null +++ b/components/web-console/app/composition/api-version-composition.ts @@ -0,0 +1,4 @@ +import { createApiVersionAdapter } from "../adapters/api/api-version"; + +/** Browser-wide reader for the API image version. */ +export const apiVersionReader = createApiVersionAdapter(); diff --git a/components/web-console/app/composition/browser-runtime-config.test.ts b/components/web-console/app/composition/browser-runtime-config.test.ts index 39ac1f4d..0a681cfe 100644 --- a/components/web-console/app/composition/browser-runtime-config.test.ts +++ b/components/web-console/app/composition/browser-runtime-config.test.ts @@ -19,20 +19,27 @@ function documentWithMeta(content: string | undefined): Document { describe("readBrowserRuntimeConfig", () => { it("reads the sample ratio from the injected meta tag", () => { const config = readBrowserRuntimeConfig( - documentWithMeta('{"tracing":{"sampleRatio":0.25}}'), + documentWithMeta( + '{"build":{"version":"v1.6.0-1234567"},"tracing":{"sampleRatio":0.25}}', + ), ); - expect(config).toEqual({ tracing: { sampleRatio: 0.25 } }); + expect(config).toEqual({ + build: { version: "v1.6.0-1234567" }, + tracing: { sampleRatio: 0.25 }, + }); }); it("samples nothing when the meta tag is absent", () => { expect(readBrowserRuntimeConfig(documentWithMeta(undefined))).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); }); it("fails closed to no tracing when the content is not valid JSON", () => { expect(readBrowserRuntimeConfig(documentWithMeta("not-json"))).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); }); @@ -46,8 +53,32 @@ describe("readBrowserRuntimeConfig", () => { "{}", ]) { expect(readBrowserRuntimeConfig(documentWithMeta(content))).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); } }); + + it("ignores an invalid build version without changing tracing", () => { + expect( + readBrowserRuntimeConfig( + documentWithMeta( + '{"build":{"version":"latest"},"tracing":{"sampleRatio":0.5}}', + ), + ), + ).toEqual({ + build: {}, + tracing: { sampleRatio: 0.5 }, + }); + }); + + it("reads a modified local image build version", () => { + const config = readBrowserRuntimeConfig( + documentWithMeta( + '{"build":{"version":"dev-abcdef0-modified"},"tracing":{"sampleRatio":0}}', + ), + ); + + expect(config.build.version).toBe("dev-abcdef0-modified"); + }); }); diff --git a/components/web-console/app/composition/browser-runtime-config.ts b/components/web-console/app/composition/browser-runtime-config.ts index f49c6756..588e9cc2 100644 --- a/components/web-console/app/composition/browser-runtime-config.ts +++ b/components/web-console/app/composition/browser-runtime-config.ts @@ -5,6 +5,9 @@ const runtimeConfigMetaName = "hypershell-runtime-config"; export interface BrowserRuntimeConfig { + build: { + version?: string; + }; tracing: { /** Fraction of browser-rooted traces to record, 0..1. */ sampleRatio: number; @@ -14,9 +17,18 @@ export interface BrowserRuntimeConfig { // When no config is available the browser records nothing: it must never emit // traces the BFF cannot relay (a dev server or a deployment with tracing off). const disabledRuntimeConfig: BrowserRuntimeConfig = { + build: {}, tracing: { sampleRatio: 0 }, }; +// REL-07 in specs/platform/source-release.spec.md defines this format. +const buildVersionPattern = + /^(?:dev-[0-9a-f]{7}(?:-modified)?|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-[0-9a-f]{7})$/u; + +function isBuildVersion(value: unknown): value is string { + return typeof value === "string" && buildVersionPattern.test(value); +} + function isSampleRatio(value: unknown): value is number { return ( typeof value === "number" && @@ -46,12 +58,15 @@ export function readBrowserRuntimeConfig( } try { const parsed = JSON.parse(content) as { + build?: { version?: unknown }; tracing?: { sampleRatio?: unknown }; }; + const buildVersion = parsed.build?.version; const sampleRatio = parsed.tracing?.sampleRatio; - return isSampleRatio(sampleRatio) - ? { tracing: { sampleRatio } } - : disabledRuntimeConfig; + return { + build: isBuildVersion(buildVersion) ? { version: buildVersion } : {}, + tracing: { sampleRatio: isSampleRatio(sampleRatio) ? sampleRatio : 0 }, + }; } catch { return disabledRuntimeConfig; } diff --git a/components/web-console/app/features/shell/use-api-version.ts b/components/web-console/app/features/shell/use-api-version.ts new file mode 100644 index 00000000..786cfb93 --- /dev/null +++ b/components/web-console/app/features/shell/use-api-version.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiVersionReader } from "../../composition/api-version-composition"; + +export const apiVersionQueryKey = ["api-version"] as const; + +/** Reads the API image version after the browser session is authenticated. */ +export function useApiVersion(enabled: boolean) { + return useQuery({ + enabled, + queryFn: ({ signal }) => apiVersionReader.readVersion(signal), + queryKey: apiVersionQueryKey, + staleTime: 300_000, + }); +} diff --git a/components/web-console/app/features/shell/user-menu.test.tsx b/components/web-console/app/features/shell/user-menu.test.tsx index fb38e735..c84df4ae 100644 --- a/components/web-console/app/features/shell/user-menu.test.tsx +++ b/components/web-console/app/features/shell/user-menu.test.tsx @@ -8,7 +8,14 @@ import { beforeEach, expect, it, vi } from "vitest"; import { englishMessages } from "../../i18n/catalog"; import { UserMenu } from "./user-menu"; -const { getSessionMock } = vi.hoisted(() => ({ getSessionMock: vi.fn() })); +const { getSessionMock, readVersionMock } = vi.hoisted(() => ({ + getSessionMock: vi.fn(), + readVersionMock: vi.fn(), +})); + +vi.mock("../../composition/api-version-composition", () => ({ + apiVersionReader: { readVersion: readVersionMock }, +})); vi.mock("../../composition/session-composition", () => ({ sessionGateway: { getSession: getSessionMock }, @@ -29,17 +36,30 @@ function renderMenu() { ); } +function setBuildVersion(version: string) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "hypershell-runtime-config"); + meta.setAttribute( + "content", + JSON.stringify({ build: { version }, tracing: { sampleRatio: 0 } }), + ); + document.head.append(meta); +} + beforeEach(() => { vi.clearAllMocks(); + readVersionMock.mockResolvedValue("v1.6.0-7654321"); + document.querySelector('meta[name="hypershell-runtime-config"]')?.remove(); }); -it("shows the user name and a full sign-out link", async () => { +it("shows the user name, image versions, and a full sign-out link", async () => { const user = userEvent.setup(); getSessionMock.mockResolvedValue({ authenticated: true, roles: ["hypershell-users"], user: { name: "Ada Lovelace", preferredUsername: "ada" }, }); + setBuildVersion("v1.6.0-1234567"); renderMenu(); @@ -47,12 +67,81 @@ it("shows the user name and a full sign-out link", async () => { await user.click(toggle); const menu = screen.getByRole("menu"); + const consoleVersion = within(menu).getByRole("menuitem", { + name: "Console version v1.6.0-1234567", + }); + const apiVersion = await within(menu).findByRole("menuitem", { + name: "API version v1.6.0-7654321", + }); const logout = within(menu).getByRole("menuitem", { name: "Log out" }); + expect((consoleVersion as HTMLButtonElement).disabled).toBe(true); + expect(consoleVersion.getAttribute("href")).toBeNull(); + expect((apiVersion as HTMLButtonElement).disabled).toBe(true); + expect(apiVersion.getAttribute("href")).toBeNull(); // Sign-out is a real navigation to the BFF endpoint, not a client route, so // the BFF can clear the session and perform RP-initiated Keycloak logout. expect(logout.getAttribute("href")).toBe("/auth/logout"); }); +it("shows unknown versions and keeps logout available", async () => { + const user = userEvent.setup(); + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["hypershell-users"], + user: { name: "Ada Lovelace" }, + }); + readVersionMock.mockRejectedValue(new Error("API unavailable")); + setBuildVersion("latest"); + + renderMenu(); + + await user.click( + await screen.findByRole("button", { name: /Ada Lovelace/u }), + ); + const menu = screen.getByRole("menu"); + expect( + within(menu).getByRole("menuitem", { + name: "Console version unknown", + }), + ).toBeTruthy(); + expect( + within(menu).getByRole("menuitem", { + name: "API version unknown", + }), + ).toBeTruthy(); + expect(within(menu).getByRole("menuitem", { name: "Log out" })).toBeTruthy(); +}); + +it("keeps the build version that it reads when it mounts", async () => { + const user = userEvent.setup(); + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["hypershell-users"], + user: { name: "Ada Lovelace" }, + }); + setBuildVersion("v1.6.0-1234567"); + + renderMenu(); + + const toggle = await screen.findByRole("button", { name: /Ada Lovelace/u }); + document + .querySelector('meta[name="hypershell-runtime-config"]') + ?.setAttribute( + "content", + JSON.stringify({ + build: { version: "v1.6.0-7654321" }, + tracing: { sampleRatio: 0 }, + }), + ); + await user.click(toggle); + + expect( + within(screen.getByRole("menu")).getByRole("menuitem", { + name: "Console version v1.6.0-1234567", + }), + ).toBeTruthy(); +}); + it("falls back to the preferred username, then email, then Account", async () => { getSessionMock.mockResolvedValue({ authenticated: true, @@ -76,5 +165,6 @@ it("renders nothing when unauthenticated", async () => { await vi.waitFor(() => { expect(getSessionMock).toHaveBeenCalled(); }); + expect(readVersionMock).not.toHaveBeenCalled(); expect(container.querySelector("button")).toBeNull(); }); diff --git a/components/web-console/app/features/shell/user-menu.tsx b/components/web-console/app/features/shell/user-menu.tsx index 295d814b..f787fe80 100644 --- a/components/web-console/app/features/shell/user-menu.tsx +++ b/components/web-console/app/features/shell/user-menu.tsx @@ -8,19 +8,30 @@ import { UserIcon } from "@patternfly/react-icons"; import { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; +import { readBrowserRuntimeConfig } from "../../composition/browser-runtime-config"; import { messages } from "../../i18n/messages"; +import { useApiVersion } from "./use-api-version"; import { useSession } from "./use-session"; /** - * Masthead identity menu. Shows the authenticated user's display name and a - * single sign-out action that performs full RP-initiated logout by navigating - * to the BFF `/auth/logout` endpoint (a real navigation, not a client route). - * Renders nothing when unauthenticated or in no-auth mode. + * Masthead identity menu. Shows the user's display name, the console and API + * image versions, and a sign-out action. Sign-out uses the BFF `/auth/logout` + * endpoint so that the BFF can complete RP-initiated logout. The menu renders + * nothing when the user is not authenticated or when no-auth mode is active. */ export function UserMenu() { const intl = useIntl(); const [isOpen, setIsOpen] = useState(false); + const [configuredBuildVersion] = useState( + () => readBrowserRuntimeConfig().build.version, + ); const { data: session } = useSession(); + const { data: apiBuildVersion } = useApiVersion( + session?.authenticated === true, + ); + const unknownVersion = intl.formatMessage(messages.unknownVersion); + const consoleBuildVersion = configuredBuildVersion ?? unknownVersion; + const displayedApiBuildVersion = apiBuildVersion ?? unknownVersion; if (!session?.authenticated) { return null; @@ -55,6 +66,18 @@ export function UserMenu() { )} > + + + + + + diff --git a/components/web-console/app/i18n/messages.ts b/components/web-console/app/i18n/messages.ts index 9d8d1c2a..84e04753 100644 --- a/components/web-console/app/i18n/messages.ts +++ b/components/web-console/app/i18n/messages.ts @@ -6,11 +6,21 @@ export const messages = defineMessages({ defaultMessage: "Account", description: "Fallback label for the identity menu when no name is known.", }, + apiVersion: { + id: "app.apiVersion", + defaultMessage: "API version {version}", + description: "API server image version in the identity menu.", + }, breadcrumbLabel: { id: "app.breadcrumb.ariaLabel", defaultMessage: "Breadcrumb", description: "Accessible label for the application breadcrumb navigation.", }, + consoleVersion: { + id: "app.consoleVersion", + defaultMessage: "Console version {version}", + description: "Web-console image version in the identity menu.", + }, errorBody: { id: "app.error.body", defaultMessage: "Refresh the page to try again.", @@ -70,4 +80,9 @@ export const messages = defineMessages({ description: "Accessible label for the color scheme toggle when dark mode is active.", }, + unknownVersion: { + id: "app.unknownVersion", + defaultMessage: "unknown", + description: "Version value when the web-console image version is absent.", + }, }); diff --git a/components/web-console/bff/src/config.ts b/components/web-console/bff/src/config.ts index 229c0b19..086cb106 100644 --- a/components/web-console/bff/src/config.ts +++ b/components/web-console/bff/src/config.ts @@ -32,6 +32,15 @@ const httpOrigin = z const configSchema = z.object({ HOST: z.string().trim().min(1).default("0.0.0.0"), + // REL-07 in specs/platform/source-release.spec.md defines this format. + HYPERSHELL_BUILD_VERSION: z + .string() + .trim() + .regex( + /^(?:dev-[0-9a-f]{7}(?:-modified)?|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-[0-9a-f]{7})$/u, + "must contain a supported image build version", + ) + .optional(), HYPERSHELL_API_ORIGIN: httpOrigin.default("http://127.0.0.1:8000"), HYPERSHELL_API_TIMEOUT_MS: z.coerce .number() @@ -85,6 +94,7 @@ export interface TracingConfig { export interface ServerConfig { apiOrigin: string; apiTimeoutMs: number; + buildVersion?: string; host: string; logLevel: z.infer["LOG_LEVEL"]; nodeEnv: z.infer["NODE_ENV"]; @@ -105,6 +115,9 @@ export interface ServerConfig { * origins, session secret, and OIDC settings never cross this boundary. */ export interface BrowserRuntimeConfig { + build: { + version?: string; + }; tracing: { /** * Fraction of browser-rooted traces to record, 0..1. It mirrors the BFF @@ -121,6 +134,7 @@ export function browserRuntimeConfig( config: ServerConfig, ): BrowserRuntimeConfig { return { + build: config.buildVersion ? { version: config.buildVersion } : {}, tracing: { sampleRatio: config.tracing?.sampleRatio ?? 0 }, }; } @@ -161,6 +175,7 @@ export function loadConfig( return { apiOrigin: result.data.HYPERSHELL_API_ORIGIN, apiTimeoutMs: result.data.HYPERSHELL_API_TIMEOUT_MS, + buildVersion: result.data.HYPERSHELL_BUILD_VERSION, host: result.data.HOST, logLevel: result.data.LOG_LEVEL, nodeEnv: result.data.NODE_ENV, diff --git a/components/web-console/bff/test/app.test.ts b/components/web-console/bff/test/app.test.ts index 60e2b48a..195f7ee8 100644 --- a/components/web-console/bff/test/app.test.ts +++ b/components/web-console/bff/test/app.test.ts @@ -84,6 +84,7 @@ describe("web-console BFF", () => { const config: ServerConfig = { apiOrigin: `http://127.0.0.1:${String(address.port)}`, apiTimeoutMs: 100, + buildVersion: "v1.6.0-1234567", host: "127.0.0.1", logLevel: "silent", nodeEnv: "test", @@ -152,6 +153,9 @@ describe("web-console BFF", () => { expect(response.statusCode).toBe(200); expect(response.body).toContain('name="hypershell-runtime-config"'); + expect(response.body).toContain( + ""version":"v1.6.0-1234567"", + ); expect(response.body).toContain(""sampleRatio":0"); // The meta tag lands in the head, before the application markup. expect(response.body.indexOf("hypershell-runtime-config")).toBeLessThan( diff --git a/components/web-console/bff/test/config.test.ts b/components/web-console/bff/test/config.test.ts index d2fd10f8..d19070dd 100644 --- a/components/web-console/bff/test/config.test.ts +++ b/components/web-console/bff/test/config.test.ts @@ -6,6 +6,7 @@ describe("loadConfig", () => { HOST: "127.0.0.1", HYPERSHELL_API_ORIGIN: "https://api.example.test/", HYPERSHELL_API_TIMEOUT_MS: "5000", + HYPERSHELL_BUILD_VERSION: "v1.6.0-1234567", LOG_LEVEL: "warn", NODE_ENV: "production", PORT: "8081", @@ -14,6 +15,7 @@ describe("loadConfig", () => { expect(config.apiOrigin).toBe("https://api.example.test"); expect(config.apiTimeoutMs).toBe(5000); + expect(config.buildVersion).toBe("v1.6.0-1234567"); expect(config.host).toBe("127.0.0.1"); expect(config.port).toBe(8081); expect(pathIsAbsolute(config.staticRoot)).toBe(true); @@ -34,6 +36,21 @@ describe("loadConfig", () => { ).toThrow(/HYPERSHELL_API_ORIGIN/u); }); + it("rejects an invalid image build version", () => { + expect(() => loadConfig({ HYPERSHELL_BUILD_VERSION: "latest" })).toThrow( + /HYPERSHELL_BUILD_VERSION/u, + ); + }); + + it("accepts a modified local image build version", () => { + const config = loadConfig({ + HYPERSHELL_BUILD_VERSION: "dev-abcdef0-modified", + STATIC_ROOT: "./public", + }); + + expect(config.buildVersion).toBe("dev-abcdef0-modified"); + }); + it("leaves tracing disabled when no collector endpoint is set", () => { const config = loadConfig({ STATIC_ROOT: "./public" }); @@ -89,6 +106,7 @@ describe("browserRuntimeConfig", () => { }); expect(browserRuntimeConfig(config)).toEqual({ + build: {}, tracing: { sampleRatio: 0.25 }, }); }); @@ -97,6 +115,7 @@ describe("browserRuntimeConfig", () => { const config = loadConfig({ STATIC_ROOT: "./public" }); expect(browserRuntimeConfig(config)).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); }); @@ -111,7 +130,22 @@ describe("browserRuntimeConfig", () => { const serialized = JSON.stringify(browserRuntimeConfig(config)); expect(serialized).not.toContain("collector.example.test"); expect(serialized).not.toContain("a".repeat(64)); - expect(Object.keys(browserRuntimeConfig(config))).toEqual(["tracing"]); + expect(Object.keys(browserRuntimeConfig(config))).toEqual([ + "build", + "tracing", + ]); + }); + + it("exposes the image build version to the browser", () => { + const config = loadConfig({ + HYPERSHELL_BUILD_VERSION: "dev-abcdef0", + STATIC_ROOT: "./public", + }); + + expect(browserRuntimeConfig(config)).toEqual({ + build: { version: "dev-abcdef0" }, + tracing: { sampleRatio: 0 }, + }); }); }); diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 377c6449..e006d8d8 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -15,6 +15,10 @@ "defaultMessage": "Retry", "description": "Action that repeats a failed request." }, + "app.apiVersion": { + "defaultMessage": "API version {version}", + "description": "API server image version in the identity menu." + }, "app.breadcrumb.ariaLabel": { "defaultMessage": "Breadcrumb", "description": "Accessible label for the application breadcrumb navigation." @@ -27,6 +31,10 @@ "defaultMessage": "Copy", "description": "Tooltip for a button that copies text to the clipboard." }, + "app.consoleVersion": { + "defaultMessage": "Console version {version}", + "description": "Web-console image version in the identity menu." + }, "app.error.body": { "defaultMessage": "Refresh the page to try again.", "description": "Recovery guidance shown after an unexpected route failure." @@ -755,6 +763,10 @@ "defaultMessage": "Switch to light mode", "description": "Accessible label for the color scheme toggle when dark mode is active." }, + "app.unknownVersion": { + "defaultMessage": "unknown", + "description": "Version value when the web-console image version is absent." + }, "app.value.notAvailable": { "defaultMessage": "Not available", "description": "Shown when the API does not provide a value." diff --git a/deploy/kind/kustomization.yaml b/deploy/kind/kustomization.yaml index 62e06858..cccb2fab 100644 --- a/deploy/kind/kustomization.yaml +++ b/deploy/kind/kustomization.yaml @@ -141,7 +141,7 @@ patches: value: "--jwk-cert-url=http://keycloak-service.keycloak.svc.cluster.local:8080/realms/hypershell/protocol/openid-connect/certs" - op: add path: /spec/template/spec/containers/0/command/- - value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/openapi,/openapi" + value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/metadata,/api/hypershell/v1/openapi,/openapi" - op: add path: /spec/template/spec/containers/0/command/- value: "--auth-bypass-methods=/grpc.health.v1.Health/,/grpc.reflection.v1alpha.ServerReflection/,/hypershell.v1.GatewayService/WatchGateways,/hypershell.v1.GatewayReleaseService/WatchGatewayReleases,/hypershell.v1.ManagedClusterService/WatchManagedClusters,/hypershell.v1.ManagedDatabaseService/WatchManagedDatabases,/hypershell.v1.GatewayNetworkService/WatchGatewayNetworks" diff --git a/deploy/openshift/kustomization.yaml b/deploy/openshift/kustomization.yaml index 02a4d510..03979747 100644 --- a/deploy/openshift/kustomization.yaml +++ b/deploy/openshift/kustomization.yaml @@ -94,7 +94,7 @@ patches: value: "--jwk-cert-url=$(JWK_CERT_URL)" - op: add path: /spec/template/spec/containers/0/command/- - value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/openapi,/openapi" + value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/metadata,/api/hypershell/v1/openapi,/openapi" - op: add path: /spec/template/spec/containers/0/command/- value: "--auth-bypass-methods=/grpc.health.v1.Health/,/grpc.reflection.v1alpha.ServerReflection/,/hypershell.v1.GatewayService/WatchGateways,/hypershell.v1.GatewayReleaseService/WatchGatewayReleases,/hypershell.v1.ManagedClusterService/WatchManagedClusters,/hypershell.v1.ManagedDatabaseService/WatchManagedDatabases,/hypershell.v1.GatewayNetworkService/WatchGatewayNetworks" diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..a6db6e33 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,95 @@ +# Source Releases + +HyperShell uses Conventional Commits and Release Please. Release Please keeps +one Release PR open against `main`. The PR is not a release candidate tag. It +is the list of changes for the next source release. + +## Pull Request Titles + +Use this form for each pull request title: + +```text +(): +``` + +To keep Jira tracking in the title, put the Jira key after the colon: + +```text +feat(release): [HYPERSHELL-123] add managed source releases +``` + +The conventional type must remain first. Do not put the Jira key before the +type. Release Please cannot parse a squash commit that starts with the Jira +key. + +The allowed types are `feat`, `fix`, `perf`, `refactor`, `docs`, `spec`, +`deps`, `build`, `ci`, `test`, `style`, `chore`, and `revert`. The `feat` type +increments the minor version. The `fix` type increments the patch version. A +`!` marker or a `BREAKING CHANGE` footer increments the major version. Before +`1.0.0`, a breaking change increments the minor version. Other types enter the +next changelog, but they do not open a Release PR by themselves. + +GitHub uses the pull request title as the squash commit subject. Do not change +the subject during merge. + +## Release Operation + +The Release Please workflow runs after a successful `main` E2E workflow. It +opens a Release PR after a feature, fix, or breaking change enters `main`. If a +Release PR is open, later `main` commits update the same PR. The PR updates +`CHANGELOG.md`, `VERSION`, `.release-please-manifest.json`, and +`build-version.env`. + +Keep the Release PR open until the contents are ready. Merge it through the +normal merge queue to start a release. Do not enable automatic merge for this +PR. After the final `main` E2E workflow succeeds, Release Please creates the +`vX.Y.Z` tag and the published GitHub release. Immutable releases must remain +enabled in the repository settings. + +The first manifest version is `0.0.0`. The history baseline is the commit +before this release strategy. Thus, the first feature proposes `0.1.0`, and the +first changelog does not contain older repository history. + +## Release PR Workflow Approval + +The Release Please workflow uses the built-in `GITHUB_TOKEN`. It needs no +repository variable or secret. Its permissions are limited to write access for +contents, issues, and pull requests. + +GitHub puts [workflow runs for an automation-created pull request][github-token] +in an approval-required state. After Release Please opens or updates the +Release PR, a user with write access must open the PR and select **Approve +workflows to run**. Approve the workflow runs for the current revision before +you merge the Release PR. You can wait until the release is ready and approve +only the current revision. + +Events that the built-in token creates do not start later workflows, except for +the documented pull request case. This strategy does not require a later tag or +release workflow. If a later change adds that automation, use a GitHub App or a +personal access token for Release Please. + +[github-token]: https://docs.github.com/en/actions/concepts/security/github_token + +## Failure Recovery + +If release automation fails, correct the cause and run the Release Please +workflow from the Actions page. The workflow uses the committed release files +and can run again safely. Do not create the tag or GitHub release by hand while +this recovery is in progress. + +## Image and Deployment Boundary + +Normal `main` pushes build only components with changed build inputs. Release +PR updates do not build all images. When the Release PR merges, its `VERSION` +change makes the final `main` push build the API server, control plane, and web +console once. + +The build keeps the existing full-SHA registry tags. Argo image bump continues +to select deployment images. This process does not promote images, create +semantic registry aliases, or change Argo configuration. + +Supported local build commands use `dev-` for a clean Git work tree. +They use `dev--modified` when the work tree contains staged, +unstaged, or untracked changes. CI versions never use the `-modified` suffix. +Each image build rejects a revision that is not a full 40-character lowercase +hexadecimal Git SHA. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..0156b062 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "f35eb1120efc25a61f8ad7fd060d15704f3945b3", + "always-update": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "include-component-in-tag": false, + "include-v-in-tag": true, + "include-v-in-release-name": true, + "group-pull-request-title-pattern": "chore${scope}: release${component} ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features", + "hidden": false + }, + { + "type": "fix", + "section": "Bug Fixes", + "hidden": false + }, + { + "type": "perf", + "section": "Performance", + "hidden": false + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": false + }, + { + "type": "docs", + "section": "Documentation", + "hidden": false + }, + { + "type": "spec", + "section": "Specifications", + "hidden": false + }, + { + "type": "deps", + "section": "Dependencies", + "hidden": false + }, + { + "type": "build", + "section": "Build System", + "hidden": false + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": false + }, + { + "type": "test", + "section": "Tests", + "hidden": false + }, + { + "type": "style", + "section": "Styles", + "hidden": false + }, + { + "type": "chore", + "section": "Maintenance", + "hidden": false + }, + { + "type": "revert", + "section": "Reverts", + "hidden": false + } + ], + "packages": { + ".": { + "release-type": "simple", + "version-file": "VERSION", + "extra-files": [ + "build-version.env" + ] + } + } +} diff --git a/scripts/build-version.sh b/scripts/build-version.sh new file mode 100755 index 00000000..c09d6f8c --- /dev/null +++ b/scripts/build-version.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-local}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "${script_dir}/.." && pwd)" +git_work_tree="${HYPERSHELL_GIT_WORK_TREE:-${repository_root}}" +revision="${HYPERSHELL_VCS_REF:-$(git -C "${git_work_tree}" rev-parse HEAD 2>/dev/null || true)}" +revision="${revision,,}" + +if [[ ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then + echo "build version error: the revision must be a full 40-character Git SHA" >&2 + exit 1 +fi + +case "${mode}" in + local) + prefix=dev + dirty_state="${HYPERSHELL_GIT_DIRTY:-}" + if [[ -z "${dirty_state}" ]]; then + if ! work_tree_status="$(git -C "${git_work_tree}" status --porcelain --untracked-files=normal 2>/dev/null)"; then + echo "build version error: the Git work tree could not be inspected" >&2 + exit 1 + fi + if [[ -n "${work_tree_status}" ]]; then + dirty_state=1 + else + dirty_state=0 + fi + fi + case "${dirty_state}" in + 0) + suffix="" + ;; + 1) + suffix=-modified + ;; + *) + echo "build version error: HYPERSHELL_GIT_DIRTY must be 0 or 1" >&2 + exit 1 + ;; + esac + ;; + ci) + version_file="${HYPERSHELL_VERSION_FILE:-${repository_root}/VERSION}" + version="$(tr -d '\r\n' < "${version_file}")" + if [[ ! "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "build version error: VERSION must contain one stable semantic version" >&2 + exit 1 + fi + prefix="v${version}" + suffix="" + ;; + *) + echo "build version error: mode must be local or ci" >&2 + exit 1 + ;; +esac + +printf '%s-%s%s\n' "${prefix}" "${revision:0:7}" "${suffix}" diff --git a/scripts/check_image_build_policy.py b/scripts/check_image_build_policy.py new file mode 100644 index 00000000..1324eb12 --- /dev/null +++ b/scripts/check_image_build_policy.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Check image identity and event-specific component build rules.""" + +from pathlib import Path +import re +import sys + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +COMPONENTS = { + "api-server": { + "dockerfile": "components/api-server/Dockerfile", + "image": "hypershell-api-server-main", + }, + "control-plane": { + "dockerfile": "components/control-plane/Dockerfile", + "image": "hypershell-control-plane-main", + }, + "web-console": { + "dockerfile": "components/web-console/Dockerfile", + "image": "hypershell-web-console-main", + }, +} +NON_PUSH_RELEASE_FILES = ("VERSION", "CHANGELOG.md", "build-version.env") +SHORT_REVISION_TRIM = 33 +_ASSIGNMENT_PATTERN = ( + r"(?:^|\s){name}\s*=\s*(?:\"(?P[^\"]*)\"|" + r"'(?P[^']*)'|(?P[^\s]+))" +) +_BUILD_VERSION_PATTERN = re.compile( + r"(?:\$BUILD_PREFIX|\$\{BUILD_PREFIX\})-" + r"\$\{VCS_REF%(?P\?+)\}" + r"\$\{BUILD_SUFFIX\}" +) + + +def _logical_instructions(dockerfile: str) -> list[str]: + """Return Dockerfile instructions with joined continuation lines.""" + joined = re.sub(r"\\[ \t]*\n[ \t]*", " ", dockerfile) + return [ + line.strip() + for line in joined.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def _assignment_values(instructions: list[str], name: str) -> list[str]: + """Return all values for one Dockerfile assignment.""" + pattern = re.compile(_ASSIGNMENT_PATTERN.format(name=re.escape(name))) + values: list[str] = [] + for instruction in instructions: + for match in pattern.finditer(instruction): + values.append( + match.group("double") + or match.group("single") + or match.group("bare") + ) + return values + + +def _is_variable_reference(value: str, name: str) -> bool: + return value in {f"${name}", f"${{{name}}}"} + + +def image_metadata_errors(relative_path: str, dockerfile: str) -> list[str]: + """Return errors for image identity metadata.""" + errors: list[str] = [] + instructions = _logical_instructions(dockerfile) + + for name, label in ( + ("org.opencontainers.image.version", "OCI build version"), + ("HYPERSHELL_BUILD_VERSION", "runtime build version"), + ): + values = _assignment_values(instructions, name) + if len(values) != 1: + errors.append(f"{relative_path} must set one {label}") + continue + match = _BUILD_VERSION_PATTERN.fullmatch(values[0]) + if match is None: + errors.append( + f"{relative_path} {label} must combine BUILD_PREFIX, a shortened VCS_REF, and BUILD_SUFFIX" + ) + continue + if len(match.group("trim")) != SHORT_REVISION_TRIM: + errors.append( + f"{relative_path} {label} must shorten the revision to seven characters" + ) + + for name, label in ( + ("org.opencontainers.image.revision", "OCI revision"), + ("HYPERSHELL_BUILD_REVISION", "runtime revision"), + ): + values = _assignment_values(instructions, name) + if len(values) != 1 or not _is_variable_reference(values[0], "VCS_REF"): + errors.append(f"{relative_path} must set {label} from VCS_REF") + + return errors + + +def check_repository(root: Path) -> list[str]: + """Return image build policy errors.""" + errors: list[str] = [] + for component, values in COMPONENTS.items(): + for event in ("push", "pull-request", "merge-queue"): + relative_path = f".tekton/hypershell-{component}-main-{event}.yaml" + text = (root / relative_path).read_text(encoding="utf-8") + if "VCS_REF={{revision}}" not in text: + errors.append(f"{relative_path} must pass the full revision") + if "value: build-version.env" not in text: + errors.append(f"{relative_path} must use build-version.env") + if event == "push": + if '"VERSION".pathChanged()' not in text: + errors.append(f"{relative_path} must build for a VERSION change") + expected_tag = f"{values['image']}:{{{{revision}}}}" + else: + for release_file in NON_PUSH_RELEASE_FILES: + if f'"{release_file}".pathChanged()' in text: + errors.append( + f"{relative_path} must not build for {release_file}" + ) + tag_prefix = ( + "on-pr-" if event == "pull-request" else "on-merge-queue-" + ) + expected_tag = f"{values['image']}:{tag_prefix}{{{{revision}}}}" + if expected_tag not in text: + errors.append(f"{relative_path} must keep its current revision tag") + + dockerfile_path = root / values["dockerfile"] + dockerfile = dockerfile_path.read_text(encoding="utf-8") + errors.extend(image_metadata_errors(values["dockerfile"], dockerfile)) + + e2e = (root / ".github/workflows/e2e.yml").read_text(encoding="utf-8") + if "release_version_changed=false" not in e2e: + errors.append("the E2E plan must identify a release-version push") + if '[[ "${EVENT_NAME}" == "push" ]]' not in e2e: + errors.append("the release image rule must check for a push event") + if 'grep -qx "VERSION"' not in e2e: + errors.append("the release image rule must check the VERSION file") + return errors + + +def main() -> int: + errors = check_repository(REPOSITORY_ROOT) + for error in errors: + print(f"image build policy error: {error}", file=sys.stderr) + if errors: + return 1 + print("Image build policy is valid.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/kind/build-images.sh b/scripts/kind/build-images.sh index 37e75416..3a8c9db3 100755 --- a/scripts/kind/build-images.sh +++ b/scripts/kind/build-images.sh @@ -33,20 +33,46 @@ else info "Building from working tree ($(git rev-parse --short HEAD))" fi +build_vcs_ref="$(git -C "${BUILD_DIR}" rev-parse HEAD)" +build_prefix=dev +local_build_version="$( + HYPERSHELL_GIT_WORK_TREE="${BUILD_DIR}" \ + HYPERSHELL_VCS_REF="${build_vcs_ref}" \ + "${REPO_ROOT}/scripts/build-version.sh" local +)" +case "${local_build_version}" in + "dev-${build_vcs_ref:0:7}") build_suffix="" ;; + "dev-${build_vcs_ref:0:7}-modified") build_suffix=-modified ;; + *) + error "Unexpected local build version: ${local_build_version}" + exit 1 + ;; +esac + info "Building API server..." ${CONTAINER_ENGINE} build -t "${api_server_local}" \ -f "${BUILD_DIR}/components/api-server/Dockerfile" \ - --build-arg GIT_VERSION="${build_version}" \ + --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg BUILD_SUFFIX="${build_suffix}" \ + --build-arg VCS_REF="${build_vcs_ref}" \ --build-arg BUILD_TIME="${build_time}" \ - "${BUILD_DIR}/components/api-server" + "${BUILD_DIR}" info "Building control plane..." ${CONTAINER_ENGINE} build -t "${control_plane_local}" \ - -f "${BUILD_DIR}/components/control-plane/Dockerfile" "${BUILD_DIR}" + -f "${BUILD_DIR}/components/control-plane/Dockerfile" \ + --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg BUILD_SUFFIX="${build_suffix}" \ + --build-arg VCS_REF="${build_vcs_ref}" \ + "${BUILD_DIR}" info "Building web console..." ${CONTAINER_ENGINE} build -t "${web_console_local}" \ - -f "${BUILD_DIR}/components/web-console/Dockerfile" "${BUILD_DIR}" + -f "${BUILD_DIR}/components/web-console/Dockerfile" \ + --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg BUILD_SUFFIX="${build_suffix}" \ + --build-arg VCS_REF="${build_vcs_ref}" \ + "${BUILD_DIR}" success "All images built" diff --git a/scripts/kind/swap-component.sh b/scripts/kind/swap-component.sh index 226c6ffe..6d8c2d69 100755 --- a/scripts/kind/swap-component.sh +++ b/scripts/kind/swap-component.sh @@ -9,6 +9,25 @@ require_cluster ACTION="${1:-}" COMPONENT="${2:-}" +VCS_REF="$(git -C "${REPO_ROOT}" rev-parse HEAD)" +LOCAL_BUILD_VERSION="$( + HYPERSHELL_GIT_WORK_TREE="${REPO_ROOT}" \ + HYPERSHELL_VCS_REF="${VCS_REF}" \ + "${REPO_ROOT}/scripts/build-version.sh" local +)" +case "${LOCAL_BUILD_VERSION}" in + "dev-${VCS_REF:0:7}") BUILD_SUFFIX="" ;; + "dev-${VCS_REF:0:7}-modified") BUILD_SUFFIX=-modified ;; + *) + error "Unexpected local build version: ${LOCAL_BUILD_VERSION}" + exit 1 + ;; +esac +BUILD_ARGS=( + --build-arg "BUILD_PREFIX=dev" + --build-arg "BUILD_SUFFIX=${BUILD_SUFFIX}" + --build-arg "VCS_REF=${VCS_REF}" +) if [[ -z "${ACTION}" ]] || [[ -z "${COMPONENT}" ]]; then error "Usage: swap-component.sh up|down " @@ -23,8 +42,8 @@ case "${COMPONENT}" in LOCAL_IMAGE="${api_server_local}" BASELINE_IMAGE="${api_server_ref}" DOCKERFILE="components/api-server/Dockerfile" - BUILD_CONTEXT="components/api-server" - BUILD_ARGS=(--build-arg "GIT_VERSION=${build_version}" --build-arg "BUILD_TIME=${build_time}") + BUILD_CONTEXT="." + BUILD_ARGS+=(--build-arg "BUILD_TIME=${build_time}") ;; control-plane) DEPLOYMENT="hypershell-controller" @@ -33,7 +52,6 @@ case "${COMPONENT}" in BASELINE_IMAGE="${control_plane_ref}" DOCKERFILE="components/control-plane/Dockerfile" BUILD_CONTEXT="." - BUILD_ARGS=() ;; web-console) DEPLOYMENT="hypershell-web-console" @@ -42,7 +60,6 @@ case "${COMPONENT}" in BASELINE_IMAGE="${web_console_ref}" DOCKERFILE="components/web-console/Dockerfile" BUILD_CONTEXT="." - BUILD_ARGS=() ;; *) error "Unknown component: ${COMPONENT}" diff --git a/scripts/release_policy.py b/scripts/release_policy.py new file mode 100644 index 00000000..b3187887 --- /dev/null +++ b/scripts/release_policy.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Check the repository source-release policy.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +TITLE_FORM = "(): " +JIRA_TITLE_EXAMPLE = "feat: [HYPERSHELL-123] " +ALLOWED_TYPES = ( + "build", + "chore", + "ci", + "deps", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "spec", + "style", + "test", +) +_TYPE_PATTERN = "|".join(ALLOWED_TYPES) +_JIRA_KEY_PATTERN = r"\[HYPERSHELL-[1-9][0-9]*\]" +_TITLE_PATTERN = re.compile( + rf"^(?P{_TYPE_PATTERN})" + r"(?:\((?P[a-z0-9][a-z0-9._/-]*)\))?" + r"(?P!)?: " + rf"(?:(?P{_JIRA_KEY_PATTERN}) )?" + r"(?P(?!\[HYPERSHELL-)\S(?:.*\S)?)$" +) +_SEMVER_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") +_RELEASE_TITLE_PATTERN = re.compile(r"^chore\(main\): release (?P" + _SEMVER_PATTERN.pattern + r")$") +_BREAKING_FOOTER_PATTERN = re.compile( + r"(?m)^BREAKING(?: |-)+CHANGE:\s+\S" +) + + +class ReleasePolicyError(Exception): + """Report a release policy input error.""" + + +def title_error(title: str) -> str | None: + """Return an error for an invalid pull request title.""" + if _TITLE_PATTERN.fullmatch(title): + return None + allowed = ", ".join(ALLOWED_TYPES) + return ( + f"title must use {TITLE_FORM}; an optional Jira key must follow the " + f"colon, for example {JIRA_TITLE_EXAMPLE}; allowed types: {allowed}" + ) + + +def is_releasing_commit(message: str) -> bool: + """Return true when one commit must run Release Please.""" + header = message.splitlines()[0] if message else "" + if _RELEASE_TITLE_PATTERN.fullmatch(header): + return True + match = _TITLE_PATTERN.fullmatch(header) + if match and ( + match.group("type") in {"feat", "fix"} or match.group("breaking") + ): + return True + return _BREAKING_FOOTER_PATTERN.search(message) is not None + + +def should_run_release(open_release_pr: bool, messages: list[str]) -> bool: + """Return true when the release workflow must run.""" + return open_release_pr or any(is_releasing_commit(message) for message in messages) + + +def _git_output(*arguments: str) -> str: + result = subprocess.run( + ("git", *arguments), + cwd=REPOSITORY_ROOT, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "git command failed" + raise ReleasePolicyError(detail) + return result.stdout + + +def _load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleasePolicyError(f"cannot read {path}: {error}") from error + + +def _release_base(head_ref: str) -> str: + tags = _git_output( + "tag", "--merged", head_ref, "--sort=-version:refname", "--list", "v[0-9]*" + ).splitlines() + for tag in tags: + if re.fullmatch(r"v" + _SEMVER_PATTERN.pattern, tag): + return tag + + config = _load_json(REPOSITORY_ROOT / "release-please-config.json") + if not isinstance(config, dict): + raise ReleasePolicyError("release-please-config.json must contain an object") + baseline = config.get("bootstrap-sha") + if not isinstance(baseline, str) or not re.fullmatch(r"[0-9a-f]{40}", baseline): + raise ReleasePolicyError("release-please-config.json has no valid bootstrap-sha") + return baseline + + +def _messages_since_release(head_ref: str) -> list[str]: + base_ref = _release_base(head_ref) + output = _git_output("log", "--format=%B%x00", f"{base_ref}..{head_ref}") + return [message.strip() for message in output.split("\0") if message.strip()] + + +def release_file_errors(root: Path) -> list[str]: + """Return all release-file consistency errors.""" + errors: list[str] = [] + try: + version = (root / "VERSION").read_text(encoding="utf-8").strip() + except OSError as error: + return [f"cannot read VERSION: {error}"] + if not _SEMVER_PATTERN.fullmatch(version): + errors.append("VERSION must contain one stable semantic version") + + try: + build_lines = (root / "build-version.env").read_text( + encoding="utf-8" + ).splitlines() + except OSError as error: + errors.append(f"cannot read build-version.env: {error}") + build_lines = [] + prefixes = [line.removeprefix("BUILD_PREFIX=") for line in build_lines if line.startswith("BUILD_PREFIX=")] + if prefixes != [f"v{version}"]: + errors.append("build-version.env BUILD_PREFIX must equal v plus VERSION") + + try: + manifest = _load_json(root / ".release-please-manifest.json") + except ReleasePolicyError as error: + errors.append(str(error)) + manifest = None + if not isinstance(manifest, dict) or manifest.get(".") != version: + errors.append("the release manifest root version must equal VERSION") + + try: + config = _load_json(root / "release-please-config.json") + except ReleasePolicyError as error: + errors.append(str(error)) + config = None + if isinstance(config, dict): + packages = config.get("packages") + root_package = packages.get(".") if isinstance(packages, dict) else None + if not isinstance(root_package, dict): + errors.append("the Release Please root package is missing") + else: + if root_package.get("release-type") != "simple": + errors.append("the Release Please root package must use the simple type") + if root_package.get("version-file") != "VERSION": + errors.append("Release Please must manage VERSION") + if "build-version.env" not in root_package.get("extra-files", []): + errors.append("Release Please must manage build-version.env") + if config.get("bump-minor-pre-major") is not True: + errors.append("breaking changes before 1.0.0 must increment the minor version") + if config.get("bump-patch-for-minor-pre-major") is not False: + errors.append("features before 1.0.0 must increment the minor version") + if config.get("always-update") is not True: + errors.append("Release Please must update an open Release PR") + if config.get("include-v-in-tag") is not True: + errors.append("release tags must have a v prefix") + sections = config.get("changelog-sections") + visible_types = { + item.get("type") + for item in sections + if isinstance(item, dict) and item.get("hidden") is False + } if isinstance(sections, list) else set() + if visible_types != set(ALLOWED_TYPES): + errors.append("each allowed commit type must have a visible changelog section") + elif config is not None: + errors.append("release-please-config.json must contain an object") + return errors + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + title = commands.add_parser("check-title", help="check one pull request title") + title.add_argument("title") + + gate = commands.add_parser("should-run", help="check whether Release Please must run") + gate.add_argument("--head-ref", default="HEAD") + gate.add_argument("--open-release-pr", choices=("true", "false"), default="false") + + files = commands.add_parser("check-files", help="check managed release files") + files.add_argument("--root", type=Path, default=REPOSITORY_ROOT) + return parser + + +def main() -> int: + arguments = _build_parser().parse_args() + try: + if arguments.command == "check-title": + error = title_error(arguments.title) + if error: + print(error, file=sys.stderr) + return 1 + return 0 + if arguments.command == "should-run": + messages = _messages_since_release(arguments.head_ref) + decision = should_run_release(arguments.open_release_pr == "true", messages) + print(str(decision).lower()) + return 0 + errors = release_file_errors(arguments.root) + for error in errors: + print(f"release policy error: {error}", file=sys.stderr) + return 1 if errors else 0 + except ReleasePolicyError as error: + print(f"release policy error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdk-generator/parser.go b/scripts/sdk-generator/parser.go index b253889a..b9d4a64e 100644 --- a/scripts/sdk-generator/parser.go +++ b/scripts/sdk-generator/parser.go @@ -21,6 +21,9 @@ func parseSpec(specPath, apiPrefix string) (*Spec, error) { resourceViews := primaryCollectionViews(document, apiPrefix) resources := make([]Resource, 0, len(resourceViews)) for _, view := range resourceViews { + if extensionIsTrue(view.Extensions, "x-sdk-exclude") { + continue + } schema := document.Schema(view.SchemaRef) if schema == nil || schema.Name == "" { continue @@ -140,7 +143,7 @@ func scopedCollectionViews(document *ir.Document, apiPrefix string) []*ir.Resour var result []*ir.ResourceView seen := make(map[string]bool) for _, view := range document.ResourceViews { - if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) || len(view.ScopeParameters) == 0 { + if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) || len(view.ScopeParameters) == 0 || extensionIsTrue(view.Extensions, "x-sdk-exclude") { continue } remainder := strings.TrimPrefix(view.Path, strings.TrimSuffix(apiPrefix, "/")+"/") @@ -504,7 +507,7 @@ func projectFields(document *ir.Document, schemaRef string, includeReadOnly bool func primaryCollectionViews(document *ir.Document, apiPrefix string) []*ir.ResourceView { bySchema := make(map[string]*ir.ResourceView) for _, view := range document.ResourceViews { - if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) { + if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) || extensionIsTrue(view.Extensions, "x-sdk-exclude") { continue } remainder := strings.TrimPrefix(view.Path, strings.TrimSuffix(apiPrefix, "/")+"/") diff --git a/scripts/sdk-generator/parser_test.go b/scripts/sdk-generator/parser_test.go index 80ac38ec..26226e38 100644 --- a/scripts/sdk-generator/parser_test.go +++ b/scripts/sdk-generator/parser_test.go @@ -69,3 +69,17 @@ func TestParseSpecProjectsScopedServiceAccountResource(t *testing.T) { } } } + +func TestParseSpecExcludesSingletonMetadata(t *testing.T) { + specPath := filepath.Join("..", "..", "components", "api-server", "openapi", "openapi.yaml") + spec, err := parseSpec(specPath, "/api/hypershell/v1") + if err != nil { + t.Fatalf("parse spec: %v", err) + } + + for _, resource := range spec.Resources { + if resource.Name == "ServiceMetadata" { + t.Fatal("singleton metadata must not become a CRUD SDK resource") + } + } +} diff --git a/scripts/test_build_version.py b/scripts/test_build_version.py new file mode 100644 index 00000000..39df8727 --- /dev/null +++ b/scripts/test_build_version.py @@ -0,0 +1,138 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("build-version.sh") +REVISION = "abcdef0123456789abcdef0123456789abcdef01" + + +class BuildVersionTest(unittest.TestCase): + def run_script(self, mode: str, **environment: str): + command_environment = os.environ.copy() + command_environment.pop("HYPERSHELL_GIT_DIRTY", None) + command_environment.pop("HYPERSHELL_GIT_WORK_TREE", None) + command_environment.update(environment) + return subprocess.run( + ("bash", str(SCRIPT_PATH), mode), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=command_environment, + ) + + def test_local_version_uses_dev_prefix(self): + result = self.run_script( + "local", HYPERSHELL_GIT_DIRTY="0", HYPERSHELL_VCS_REF=REVISION + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("dev-abcdef0\n", result.stdout) + + def test_local_version_marks_all_work_tree_change_types(self): + for change_type in ("staged", "unstaged", "untracked"): + with self.subTest(change_type=change_type): + with tempfile.TemporaryDirectory() as temporary_directory: + work_tree = Path(temporary_directory) + self.run_git(work_tree, "init", "--quiet") + tracked_file = work_tree / "tracked.txt" + tracked_file.write_text("original\n", encoding="utf-8") + self.run_git(work_tree, "add", "tracked.txt") + self.run_git( + work_tree, + "-c", + "user.name=Build Test", + "-c", + "user.email=build-test@example.test", + "commit", + "--quiet", + "-m", + "initial", + ) + revision = self.run_git( + work_tree, "rev-parse", "HEAD" + ).stdout.strip() + clean_result = self.run_script( + "local", + HYPERSHELL_GIT_WORK_TREE=str(work_tree), + HYPERSHELL_VCS_REF=revision, + ) + self.assertEqual( + 0, clean_result.returncode, clean_result.stderr + ) + self.assertEqual( + f"dev-{revision[:7]}\n", clean_result.stdout + ) + + if change_type == "untracked": + (work_tree / "untracked.txt").write_text( + "new\n", encoding="utf-8" + ) + else: + tracked_file.write_text("changed\n", encoding="utf-8") + if change_type == "staged": + self.run_git(work_tree, "add", "tracked.txt") + + result = self.run_script( + "local", + HYPERSHELL_GIT_WORK_TREE=str(work_tree), + HYPERSHELL_VCS_REF=revision, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(f"dev-{revision[:7]}-modified\n", result.stdout) + + def test_ci_version_uses_version_file(self): + with tempfile.TemporaryDirectory() as temporary_directory: + version_file = Path(temporary_directory) / "VERSION" + version_file.write_text("1.6.0\n", encoding="utf-8") + result = self.run_script( + "ci", + HYPERSHELL_GIT_DIRTY="1", + HYPERSHELL_VCS_REF=REVISION, + HYPERSHELL_VERSION_FILE=str(version_file), + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("v1.6.0-abcdef0\n", result.stdout) + + def test_rejects_an_abbreviated_revision(self): + result = self.run_script( + "local", HYPERSHELL_GIT_DIRTY="0", HYPERSHELL_VCS_REF="abcdef0" + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("full 40-character Git SHA", result.stderr) + + def test_rejects_an_invalid_dirty_state(self): + result = self.run_script( + "local", HYPERSHELL_GIT_DIRTY="yes", HYPERSHELL_VCS_REF=REVISION + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("HYPERSHELL_GIT_DIRTY must be 0 or 1", result.stderr) + + def test_rejects_an_invalid_ci_version(self): + with tempfile.TemporaryDirectory() as temporary_directory: + version_file = Path(temporary_directory) / "VERSION" + version_file.write_text("v1.6.0\n", encoding="utf-8") + result = self.run_script( + "ci", + HYPERSHELL_VCS_REF=REVISION, + HYPERSHELL_VERSION_FILE=str(version_file), + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("stable semantic version", result.stderr) + + @staticmethod + def run_git(work_tree: Path, *arguments: str): + return subprocess.run( + ("git", "-C", str(work_tree), *arguments), + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_check_image_build_policy.py b/scripts/test_check_image_build_policy.py new file mode 100644 index 00000000..499fd7d1 --- /dev/null +++ b/scripts/test_check_image_build_policy.py @@ -0,0 +1,66 @@ +import importlib.util +from pathlib import Path +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("check_image_build_policy.py") +SPEC = importlib.util.spec_from_file_location("check_image_build_policy", SCRIPT_PATH) +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) + + +class ImageMetadataPolicyTest(unittest.TestCase): + def test_accepts_reordered_assignments_and_different_quotes(self): + trim = "?" * 33 + dockerfile = f""" + # syntax=docker/dockerfile:1 + ENV HYPERSHELL_BUILD_REVISION=$VCS_REF \\ + HYPERSHELL_BUILD_VERSION='${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}' + LABEL org.opencontainers.image.revision='${{VCS_REF}}' \\ + org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}" + """ + + self.assertEqual([], POLICY.image_metadata_errors("Dockerfile", dockerfile)) + + def test_reports_missing_assignments(self): + errors = POLICY.image_metadata_errors("Dockerfile", "FROM scratch\n") + + self.assertEqual(4, len(errors)) + self.assertIn("Dockerfile must set one OCI build version", errors) + self.assertIn("Dockerfile must set one runtime build version", errors) + self.assertIn("Dockerfile must set OCI revision from VCS_REF", errors) + self.assertIn("Dockerfile must set runtime revision from VCS_REF", errors) + + def test_reports_an_invalid_short_revision(self): + trim = "?" * 32 + dockerfile = f""" + ENV HYPERSHELL_BUILD_VERSION="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}" \\ + HYPERSHELL_BUILD_REVISION="${{VCS_REF}}" + LABEL org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}" \\ + org.opencontainers.image.revision="${{VCS_REF}}" + """ + + errors = POLICY.image_metadata_errors("Dockerfile", dockerfile) + + self.assertEqual(2, len(errors)) + for error in errors: + self.assertIn("must shorten the revision to seven characters", error) + + def test_requires_the_local_build_suffix(self): + trim = "?" * 33 + dockerfile = f""" + ENV HYPERSHELL_BUILD_VERSION="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" \\ + HYPERSHELL_BUILD_REVISION="${{VCS_REF}}" + LABEL org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" \\ + org.opencontainers.image.revision="${{VCS_REF}}" + """ + + errors = POLICY.image_metadata_errors("Dockerfile", dockerfile) + + self.assertEqual(2, len(errors)) + for error in errors: + self.assertIn("and BUILD_SUFFIX", error) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_policy.py b/scripts/test_release_policy.py new file mode 100644 index 00000000..42ec60f0 --- /dev/null +++ b/scripts/test_release_policy.py @@ -0,0 +1,122 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("release_policy.py") +SPEC = importlib.util.spec_from_file_location("release_policy", SCRIPT_PATH) +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) + + +class PullRequestTitleTest(unittest.TestCase): + def test_accepts_supported_titles(self): + for title in ( + "feat: add releases", + "fix(api): return the version", + "feat(web-console)!: change the menu", + "feat(release): [HYPERSHELL-123] add releases", + "chore(main): release 0.1.0", + ): + with self.subTest(title=title): + self.assertIsNone(POLICY.title_error(title)) + + def test_rejects_invalid_titles(self): + for title in ( + "Add releases", + "Feature: add releases", + "feat(API): add releases", + "[HYPERSHELL-123] feat(release): add releases", + "feat(release): [HYPERSHELL-ABC] add releases", + "feat: ", + "feat add releases", + "merge: add releases", + ): + with self.subTest(title=title): + self.assertIsNotNone(POLICY.title_error(title)) + + +class ReleaseGateTest(unittest.TestCase): + def test_runs_for_release_changes(self): + for message in ( + "feat: add releases", + "fix(api): return the version", + "refactor(api)!: change the response", + "refactor(api): change the response\n\nBREAKING CHANGE: clients must update", + "chore(main): release 1.2.3", + ): + with self.subTest(message=message): + self.assertTrue(POLICY.is_releasing_commit(message)) + + def test_does_not_run_for_maintenance_only(self): + messages = ["docs: explain releases", "chore(deps): update a tool"] + self.assertFalse(POLICY.should_run_release(False, messages)) + + def test_updates_an_open_release_pull_request(self): + self.assertTrue(POLICY.should_run_release(True, ["docs: explain releases"])) + + +class ReleaseFilesTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + (self.root / "VERSION").write_text("1.2.3\n", encoding="utf-8") + (self.root / "build-version.env").write_text( + "# x-release-please-start-version\n" + "BUILD_PREFIX=v1.2.3\n" + "# x-release-please-end\n", + encoding="utf-8", + ) + (self.root / ".release-please-manifest.json").write_text( + json.dumps({".": "1.2.3"}), encoding="utf-8" + ) + config = { + "always-update": True, + "bump-minor-pre-major": True, + "bump-patch-for-minor-pre-major": False, + "include-v-in-tag": True, + "changelog-sections": [ + {"type": commit_type, "section": commit_type, "hidden": False} + for commit_type in POLICY.ALLOWED_TYPES + ], + "packages": { + ".": { + "release-type": "simple", + "version-file": "VERSION", + "extra-files": ["build-version.env"], + } + }, + } + (self.root / "release-please-config.json").write_text( + json.dumps(config), encoding="utf-8" + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_accepts_consistent_files(self): + self.assertEqual([], POLICY.release_file_errors(self.root)) + + def test_rejects_a_build_prefix_mismatch(self): + (self.root / "build-version.env").write_text( + "BUILD_PREFIX=v1.2.2\n", encoding="utf-8" + ) + self.assertIn( + "build-version.env BUILD_PREFIX must equal v plus VERSION", + POLICY.release_file_errors(self.root), + ) + + def test_rejects_a_manifest_mismatch(self): + (self.root / ".release-please-manifest.json").write_text( + json.dumps({".": "1.2.2"}), encoding="utf-8" + ) + self.assertIn( + "the release manifest root version must equal VERSION", + POLICY.release_file_errors(self.root), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 7228737a..b2a93323 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-08-31 (Keycloak event-storm KC-ES-W1 complete) -**Spec corpus**: 40 spec files; the coverage table tracks 32 analyzed feature/spec groups after adding OpenShell Gateway Console -**Codebase commit**: working tree (Keycloak event-storm KC-ES-W1 complete) +**Last analyzed**: 2026-09-02 (source-release build safeguards complete) +**Spec corpus**: 44 spec files; the coverage table tracks 33 analyzed feature/spec groups +**Codebase commit**: working tree (source-release build safeguards complete) ### Coverage Summary @@ -72,16 +72,17 @@ skills/ | Platform - Local Development | 1 | 25 | 23 | 0 | 1 | 1 | 96% | | Platform - E2E Testing | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Platform - OIDC Integration | 1 | 7 | 6 | 1 | 0 | 0 | 93% | +| Platform - Source Release | 1 | 12 | 12 | 0 | 0 | 0 | 100% | | Web Console - Architecture | 1 | 28 | 21 | 5 | 2 | 0 | 86% | | Security - RBAC Enforcement | 1 | 13 | 11 | 0 | 0 | 2 | 85% | | Standards | 13 | 0 | 0 | 0 | 0 | 0 | N/A | -| **TOTAL** | **32** | **225** | **176** | **18** | **26** | **5** | **82%** | +| **TOTAL** | **33** | **237** | **188** | **18** | **26** | **5** | **83%** | ### Spec Dependency Order ``` Layer 0 (roots): data-model, standards/* -Layer 1: control-plane, local-development, web-console architecture +Layer 1: control-plane, local-development, source-release, web-console architecture Layer 2: openshell-gateway (core) Layer 3: openshell-gateway-database, openshell-gateway-tls Layer 4: openshell-gateway-oidc (depends on TLS for trusted CA) @@ -99,6 +100,30 @@ Layer 7: web-console/architecture (depends on data-model, security, UI ## Gap Table +### source-release.spec.md + +| # | Requirement | Status | Gap | Code Location | Wave | +|---|-------------|--------|-----|---------------|------| +| REL-01 | Conventional pull request titles | Present | - | `scripts/release_policy.py`, `.github/workflows/lint.yml` | REL-W1 | +| REL-02 | Managed Release PR | Present | - | `.github/workflows/release-please.yml`, `release-please-config.json` | REL-W1 | +| REL-03 | Release files | Present | - | `VERSION`, `CHANGELOG.md`, `build-version.env`, `.release-please-manifest.json` | REL-W1 | +| REL-04 | Release publication | Present | - | `.github/workflows/release-please.yml`, `docs/releasing.md` | REL-W1 | +| REL-05 | First release baseline | Present | - | `release-please-config.json`, `.release-please-manifest.json` | REL-W1 | +| REL-06 | Component-selective CI builds | Present | - | `.tekton/`, `.github/workflows/e2e.yml` | REL-W2 | +| REL-07 | Build version format | Present | - | `Makefile`, `scripts/build-version.sh`, `scripts/kind/`, `.tekton/` | REL-W2 | +| REL-08 | Container metadata | Present | - | `components/{api-server,control-plane,web-console}/Dockerfile` | REL-W2 | +| REL-09 | API service metadata | Present | - | `components/api-server/openapi/openapi.yaml`, `components/api-server/pkg/api/metadata_test.go` | REL-W3 | +| REL-10 | Web-console version display | Present | - | `components/web-console/bff/src/config.ts`, `components/web-console/app/adapters/api/api-version.ts`, `components/web-console/app/features/shell/user-menu.tsx` | REL-W4 | +| REL-11 | Registry and deployment scope | Present | - | `.tekton/`, `docs/releasing.md` | REL-W1 | +| REL-12 | Verification and documentation | Present | - | `scripts/test_release_policy.py`, `scripts/test_build_version.py`, `scripts/check_image_build_policy.py`, `components/api-server/pkg/api/metadata_test.go`, `components/web-console/app/features/shell/user-menu.test.tsx`, `docs/releasing.md` | REL-W1..REL-W4 | + +**Wave plan:** + +- REL-W1 adds the managed source-release control and policy checks. +- REL-W2 adds deterministic image identity and final-release build selection. +- REL-W3 makes the existing API metadata route report the image identity. +- REL-W4 adds the console and API identities to the user menu. + ### openshell-gateway-console.spec.md | # | Requirement | Status | Gap | Code Location | Wave | diff --git a/specs/index.spec.md b/specs/index.spec.md index 05bbd639..fcbfc84e 100644 --- a/specs/index.spec.md +++ b/specs/index.spec.md @@ -52,6 +52,7 @@ Machine-readable index for autonomous reconciliation (`/reconcile` skill). | `platform/local-development.spec.md` | platform | Kind cluster, images, Make targets | ALL | cross-cutting, security | | `platform/oidc-integration.spec.md` | platform | API JWT validation, BFF OIDC session, IdP client config, Kind opt-in | API, WEB, CP | local-development, openshell-gateway-oidc, web-console/architecture | | `platform/e2e-testing.spec.md` | platform | Infra drivers, e2e test suite, CI workflow, deploy overlays | ALL | local-development, control-plane, openshell-gateway-routing | +| `platform/source-release.spec.md` | platform | Release PR, source release, build identity | API, CP, WEB, CI | cross-cutting, local-development, e2e-testing, web-console/architecture | | `platform/api-server-observability.spec.md` | platform | API OTel SDK bootstrap, HTTP/gRPC server spans, W3C trace continuation, request metrics | API | web-console/tracing, security, local-development, e2e-testing | | `platform/control-plane-observability.spec.md` | platform | CP OTel SDK bootstrap, reconcile spans, gRPC client spans, watch lifecycle, K8s API spans, reconcile metrics | CP | api-server-observability, control-plane, security, local-development | | `standards/ui/foundations.spec.md` | standards | UI foundations | WEB | - | diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md new file mode 100644 index 00000000..8b1668cd --- /dev/null +++ b/specs/platform/source-release.spec.md @@ -0,0 +1,342 @@ +# Source Release Specification + +## Purpose + +This specification defines source releases for the HyperShell repository. It +defines conventional commit input, the managed release pull request, release +files, GitHub releases, and container build identity. In this specification, a +"release" is a source release of the HyperShell repository. It is not a +`GatewayRelease` API resource. Image promotion and deployment are outside this +specification. Argo image bump continues to control deployment image updates. + +## Definitions + +- **Release PR**: The long-lived pull request that proposes the next repository + release. +- **Release version**: The SemVer value in the root `VERSION` file. The file does + not include the `v` tag prefix. +- **Build version**: The version string embedded in one component image. +- **Revision**: The full Git commit SHA from which an image is built. +- **Short revision**: The first seven lowercase hexadecimal characters of the + revision. + +## Requirements + +### Requirement REL-01: Conventional pull request titles + +Each pull request that targets `main` SHALL have a title that follows the +Conventional Commits form `(): `. +The type and scope SHALL use lowercase characters. The repository SHALL check +the title in a required CI gate. A squash merge SHALL use the pull request title +as the commit subject on `main`. + +An optional Jira key MAY be the first part of the description. It SHALL use the +form `[HYPERSHELL-]`. The conventional type SHALL remain the first part +of the title. For example, `feat(api): [HYPERSHELL-123] add service status` is +valid. `[HYPERSHELL-123] feat(api): add service status` is not valid because +Release Please cannot parse that squash commit subject. + +`fix` SHALL request a patch increment. `feat` SHALL request a minor increment. +The `!` marker or a `BREAKING CHANGE` footer SHALL request a major increment. +Before version `1.0.0`, a breaking change SHALL request a minor increment. +Other valid types MAY add changelog content, but they SHALL NOT request a +version increment by themselves. + +#### Scenario: Invalid pull request title + +- GIVEN a pull request targets `main` +- WHEN its title does not follow the required form +- THEN the required lint gate SHALL fail +- AND the failure SHALL show the required title form + +#### Scenario: Feature increment + +- GIVEN the current release version is `1.2.3` +- WHEN `feat(api): add service status` enters `main` +- THEN the Release PR SHALL propose version `1.3.0` + +#### Scenario: Jira-linked pull request title + +- GIVEN a pull request tracks Jira issue `HYPERSHELL-123` +- WHEN its title is `feat(api): [HYPERSHELL-123] add service status` +- THEN the required lint gate SHALL pass +- AND the squash commit subject SHALL keep the Jira key + +### Requirement REL-02: Managed release pull request + +Release Please SHALL maintain one Release PR against `main`. It SHALL open the +Release PR after a commit that requests a version increment enters `main`. It +SHALL update the open Release PR after later commits enter `main`. The Release +PR SHALL use a conventional commit title. It SHALL remain open until a person +selects it for release. The automation SHALL NOT enable automatic merge. + +The Release PR is a release decision surface. It is not a SemVer prerelease, +and it SHALL NOT create an `-rc` tag. + +Release Please SHALL use the repository `GITHUB_TOKEN` to create and update the +Release PR. GitHub SHALL hold the resulting CI workflow runs for manual +approval. A user with write access SHALL approve the workflow runs for the +current Release PR revision before merge. + +#### Scenario: Work enters main while the Release PR is open + +- GIVEN an open Release PR proposes version `0.4.0` +- WHEN another conforming commit enters `main` +- THEN Release Please SHALL update the same Release PR +- AND it SHALL update the proposed changelog +- AND it SHALL recalculate the proposed version when necessary +- AND CI workflow runs for the updated revision SHALL wait for manual approval +- AND it SHALL NOT create a GitHub release + +#### Scenario: Maintenance-only work + +- GIVEN no Release PR is open +- WHEN only commits that do not request a version increment enter `main` +- THEN Release Please SHALL NOT open a Release PR + +### Requirement REL-03: Release files + +The repository SHALL have root `VERSION` and `CHANGELOG.md` files. `VERSION` +SHALL contain one SemVer value without a `v` prefix. The Release PR SHALL update +`VERSION` to the proposed release version. It SHALL update `CHANGELOG.md` with +all conforming commits since the previous release. Changelog entries SHALL be +grouped by commit type. + +Release Please SHALL also update the checked-in CI build prefix from the same +release version. A repository policy check SHALL fail when the CI build prefix +and `VERSION` do not identify the same release version. + +#### Scenario: Release file consistency + +- GIVEN a Release PR proposes version `1.5.0` +- WHEN its files are inspected +- THEN `VERSION` SHALL contain `1.5.0` +- AND the CI build prefix SHALL contain `v1.5.0` +- AND `CHANGELOG.md` SHALL contain the proposed `1.5.0` entries + +### Requirement REL-04: Release publication + +Only the merge of a Release PR SHALL authorize a repository release. After the +required `main` CI workflow succeeds, Release Please SHALL create a `vX.Y.Z` Git +tag and a published GitHub release. The tag SHALL identify the merged Release +PR commit. The GitHub release notes SHALL use the matching `CHANGELOG.md` +section. The repository SHALL have immutable GitHub releases enabled. + +Release automation SHALL be safe to run again after a failure. It SHALL use a +repository `GITHUB_TOKEN` with write access only for contents, issues, and pull +requests. The repository SHALL allow GitHub Actions to create pull requests. +Each external GitHub Action SHALL use a full commit SHA. + +#### Scenario: Ordinary merge + +- GIVEN an ordinary pull request merges to `main` +- WHEN the release automation runs +- THEN it MAY create or update the Release PR +- AND it SHALL NOT create a GitHub release for that merge + +#### Scenario: Release merge + +- GIVEN the Release PR proposes version `1.5.0` +- WHEN the Release PR merges and the required `main` CI workflow succeeds +- THEN Release Please SHALL create tag `v1.5.0` +- AND it SHALL create one published immutable GitHub release for `v1.5.0` +- AND `VERSION` SHALL contain `1.5.0` + +### Requirement REL-05: First release baseline + +Release Please SHALL start from version `0.0.0`. The first proposed release +SHALL be `0.1.0` when the first included release change is a feature. The first +generated changelog SHALL include only commits after the release strategy +baseline. It SHALL NOT import the earlier repository history. + +#### Scenario: First release proposal + +- GIVEN no repository release tag exists +- AND the manifest records version `0.0.0` +- WHEN the first included `feat` commit enters `main` +- THEN the Release PR SHALL propose `0.1.0` +- AND its changelog SHALL stop at the configured baseline commit + +### Requirement REL-06: Component-selective CI builds + +A normal push to `main` SHALL build only the components whose build inputs +changed. An update to the long-lived Release PR SHALL NOT build every component +only because `VERSION`, `CHANGELOG.md`, or the CI build prefix changed. A +version-only merge-queue run SHALL NOT build every component. + +When the Release PR merges to `main`, the changed `VERSION` file SHALL cause one +API server build, one control-plane build, and one web-console build from the +final `main` revision. The corresponding `main` end-to-end run SHALL wait for +these three builds. This rule SHALL apply only to a `main` push. + +#### Scenario: Normal component change + +- GIVEN the current release version is `1.5.0` +- WHEN a normal `main` commit changes only the API server +- THEN CI SHALL build the API server image +- AND it SHALL NOT build the control-plane or web-console image + +#### Scenario: Release PR update + +- GIVEN the Release PR remains open +- WHEN Release Please updates `VERSION`, `CHANGELOG.md`, or the CI build prefix +- THEN the release PR event SHALL NOT build all component images + +#### Scenario: Release merge build + +- GIVEN the Release PR changes `VERSION` from `1.5.0` to `1.6.0` +- WHEN that pull request merges to `main` +- THEN CI SHALL build the API server, control-plane, and web-console images once +- AND all three builds SHALL use the final release commit as their revision + +### Requirement REL-07: Build version format + +A supported local image build from a clean Git work tree SHALL use build +version `dev-`. A local build with staged, unstaged, or +untracked changes SHALL append `-modified`. A CI image build SHALL use build +version `v-`. CI SHALL read the release version +from the checked-in release files. It SHALL NOT query for a live Git tag during +the image build. A build SHALL use the triggering full revision and SHALL +derive the short revision from it. Each image build SHALL reject `VCS_REF` +unless it is a 40-character lowercase hexadecimal Git SHA. + +A normal CI build after a release SHALL continue to use that release version +until the next Release PR merges. Thus, different component builds MAY have +different short revisions in the same release version. The three builds caused +by a Release PR merge SHALL have the same build version. + +#### Scenario: Local image build + +- GIVEN local `HEAD` is revision `abcdef0123456789abcdef0123456789abcdef01` +- WHEN a developer uses a supported local image build command +- THEN the image build version SHALL be `dev-abcdef0` + +#### Scenario: Modified local image build + +- GIVEN local `HEAD` is revision `abcdef0123456789abcdef0123456789abcdef01` +- AND the local Git work tree contains an uncommitted change +- WHEN a developer uses a supported local image build command +- THEN the image build version SHALL be `dev-abcdef0-modified` + +#### Scenario: CI image build + +- GIVEN `VERSION` contains `1.6.0` +- AND the triggering revision is `1234567890abcdef1234567890abcdef12345678` +- WHEN CI builds a component image +- THEN its build version SHALL be `v1.6.0-1234567` + +#### Scenario: Reject an invalid image revision + +- GIVEN `VCS_REF` is `abcdef0` +- WHEN a component image build starts +- THEN the build SHALL stop before it shortens the revision +- AND the build SHALL report that a full 40-character Git SHA is required + +### Requirement REL-08: Container metadata + +Each API server, control-plane, and web-console image SHALL set +`org.opencontainers.image.version` to its build version. It SHALL set +`org.opencontainers.image.revision` to its full revision. The image SHALL also +contain the build version as a runtime environment value. Static placeholder +versions SHALL NOT remain in these Containerfiles. The Containerfiles SHALL +explain how they shorten the full revision. + +#### Scenario: Inspect a CI image + +- GIVEN CI built an image with build version `v1.6.0-1234567` +- WHEN an operator inspects the image configuration +- THEN `org.opencontainers.image.version` SHALL be `v1.6.0-1234567` +- AND `org.opencontainers.image.revision` SHALL be the full triggering revision +- AND the runtime build-version value SHALL be `v1.6.0-1234567` + +### Requirement REL-09: API service metadata + +The existing `GET /api/hypershell/v1/metadata` endpoint SHALL return the API +server build version and build time. The `version` value SHALL equal the API +server image build version. The endpoint SHALL be available without user +authentication and SHALL NOT query the database. The OpenAPI contract SHALL +describe its response. Existing liveness and readiness probes SHALL keep their +current paths and meanings. CI SHALL link a focused test with the production +linker flags. The test SHALL fail when the linked version or build time does not +equal its expected value. + +#### Scenario: Read API build identity + +- GIVEN the API server image build version is `v1.6.0-1234567` +- WHEN a client sends an unauthenticated `GET` request to + `/api/hypershell/v1/metadata` +- THEN the response status SHALL be `200` +- AND the response `version` SHALL be `v1.6.0-1234567` +- AND the response SHALL include `build_time` + +#### Scenario: Detect an invalid linker target + +- GIVEN a linker target does not set the framework build identity +- WHEN CI runs the linked build-metadata test +- THEN the test SHALL fail + +### Requirement REL-10: Web-console version display + +The web-console image SHALL give its build version to the BFF as runtime +configuration. The BFF SHALL include only this non-secret value in the existing +browser runtime-configuration allowlist. The authenticated user menu SHALL show +the localized text `Console version ` as non-action content. It +SHALL get the API server build version from the existing metadata endpoint +through the same-origin BFF proxy. It SHALL show the localized text +`API version ` as separate non-action content. The menu SHALL +remain usable when either value is unavailable and SHALL show a localized +unknown value for that source. + +The two rows SHALL identify their source. The console SHALL NOT state or imply +that the console and API server use the same revision. + +#### Scenario: Show both image build versions + +- GIVEN an authenticated user uses console build `v1.6.0-1234567` +- AND the API server returns build version `v1.6.0-7654321` +- WHEN the user opens the user menu +- THEN the menu SHALL show `Console version v1.6.0-1234567` +- AND the menu SHALL show `API version v1.6.0-7654321` +- AND neither version row SHALL start navigation or another action + +#### Scenario: Build versions are unavailable + +- GIVEN browser runtime configuration has no valid build version +- AND the API metadata request is unavailable +- WHEN an authenticated user opens the user menu +- THEN the menu SHALL show a localized unknown console version +- AND the menu SHALL show a localized unknown API version +- AND the Log out action SHALL remain available + +### Requirement REL-11: Registry and deployment scope + +CI SHALL keep the existing full-SHA image tags that current tests and Argo image +bump consume. This release strategy SHALL NOT create semantic registry aliases, +promote images, change a Konflux ReleasePlan, or change Argo configuration. +Image promotion and deployment approval SHALL remain separate processes. + +#### Scenario: Release images become available + +- GIVEN a Release PR merges to `main` +- WHEN the three component builds finish +- THEN each existing component repository SHALL contain an image with the full + release commit SHA tag +- AND this change SHALL NOT update an Argo deployment reference + +### Requirement REL-12: Verification and documentation + +Repository checks SHALL verify conventional title validation, release-file +consistency, build-version calculation, API metadata, browser runtime +configuration, user-menu presentation, and event-specific image selection. +Release documentation SHALL describe commit types, release PR operation, +`GITHUB_TOKEN` permissions, manual workflow approval, the manual merge step, +first-release behavior, failure recovery, and the boundary with Argo image +bump. + +#### Scenario: Release automation fails after merge + +- GIVEN a Release PR has merged +- AND release automation fails before it creates the tag or GitHub release +- WHEN an operator corrects the cause and runs the automation again +- THEN the automation SHALL create at most one tag and one GitHub release +- AND it SHALL use the version already committed in `VERSION`