diff --git a/build.sh b/build.sh index 38f1f7d4..62c1f73d 100755 --- a/build.sh +++ b/build.sh @@ -83,6 +83,8 @@ # PIP_NO_BINARY If set, passed as --build-arg to buildah so Containerfiles # can set ENV PIP_NO_BINARY. Use ":all:" to force pip to # build all packages from source instead of using wheels. +# REGISTRY_AUTH_FILE Authentication file passed explicitly to buildah push. +# REGISTRY_CERT_DIR TLS certificate directory passed explicitly to buildah push. set -euo pipefail @@ -102,6 +104,8 @@ UPSTREAM_CONSTRAINTS="upper-constraints.txt" DEFAULT_STREAM="${DEFAULT_STREAM:-master}" SKIP_HASH_UPDATE="${SKIP_HASH_UPDATE:-}" PIP_NO_BINARY="${PIP_NO_BINARY:-}" +REGISTRY_AUTH_FILE="${REGISTRY_AUTH_FILE:-}" +REGISTRY_CERT_DIR="${REGISTRY_CERT_DIR:-}" PARALLEL="${PARALLEL:-$(nproc)}" # Discover all buildable images from the directory structure. @@ -380,10 +384,32 @@ push_image() { name="$(image_name "${dir_name}")" IFS=',' read -ra tags <<< "${TAG}" + local registry_args=() + [[ -n "${REGISTRY_AUTH_FILE}" ]] && \ + registry_args+=(--authfile "${REGISTRY_AUTH_FILE}") + [[ -n "${REGISTRY_CERT_DIR}" ]] && \ + registry_args+=(--cert-dir "${REGISTRY_CERT_DIR}") + for t in "${tags[@]}"; do local full_tag="${REGISTRY}/${NAMESPACE}/${name}:${t}" echo "=== Pushing ${full_tag} ===" - buildah push "${full_tag}" + buildah push "${registry_args[@]}" "${full_tag}" + done +} + +# Print the exact image references produced for a target. +target_refs() { + local dir_name + local name + local tag + local -a tags + resolve_targets_array "$@" || return 1 + for dir_name in "${_RESOLVED_TARGETS[@]}"; do + name="$(image_name "${dir_name}")" + IFS=',' read -ra tags <<< "${TAG}" + for tag in "${tags[@]}"; do + echo "${REGISTRY}/${NAMESPACE}/${name}:${tag}" + done done } @@ -407,50 +433,93 @@ list_images() { fi } -# Resolve which images to process (accepts one or more targets) +# Resolve one or more image expressions in their requested order. A +# comma-separated expression is an explicit ordered union. Separate +# positional targets preserve the upstream multi-target behavior. +# The caller is responsible for including "base" if needed. resolve_targets() { local all_images all_images=($(discover_images)) - local resolved=() + local -a resolved=() + local expression item image item_images + declare -A seen=() - for target in "$@"; do - if [[ "${target}" == "all" ]]; then + for expression in "$@"; do + if [[ "${expression}" == "all" ]]; then echo "${all_images[@]}" return fi - local found=0 + if [[ "${expression}" == *,* ]]; then + local -a requested + IFS=',' read -ra requested <<< "${expression}" + for item in "${requested[@]}"; do + if [[ -z "${item}" || "${item}" != "${item//[[:space:]]/}" ]]; then + echo "ERROR: Invalid empty or whitespace-containing target '${item}'" >&2 + return 1 + fi + if ! item_images=$(resolve_targets "${item}"); then + return 1 + fi + for image in ${item_images}; do + if [[ -z "${seen[${image}]:-}" ]]; then + resolved+=("${image}") + seen["${image}"]=1 + fi + done + done + continue + fi - # Exact match - for dir_name in "${all_images[@]}"; do - if [[ "${dir_name}" == "${target}" ]]; then - resolved+=("${target}") + local found=0 + for image in "${all_images[@]}"; do + if [[ "${image}" == "${expression}" ]]; then + if [[ -z "${seen[${image}]:-}" ]]; then + resolved+=("${image}") + seen["${image}"]=1 + fi found=1 break fi done [[ ${found} -eq 1 ]] && continue - # Project prefix match - for dir_name in "${all_images[@]}"; do - if [[ "${dir_name}" == "${target}/"* ]]; then - resolved+=("${dir_name}") + for image in "${all_images[@]}"; do + if [[ "${image}" == "${expression}/"* ]]; then + if [[ -z "${seen[${image}]:-}" ]]; then + resolved+=("${image}") + seen["${image}"]=1 + fi found=1 fi done [[ ${found} -eq 1 ]] && continue - echo "ERROR: Unknown image or project '${target}'" >&2 + echo "ERROR: Unknown image or project '${expression}'" >&2 echo "Available images:" >&2 - for dir_name in "${all_images[@]}"; do - echo " ${dir_name}" >&2 + for image in "${all_images[@]}"; do + echo " ${image}" >&2 done return 1 done + if [[ ${#resolved[@]} -eq 0 ]]; then + echo "ERROR: Explicit target selection is empty" >&2 + return 1 + fi echo "${resolved[@]}" } +# Resolve an expression without losing failures in command substitutions. +declare -a _RESOLVED_TARGETS=() +resolve_targets_array() { + local output + if ! output=$(resolve_targets "$@"); then + return 1 + fi + _RESOLVED_TARGETS=(${output}) +} + # Clone a repo at a branch tip (or tag) and store the resolved commit hash # in _CLONE_RESULT. Must NOT be called via command substitution ($(...)) # because _AUTO_CLONED assignments would be lost in the subshell. @@ -1128,11 +1197,24 @@ case "${ACTION}" in ;; build-parallel) _bp_targets=($(resolve_targets "${TARGETS[@]}")) + if [[ ! "${PARALLEL}" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: PARALLEL must be a positive integer" >&2 + exit 1 + fi + if [[ -n "${BUILD_LOGS_DIR:-}" ]]; then + _bp_logdir="${BUILD_LOGS_DIR}" + mkdir -p "${_bp_logdir}" + else + _bp_logdir=$(mktemp -d) + fi # Build base first (all service images depend on it) for _bp_img in "${_bp_targets[@]}"; do [[ -n "$(project_name "${_bp_img}")" ]] && continue - build_image "${_bp_img}" + set -o pipefail + build_image "${_bp_img}" 2>&1 | + sed -u "s|^|[${_bp_img}] |" | + tee "${_bp_logdir}/${_bp_img//\//_}.log" done # Pre-clone sources so parallel builds don't race on the same directories @@ -1142,12 +1224,6 @@ case "${ACTION}" in done # Build service images in parallel (max PARALLEL at a time) - if [[ -n "${BUILD_LOGS_DIR:-}" ]]; then - _bp_logdir="${BUILD_LOGS_DIR}" - mkdir -p "${_bp_logdir}" - else - _bp_logdir=$(mktemp -d) - fi _bp_service_imgs=() for _bp_img in "${_bp_targets[@]}"; do [[ -z "$(project_name "${_bp_img}")" ]] && continue @@ -1161,47 +1237,50 @@ case "${ACTION}" in _bp_running=0 for _bp_img in "${_bp_service_imgs[@]}"; do - # Wait for a slot if at the limit while [[ ${_bp_running} -ge ${PARALLEL} ]]; do - if ! wait -n; then + _bp_finished="" + if wait -n -p _bp_finished "${!_bp_pids[@]}"; then + unset '_bp_pids['"${_bp_finished}"']' + ((_bp_running--)) || true + else _bp_fail=1 + [[ -n "${_bp_finished}" ]] && unset '_bp_pids['"${_bp_finished}"']' break 2 fi - ((_bp_running--)) || true done _bp_log="${_bp_logdir}/${_bp_img//\//_}.log" - build_image "${_bp_img}" > "${_bp_log}" 2>&1 & + ( + set -o pipefail + build_image "${_bp_img}" 2>&1 | + sed -u "s|^|[${_bp_img}] |" | + tee "${_bp_log}" + ) & _bp_pids[$!]="${_bp_img}" ((_bp_running++)) || true done - # Wait for remaining builds if [[ ${_bp_fail} -eq 0 ]]; then while [[ ${_bp_running} -gt 0 ]]; do - if ! wait -n; then + _bp_finished="" + if wait -n -p _bp_finished "${!_bp_pids[@]}"; then + unset '_bp_pids['"${_bp_finished}"']' + ((_bp_running--)) || true + else _bp_fail=1 + [[ -n "${_bp_finished}" ]] && unset '_bp_pids['"${_bp_finished}"']' break fi - ((_bp_running--)) || true done fi - # Show logs for all builds - for _bp_log in "${_bp_logdir}"/*.log; do - _bp_name=$(basename "${_bp_log}" .log) - echo "=== ${_bp_name} ===" - cat "${_bp_log}" - echo "" - done - if [[ ${_bp_fail} -eq 1 ]]; then - echo "ERROR: A build failed, killing remaining builds" >&2 + echo "ERROR: A build failed; stopping remaining builds" >&2 for _bp_pid in "${!_bp_pids[@]}"; do kill "${_bp_pid}" 2>/dev/null || true done wait 2>/dev/null || true - [[ -z "${BUILD_LOGS_DIR:-}" ]] && rm -rf "${_bp_logdir}" + echo "Build logs are available in ${_bp_logdir}" >&2 exit 1 fi @@ -1223,6 +1302,13 @@ case "${ACTION}" in push_image "${img}" done ;; + refs) + target_refs "${TARGETS[@]}" + ;; + resolve) + resolve_targets_array "${TARGETS[@]}" + printf '%s\n' "${_RESOLVED_TARGETS[@]}" + ;; update-sources) if [[ -n "${SKIP_HASH_UPDATE}" ]]; then echo "=== Skipping hash update (SKIP_HASH_UPDATE is set) ===" @@ -1330,7 +1416,7 @@ case "${ACTION}" in list_images ;; *) - echo "Usage: STREAM= $0 {build|build-parallel|push|update-sources|update-lockfiles|install-deps|list} [target ...]" + echo "Usage: STREAM= $0 {build|build-parallel|push|refs|resolve|update-sources|update-lockfiles|install-deps|list} [target ...]" echo "" echo "Images (discovered from containers/):" for dir_name in $(discover_images); do @@ -1354,6 +1440,8 @@ case "${ACTION}" in echo " BUILD_LOGS_DIR Persist build-parallel logs to this directory" echo " SKIP_HASH_UPDATE Skip updating pinned hashes; regenerate locks only" echo " PIP_NO_BINARY Pass PIP_NO_BINARY to container build (e.g., ':all:')" + echo " REGISTRY_AUTH_FILE Registry authentication file for pushes" + echo " REGISTRY_CERT_DIR Registry TLS certificate directory for pushes" echo "" echo "Source directories: containers//src//" echo "Overrides: containers//src/overrides//" diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 909bcfa0..ddfb534c 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -343,6 +343,94 @@ All image tags are verified to exist locally before any push begins. ./build.sh list ``` +## Zuul content provider + +The `s2i-openstack-container-content-provider` job runs on the +CentOS Stream 10 nodeset host named `builder`. All host preparation, registry +validation, builds, publication, result generation, and cleanup target that +host explicitly. The Zuul executor controls Ansible but does not perform those +mutations. + +In this repository the provider defaults to `all`, so every maintained image +is built from its exact `sources.txt` pins. A child job can set `s2i_ci_images` +to an explicit list of image targets; the provider resolves the selection +through `build.sh` and publishes only that set. The caller is responsible +for including `base` in the list if service images depend on it. + +To compose the provider in an operator repository, inherit the job, add the +container repository and any related repositories to `required-projects`, set +`s2i_ci_container_project`, and override `s2i_ci_images` with the required +subset. Zuul places those projects in the shared buildset workspace, but this +provider does not yet consume speculative service checkouts or build operators. +Those integrations remain follow-up scope; the current provider always uses +maintained source pins. + +### Image deployment metadata + +The repository-level `containers/image-mappings.yaml` associates exact build +targets with OpenStackVersion custom-image fields. Unlisted targets have no +deployment mapping but are still built and returned. The consolidated +`watcher/watcher-base` image declares: + +```yaml +openstack_version: + custom_container_images: + watcher/watcher-base: + - watcherAPIImage + - watcherApplierImage + - watcherDecisionEngineImage +``` + +All three keys resolve to the same exact `openstack-watcher-base` reference. +The image contains the API, applier, and decision-engine entry points and the +union of their runtime dependencies. Watcher is intentionally not split into +process-specific images. + +Both Cyborg images build and publish but are absent from the central mapping. +Their exact references therefore appear in provider diagnostics without adding +fields to the default deployment map. + +A child job may provide `s2i_ci_image_mappings` as a mapping from a selected +image target to a replacement list of keys. Replacement is per image rather +than additive. The provider records whether each effective list came from +tracked or inventory metadata and rejects malformed values, unknown or unbuilt +image targets, empty key strings, duplicate keys, and a key assigned to more +than one image. + +### Registry and returned data + +The provider starts or inherits a Zuul buildset registry, validates push and +pull with a dedicated UBI tag, builds and pushes the selected image set, and +pulls every exact result back. Credentials and certificate data remain in +Zuul secret data. Returned public diagnostics use the buildset registry's +reachable host or IP and port, never the builder-local registry alias. + +`s2i_ci_content.images` contains every exact successful reference, including +base and both unmapped Cyborg images. The partial +`s2i_content_provider_os_custom_container_images` map contains only effective +keys joined to exact successful references. The legacy global OS registry URL +remains the neutral `null` sentinel, while its namespace/tag and gating-repo +fields remain empty or false because this selective provider does not publish a +complete OpenStack image namespace. `s2i_cifmw_build_images_output` +remains an empty mapping and is not repurposed for service images. + +All returned variables use the `s2i_content_provider_` or `s2i_cifmw_` prefix +to avoid colliding with the standard `openstack-k8s-operators-content-provider` +when both run as parents of the same consumer job. + +Intended references are written before build mutation. Post-run cleanup +removes only those exact Podman pullback and Buildah build tags, verifies exact +absence, and removes a buildset registry only when its ownership marker is +valid. +Per-image parallel logs and registry/result manifests are retained under +`zuul-output/logs/container-build/`. + +The provider pauses while dependent jobs run. Private onboarding may attach a +trivial child that prints the returned registry paths and maps. That debug job +does not pull images, patch an OpenStackVersion resource, deploy OpenStack, or +invoke a downstream repository's playbooks. Downstream consumption is separate +work. + ## Build architecture ### Base image (`openstack-base`) diff --git a/molecule/provider-contract/cleanup.yml b/molecule/provider-contract/cleanup.yml new file mode 100644 index 00000000..9b147fe0 --- /dev/null +++ b/molecule/provider-contract/cleanup.yml @@ -0,0 +1,13 @@ +--- +- name: Remove provider contract fixtures + hosts: builder + gather_facts: false + vars: + s2i_test_root: >- + {{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/.tmp/molecule/provider-contract + + tasks: + - name: Remove provider contract fixture state + ansible.builtin.file: + path: "{{ s2i_test_root }}" + state: absent diff --git a/molecule/provider-contract/converge.yml b/molecule/provider-contract/converge.yml new file mode 100644 index 00000000..bb0c1ba1 --- /dev/null +++ b/molecule/provider-contract/converge.yml @@ -0,0 +1,126 @@ +--- +- name: Validate the fake normalized registry + ansible.builtin.import_playbook: >- + ../../playbooks/container-ci/shared/validate-registry.yaml + vars: + s2i_ci_output_dir: >- + {{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/.tmp/molecule/provider-contract/zuul-output + +- name: Prepare exact cleanup fixtures + hosts: builder + gather_facts: false + vars: + s2i_test_repo_root: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" + s2i_test_root: "{{ s2i_test_repo_root }}/.tmp/molecule/provider-contract" + s2i_test_images: + - registry.test:5000/openstack/openstack-base:test + - registry.test:5000/openstack/openstack-watcher-base:test + s2i_ci_container_repo: "{{ s2i_test_repo_root }}" + s2i_ci_selected_images: + - base + - cyborg/cyborg + - watcher/watcher-base + s2i_ci_image_mappings: + cyborg/cyborg: + - futureCyborgImage + + tasks: + - name: Initialize metadata expansion fixtures + ansible.builtin.set_fact: + s2i_ci_effective_image_mappings: {} + s2i_ci_mapping_sources: {} + + - name: Load tracked image mappings + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/load-image-mappings.yaml + + - name: Expand tracked and inventory image metadata + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/image-metadata-item.yaml + loop: "{{ s2i_ci_selected_images }}" + loop_control: + loop_var: s2i_ci_image + + - name: Validate effective deployment key ownership + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/validate-deployment-keys.yaml + + - name: Require tracked and replacement metadata expansion + ansible.builtin.assert: + that: + - s2i_ci_effective_image_mappings['base'] == [] + - s2i_ci_effective_image_mappings['cyborg/cyborg'] == + ['futureCyborgImage'] + - s2i_ci_effective_image_mappings['watcher/watcher-base'] | length == 3 + - s2i_ci_mapping_sources['cyborg/cyborg'] == 'inventory' + - s2i_ci_mapping_sources['watcher/watcher-base'] == 'tracked' + + - name: Initialize exact reference expansion fixtures + ansible.builtin.set_fact: + s2i_ci_target_references: + base: registry.test:5000/openstack/openstack-base:test + cyborg/cyborg: registry.test:5000/openstack/openstack-cyborg:test + watcher/watcher-base: >- + registry.test:5000/openstack/openstack-watcher-base:test + s2i_ci_custom_container_images: {} + + - name: Expand exact metadata references + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/deployment-map-item.yaml + loop: "{{ s2i_ci_selected_images }}" + loop_control: + loop_var: s2i_ci_image + + - name: Require consolidated Watcher reference expansion + ansible.builtin.assert: + that: + - s2i_ci_custom_container_images.watcherAPIImage == + s2i_ci_target_references['watcher/watcher-base'] + - s2i_ci_custom_container_images.watcherApplierImage == + s2i_ci_target_references['watcher/watcher-base'] + - s2i_ci_custom_container_images.watcherDecisionEngineImage == + s2i_ci_target_references['watcher/watcher-base'] + - s2i_ci_custom_container_images.futureCyborgImage == + s2i_ci_target_references['cyborg/cyborg'] + + - name: Read fake container storage after registry validation + ansible.builtin.slurp: + src: "{{ s2i_test_root }}/state.json" + register: s2i_test_state_data + + - name: Decode fake container storage + ansible.builtin.set_fact: + s2i_test_state: >- + {{ s2i_test_state_data.content | b64decode | from_json }} + + - name: Add exact workflow tags to both storage clients + ansible.builtin.copy: + dest: "{{ s2i_test_root }}/state.json" + mode: "0600" + content: | + {{ s2i_test_state | + combine({ + 'podman': s2i_test_state.podman + s2i_test_images, + 'buildah': s2i_test_images + }) | to_nice_json }} + + - name: Write intended exact cleanup metadata + ansible.builtin.copy: + dest: >- + {{ s2i_test_root }}/zuul-output/logs/container-build/intended-images.json + mode: "0644" + content: | + {{ { + 'registry': 'registry.test:5000', + 'namespace': 'openstack', + 'images': s2i_test_images, + 'selected_images': ['base', 'watcher/watcher-base'], + 'target': 'watcher/watcher-base' + } | to_nice_json }} + +- name: Exercise exact shared cleanup + ansible.builtin.import_playbook: >- + ../../playbooks/container-ci/shared/cleanup-images.yaml + vars: + s2i_ci_output_dir: >- + {{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/.tmp/molecule/provider-contract/zuul-output diff --git a/molecule/provider-contract/duplicate-metadata.yml b/molecule/provider-contract/duplicate-metadata.yml new file mode 100644 index 00000000..faedb612 --- /dev/null +++ b/molecule/provider-contract/duplicate-metadata.yml @@ -0,0 +1,16 @@ +--- +- name: Reject duplicate deployment keys + hosts: builder + gather_facts: false + vars: + s2i_test_repo_root: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" + s2i_ci_effective_image_mappings: + base: + - duplicateImage + watcher/watcher-base: + - duplicateImage + + tasks: + - name: Validate duplicate fixture + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/validate-deployment-keys.yaml diff --git a/molecule/provider-contract/files/fake-container-client.py b/molecule/provider-contract/files/fake-container-client.py new file mode 100755 index 00000000..ade2a2cb --- /dev/null +++ b/molecule/provider-contract/files/fake-container-client.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +import json +import os +import pathlib +import sys + + +state_path = pathlib.Path(os.environ["S2I_FAKE_STATE"]) +client = pathlib.Path(sys.argv[0]).name +arguments = sys.argv[1:] +state = json.loads(state_path.read_text(encoding="utf-8")) +state["commands"].append([client, *arguments]) +images = state[client] +return_code = 0 + +if client == "podman": + if arguments[:1] == ["pull"]: + images.append(arguments[-1]) + elif arguments[:1] == ["tag"]: + images.append(arguments[-1]) + elif arguments[:1] == ["push"]: + pass + elif arguments[:1] == ["untag"]: + if arguments[-1] in images: + images.remove(arguments[-1]) + elif arguments[:2] in (["image", "inspect"], ["image", "exists"]): + return_code = 0 if arguments[-1] in images else 1 + elif arguments[:2] == ["image", "rm"]: + if arguments[-1] in images: + images.remove(arguments[-1]) + else: + return_code = 1 + elif arguments[:1] == ["images"]: + print("\n".join(images)) + else: + return_code = 2 +elif client == "buildah": + if arguments[:1] == ["inspect"]: + return_code = 0 if arguments[-1] in images else 125 + elif arguments[:1] == ["rmi"]: + if arguments[-1] in images: + images.remove(arguments[-1]) + else: + return_code = 125 + elif arguments[:1] == ["images"]: + print("\n".join(images)) + else: + return_code = 2 +else: + return_code = 2 + +state[client] = list(dict.fromkeys(images)) +state_path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") +sys.exit(return_code) diff --git a/molecule/provider-contract/malformed-mapping.yml b/molecule/provider-contract/malformed-mapping.yml new file mode 100644 index 00000000..60c20335 --- /dev/null +++ b/molecule/provider-contract/malformed-mapping.yml @@ -0,0 +1,18 @@ +--- +- name: Reject malformed mapping value + hosts: builder + gather_facts: false + vars: + s2i_test_repo_root: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" + s2i_ci_container_repo: "{{ s2i_test_repo_root }}" + s2i_ci_image_mappings: + base: not-a-list + s2i_ci_effective_image_mappings: {} + s2i_ci_mapping_sources: {} + + tasks: + - name: Validate malformed mapping fixture + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/image-metadata-item.yaml + vars: + s2i_ci_image: base diff --git a/molecule/provider-contract/molecule.yml b/molecule/provider-contract/molecule.yml new file mode 100644 index 00000000..d3eed451 --- /dev/null +++ b/molecule/provider-contract/molecule.yml @@ -0,0 +1,34 @@ +--- +driver: + name: default + options: + managed: false + ansible_connection_options: + ansible_connection: local + +platforms: + - name: localhost + groups: + - builder + +provisioner: + name: ansible + env: + ANSIBLE_LOCAL_TEMP: "${MOLECULE_PROJECT_DIRECTORY}/.tmp/ansible/local-tmp" + PATH: "${MOLECULE_PROJECT_DIRECTORY}/.tmp/molecule/provider-contract/bin:${PATH}" + S2I_FAKE_STATE: "${MOLECULE_PROJECT_DIRECTORY}/.tmp/molecule/provider-contract/state.json" + inventory: + host_vars: + localhost: + ansible_connection: local + +verifier: + name: ansible + +scenario: + test_sequence: + - syntax + - prepare + - converge + - verify + - cleanup diff --git a/molecule/provider-contract/prepare.yml b/molecule/provider-contract/prepare.yml new file mode 100644 index 00000000..3874ab96 --- /dev/null +++ b/molecule/provider-contract/prepare.yml @@ -0,0 +1,57 @@ +--- +- name: Prepare provider contract fixtures + hosts: builder + gather_facts: false + vars: + s2i_test_repo_root: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" + s2i_test_root: "{{ s2i_test_repo_root }}/.tmp/molecule/provider-contract" + + tasks: + - name: Reset provider contract fixture state + ansible.builtin.file: + path: "{{ s2i_test_root }}" + state: absent + + - name: Create provider contract fixture directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ s2i_test_root }}/bin" + - "{{ s2i_test_root }}/zuul-output/logs/container-build" + + - name: Install the fake container client + ansible.builtin.copy: + src: fake-container-client.py + dest: "{{ s2i_test_root }}/bin/fake-container-client.py" + mode: "0755" + + - name: Link fake Podman and Buildah clients + ansible.builtin.file: + src: "{{ s2i_test_root }}/bin/fake-container-client.py" + dest: "{{ s2i_test_root }}/bin/{{ item }}" + state: link + loop: + - podman + - buildah + + - name: Initialize fake container storage + ansible.builtin.copy: + dest: "{{ s2i_test_root }}/state.json" + mode: "0600" + content: | + {{ {'podman': [], 'buildah': [], 'commands': []} | to_nice_json }} + + - name: Write normalized registry connection + ansible.builtin.copy: + dest: "{{ s2i_test_root }}/zuul-output/.s2i-ci-registry-connection.json" + mode: "0600" + content: | + {{ { + 'endpoint': 'registry.test:5000', + 'public_host': '192.0.2.10', + 'public_port': 5000, + 'auth_file': '', + 'cert_dir': '' + } | to_nice_json }} diff --git a/molecule/provider-contract/unknown-mapping.yml b/molecule/provider-contract/unknown-mapping.yml new file mode 100644 index 00000000..73dec15b --- /dev/null +++ b/molecule/provider-contract/unknown-mapping.yml @@ -0,0 +1,17 @@ +--- +- name: Reject unknown mapping target + hosts: builder + gather_facts: false + vars: + s2i_test_repo_root: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" + s2i_ci_selected_images: + - base + - watcher/watcher-base + s2i_ci_image_mappings: + cyborg/cyborg: + - unknownImage + + tasks: + - name: Validate unknown mapping fixture + ansible.builtin.include_tasks: >- + {{ s2i_test_repo_root }}/playbooks/container-ci/shared/validate-mapping-overrides.yaml diff --git a/molecule/provider-contract/verify.yml b/molecule/provider-contract/verify.yml new file mode 100644 index 00000000..0243d40a --- /dev/null +++ b/molecule/provider-contract/verify.yml @@ -0,0 +1,89 @@ +--- +- name: Verify provider registry and cleanup contracts + hosts: builder + gather_facts: false + vars: + s2i_test_repo_root: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" + s2i_test_root: "{{ s2i_test_repo_root }}/.tmp/molecule/provider-contract" + s2i_test_validation_image: >- + registry.test:5000/s2i-validation/ubi10-minimal:latest + s2i_test_images: + - registry.test:5000/openstack/openstack-base:test + - registry.test:5000/openstack/openstack-watcher-base:test + + tasks: + - name: Exercise invalid metadata contracts + ansible.builtin.command: + argv: + - ansible-playbook + - -i + - builder, + - -c + - local + - "{{ lookup('env', 'MOLECULE_SCENARIO_DIRECTORY') }}/{{ item }}" + loop: + - duplicate-metadata.yml + - unknown-mapping.yml + - malformed-mapping.yml + register: s2i_test_invalid_metadata + changed_when: false + failed_when: false + + - name: Require invalid metadata to fail deterministically + ansible.builtin.assert: + that: + - s2i_test_invalid_metadata.results | + map(attribute='rc') | reject('equalto', 0) | list | length == 3 + - >- + 'A deployment key may be assigned to only one image' in + (s2i_test_invalid_metadata.results[0].stdout + + s2i_test_invalid_metadata.results[0].stderr) + - >- + 'Image mapping overrides must name selected image targets' in + (s2i_test_invalid_metadata.results[1].stdout + + s2i_test_invalid_metadata.results[1].stderr) + - >- + 'Invalid effective image mapping for base' in + (s2i_test_invalid_metadata.results[2].stdout + + s2i_test_invalid_metadata.results[2].stderr) + + - name: Read final fake container storage + ansible.builtin.slurp: + src: "{{ s2i_test_root }}/state.json" + register: s2i_test_state_data + + - name: Decode final fake container storage + ansible.builtin.set_fact: + s2i_test_state: >- + {{ s2i_test_state_data.content | b64decode | from_json }} + + - name: Validate exact cleanup and retained source image + ansible.builtin.assert: + that: + - s2i_test_images | intersect(s2i_test_state.podman) | length == 0 + - s2i_test_images | intersect(s2i_test_state.buildah) | length == 0 + - s2i_test_validation_image not in s2i_test_state.podman + - >- + 'registry.access.redhat.com/ubi10/ubi-minimal:latest' in + s2i_test_state.podman + - >- + ['podman', 'push', '--remove-signatures', + 'registry.test:5000/s2i-validation/ubi10-minimal:latest'] in + s2i_test_state.commands + - >- + ['podman', 'image', 'rm', '--force', s2i_test_images[0]] in + s2i_test_state.commands + - >- + ['buildah', 'rmi', s2i_test_images[0]] in s2i_test_state.commands + + - name: Check public registry state artifact + ansible.builtin.stat: + path: >- + {{ s2i_test_root }}/zuul-output/logs/container-build/registry-state.json + register: s2i_test_registry_state + + - name: Require registry state artifact + ansible.builtin.assert: + that: + - s2i_test_registry_state.stat.exists + - s2i_test_registry_state.stat.mode == '0644' diff --git a/playbooks/container-ci/shared/cleanup-images.yaml b/playbooks/container-ci/shared/cleanup-images.yaml new file mode 100644 index 00000000..2100cba9 --- /dev/null +++ b/playbooks/container-ci/shared/cleanup-images.yaml @@ -0,0 +1,208 @@ +--- +- name: Remove exact images recorded by the workflow + hosts: builder + gather_facts: false + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_log_dir: "{{ s2i_ci_output_dir }}/logs/container-build" + s2i_ci_cleanup_images: true + + tasks: + - name: Check intended image cleanup metadata + when: s2i_ci_cleanup_images | bool + ansible.builtin.stat: + path: "{{ s2i_ci_log_dir }}/intended-images.json" + register: s2i_ci_intended_images_metadata + + - name: Load intended image cleanup metadata + when: + - s2i_ci_cleanup_images | bool + - s2i_ci_intended_images_metadata.stat.exists + ansible.builtin.slurp: + src: "{{ s2i_ci_log_dir }}/intended-images.json" + register: s2i_ci_intended_images_data + + - name: Decode intended image cleanup metadata + when: + - s2i_ci_cleanup_images | bool + - s2i_ci_intended_images_metadata.stat.exists + ansible.builtin.set_fact: + s2i_ci_cleanup_metadata: >- + {{ s2i_ci_intended_images_data.content | b64decode | from_json }} + + - name: Validate intended image cleanup metadata + when: + - s2i_ci_cleanup_images | bool + - s2i_ci_intended_images_metadata.stat.exists + ansible.builtin.assert: + that: + - s2i_ci_cleanup_metadata is mapping + - s2i_ci_cleanup_metadata.registry is string + - s2i_ci_cleanup_metadata.registry is match('^[A-Za-z0-9.:-]+$') + - s2i_ci_cleanup_metadata.namespace is string + - s2i_ci_cleanup_metadata.namespace is match('^[A-Za-z0-9_.-]+$') + - s2i_ci_cleanup_metadata.images is sequence + - s2i_ci_cleanup_metadata.images is not string + - s2i_ci_cleanup_metadata.images | unique | list | length == + s2i_ci_cleanup_metadata.images | length + - s2i_ci_cleanup_metadata.images | + select('match', '^' + s2i_ci_cleanup_metadata.registry | + regex_escape + '/' + s2i_ci_cleanup_metadata.namespace | + regex_escape + '/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$') | + list | length == s2i_ci_cleanup_metadata.images | length + fail_msg: Refusing unsafe or malformed intended image cleanup metadata + + - name: Record exact intended images for cleanup + ansible.builtin.set_fact: + s2i_ci_cleanup_images_exact: >- + {{ s2i_ci_cleanup_metadata.images | reverse | list }} + when: + - s2i_ci_cleanup_images | bool + - s2i_ci_intended_images_metadata.stat.exists + + - name: Default exact intended image list + ansible.builtin.set_fact: + s2i_ci_cleanup_images_exact: [] + when: + - not s2i_ci_cleanup_images | bool or + not s2i_ci_intended_images_metadata.stat.exists + + - name: Check Podman pullback tags + ansible.builtin.command: + argv: + - podman + - image + - exists + - "{{ item }}" + loop: "{{ s2i_ci_cleanup_images_exact }}" + register: s2i_ci_podman_image_checks + changed_when: false + failed_when: s2i_ci_podman_image_checks.rc not in [0, 1] + + - name: Remove exact Podman pullback tags + ansible.builtin.command: + argv: + - podman + - image + - rm + - --force + - "{{ item.item }}" + loop: "{{ s2i_ci_podman_image_checks.results }}" + when: item.rc == 0 + changed_when: true + + - name: Check Buildah-created image tags + ansible.builtin.command: + argv: + - buildah + - inspect + - --type + - image + - "{{ item }}" + loop: "{{ s2i_ci_cleanup_images_exact }}" + register: s2i_ci_buildah_image_checks + changed_when: false + failed_when: s2i_ci_buildah_image_checks.rc not in [0, 125] + + - name: Remove exact Buildah-created image tags + ansible.builtin.command: + argv: + - buildah + - rmi + - "{{ item.item }}" + loop: "{{ s2i_ci_buildah_image_checks.results }}" + when: item.rc == 0 + changed_when: true + + - name: List remaining Podman image references + ansible.builtin.command: + argv: + - podman + - images + - --format + - "{{ '{{.Repository}}:{{.Tag}}' }}" + register: s2i_ci_remaining_podman_images + changed_when: false + + - name: List remaining Buildah image references + ansible.builtin.command: + argv: + - buildah + - images + - --format + - "{{ '{{.Name}}:{{.Tag}}' }}" + register: s2i_ci_remaining_buildah_images + changed_when: false + + - name: Require exact workflow references to be absent + ansible.builtin.assert: + that: + - item not in s2i_ci_remaining_podman_images.stdout_lines + - item not in s2i_ci_remaining_buildah_images.stdout_lines + fail_msg: "Image cleanup left exact workflow reference {{ item }}" + loop: "{{ s2i_ci_cleanup_images_exact }}" + + - name: Check registry validation metadata + ansible.builtin.stat: + path: "{{ s2i_ci_log_dir }}/registry-state.json" + register: s2i_ci_registry_state_metadata + + - name: Load registry validation metadata + when: s2i_ci_registry_state_metadata.stat.exists + ansible.builtin.slurp: + src: "{{ s2i_ci_log_dir }}/registry-state.json" + register: s2i_ci_registry_state_data + + - name: Decode registry validation metadata + when: s2i_ci_registry_state_metadata.stat.exists + ansible.builtin.set_fact: + s2i_ci_registry_state: >- + {{ s2i_ci_registry_state_data.content | b64decode | from_json }} + + - name: Validate registry validation metadata + when: s2i_ci_registry_state_metadata.stat.exists + ansible.builtin.assert: + that: + - s2i_ci_registry_state is mapping + - s2i_ci_registry_state.endpoint is string + - s2i_ci_registry_state.endpoint is match('^[A-Za-z0-9.:-]+$') + - s2i_ci_registry_state.validation_image is string + - s2i_ci_registry_state.validation_image.startswith( + s2i_ci_registry_state.endpoint + '/') + + - name: Check the Podman registry validation tag + when: s2i_ci_registry_state_metadata.stat.exists + ansible.builtin.command: + argv: + - podman + - image + - exists + - "{{ s2i_ci_registry_state.validation_image }}" + register: s2i_ci_validation_image_check + changed_when: false + failed_when: s2i_ci_validation_image_check.rc not in [0, 1] + + - name: Remove the exact Podman registry validation tag + when: + - s2i_ci_registry_state_metadata.stat.exists + - s2i_ci_validation_image_check.rc == 0 + ansible.builtin.command: + argv: + - podman + - image + - rm + - --force + - "{{ s2i_ci_registry_state.validation_image }}" + changed_when: true + + - name: Require registry validation tag to be absent + when: s2i_ci_registry_state_metadata.stat.exists + ansible.builtin.command: + argv: + - podman + - image + - exists + - "{{ s2i_ci_registry_state.validation_image }}" + register: s2i_ci_validation_image_absent + changed_when: false + failed_when: s2i_ci_validation_image_absent.rc != 1 diff --git a/playbooks/container-ci/shared/deployment-map-item.yaml b/playbooks/container-ci/shared/deployment-map-item.yaml new file mode 100644 index 00000000..c38a4e07 --- /dev/null +++ b/playbooks/container-ci/shared/deployment-map-item.yaml @@ -0,0 +1,7 @@ +--- +- name: Expand image reference for each deployment key + ansible.builtin.set_fact: + s2i_ci_custom_container_images: >- + {{ s2i_ci_custom_container_images | + combine({item: s2i_ci_target_references[s2i_ci_image]}) }} + loop: "{{ s2i_ci_effective_image_mappings[s2i_ci_image] }}" diff --git a/playbooks/container-ci/shared/image-metadata-item.yaml b/playbooks/container-ci/shared/image-metadata-item.yaml new file mode 100644 index 00000000..c9119f47 --- /dev/null +++ b/playbooks/container-ci/shared/image-metadata-item.yaml @@ -0,0 +1,42 @@ +--- +- name: Check the selected image Containerfile + ansible.builtin.stat: + path: "{{ s2i_ci_container_repo }}/containers/{{ s2i_ci_image }}/Containerfile" + register: s2i_ci_image_containerfile + +- name: Require the selected image Containerfile + ansible.builtin.assert: + that: + - s2i_ci_image_containerfile.stat.isreg | default(false) + fail_msg: "Selected image {{ s2i_ci_image }} requires a Containerfile" + +- name: Select effective image deployment keys + ansible.builtin.set_fact: + s2i_ci_effective_image_keys: >- + {{ s2i_ci_image_mappings[s2i_ci_image] + if s2i_ci_image in s2i_ci_image_mappings + else s2i_ci_tracked_image_mappings.get(s2i_ci_image, []) }} + s2i_ci_effective_mapping_source: >- + {{ 'inventory' if s2i_ci_image in s2i_ci_image_mappings else 'tracked' }} + +- name: Validate effective image deployment keys + ansible.builtin.assert: + that: + - s2i_ci_effective_image_keys is sequence + - s2i_ci_effective_image_keys is not string + - s2i_ci_effective_image_keys | select('string') | list | length == + s2i_ci_effective_image_keys | length + - s2i_ci_effective_image_keys | + reject('match', '^[A-Za-z][A-Za-z0-9]*$') | list | length == 0 + - s2i_ci_effective_image_keys | unique | list | length == + s2i_ci_effective_image_keys | length + fail_msg: "Invalid effective image mapping for {{ s2i_ci_image }}" + +- name: Record effective image metadata + ansible.builtin.set_fact: + s2i_ci_effective_image_mappings: >- + {{ s2i_ci_effective_image_mappings | + combine({s2i_ci_image: s2i_ci_effective_image_keys | list}) }} + s2i_ci_mapping_sources: >- + {{ s2i_ci_mapping_sources | + combine({s2i_ci_image: s2i_ci_effective_mapping_source}) }} diff --git a/playbooks/container-ci/shared/load-image-mappings.yaml b/playbooks/container-ci/shared/load-image-mappings.yaml new file mode 100644 index 00000000..4b44834f --- /dev/null +++ b/playbooks/container-ci/shared/load-image-mappings.yaml @@ -0,0 +1,67 @@ +--- +- name: Check the tracked image mapping manifest + ansible.builtin.stat: + path: "{{ s2i_ci_container_repo }}/containers/image-mappings.yaml" + register: s2i_ci_image_mapping_manifest + +- name: Require the tracked image mapping manifest + ansible.builtin.assert: + that: + - s2i_ci_image_mapping_manifest.stat.isreg | default(false) + fail_msg: containers/image-mappings.yaml is required + +- name: Read the tracked image mapping manifest + ansible.builtin.slurp: + src: "{{ s2i_ci_container_repo }}/containers/image-mappings.yaml" + register: s2i_ci_image_mapping_data + +- name: Decode the tracked image mapping manifest + ansible.builtin.set_fact: + s2i_ci_image_mapping_document: >- + {{ s2i_ci_image_mapping_data.content | b64decode | from_yaml }} + +- name: Validate the tracked image mapping schema + ansible.builtin.assert: + that: + - s2i_ci_image_mapping_document is mapping + - s2i_ci_image_mapping_document.openstack_version is mapping + - s2i_ci_image_mapping_document.openstack_version.custom_container_images is mapping + fail_msg: Malformed containers/image-mappings.yaml + +- name: Record tracked image mappings + ansible.builtin.set_fact: + s2i_ci_tracked_image_mappings: >- + {{ s2i_ci_image_mapping_document.openstack_version.custom_container_images }} + s2i_ci_tracked_deployment_keys: [] + +- name: Resolve all available image targets + ansible.builtin.command: + argv: + - "{{ s2i_ci_container_repo }}/build.sh" + - resolve + - all + args: + chdir: "{{ s2i_ci_container_repo }}" + register: s2i_ci_available_images + changed_when: false + +- name: Reject mappings for unknown image targets + ansible.builtin.assert: + that: + - s2i_ci_tracked_image_mappings.keys() | + difference(s2i_ci_available_images.stdout_lines) | length == 0 + fail_msg: containers/image-mappings.yaml names an unknown image target + +- name: Validate every tracked image mapping + ansible.builtin.include_tasks: validate-tracked-mapping-item.yaml + loop: "{{ s2i_ci_tracked_image_mappings.keys() | list }}" + loop_control: + loop_var: s2i_ci_tracked_mapping_target + label: "{{ s2i_ci_tracked_mapping_target }}" + +- name: Reject globally duplicated tracked deployment keys + ansible.builtin.assert: + that: + - s2i_ci_tracked_deployment_keys | unique | list | length == + s2i_ci_tracked_deployment_keys | length + fail_msg: A tracked deployment key may be assigned to only one image diff --git a/playbooks/container-ci/shared/prepare-host.yaml b/playbooks/container-ci/shared/prepare-host.yaml new file mode 100644 index 00000000..64104d47 --- /dev/null +++ b/playbooks/container-ci/shared/prepare-host.yaml @@ -0,0 +1,46 @@ +--- +- name: Prepare the shared container image build tools + hosts: builder + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_log_dir: "{{ s2i_ci_output_dir }}/logs/container-build" + + tasks: + - name: Ensure the container build log directory exists + ansible.builtin.file: + path: "{{ s2i_ci_log_dir }}" + state: directory + mode: "0755" + + - name: Update the apt package cache + when: + - s2i_ci_install_host_packages | default(true) | bool + - ansible_facts.os_family == "Debian" + become: true + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install container build host packages + when: s2i_ci_install_host_packages | default(true) | bool + become: true + ansible.builtin.package: + name: + - buildah + - ca-certificates + - git + - openssl + state: present + + - name: Ensure Podman is installed and usable + when: s2i_ci_install_host_packages | default(true) | bool + ansible.builtin.include_role: + name: ensure-podman + vars: + ensure_podman_rootless: false + ensure_podman_validate: true + + - name: Validate Buildah + when: s2i_ci_install_host_packages | default(true) | bool + ansible.builtin.command: buildah version + changed_when: false diff --git a/playbooks/container-ci/shared/published-image-item.yaml b/playbooks/container-ci/shared/published-image-item.yaml new file mode 100644 index 00000000..1b30ee8a --- /dev/null +++ b/playbooks/container-ci/shared/published-image-item.yaml @@ -0,0 +1,10 @@ +--- +- name: Record exact reference for selected image + ansible.builtin.set_fact: + s2i_ci_target_references: >- + {{ s2i_ci_target_references | + combine({s2i_ci_image: s2i_ci_published_images[s2i_ci_image_index]}) }} + s2i_ci_local_target_references: >- + {{ s2i_ci_local_target_references | + combine({s2i_ci_image: + s2i_ci_local_published_images[s2i_ci_image_index]}) }} diff --git a/playbooks/container-ci/shared/run.yaml b/playbooks/container-ci/shared/run.yaml new file mode 100644 index 00000000..354cdfa6 --- /dev/null +++ b/playbooks/container-ci/shared/run.yaml @@ -0,0 +1,343 @@ +--- +- name: Build and publish selected container images + hosts: builder + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_log_dir: "{{ s2i_ci_output_dir }}/logs/container-build" + s2i_ci_registry_connection_file: >- + {{ s2i_ci_output_dir }}/.s2i-ci-registry-connection.json + s2i_ci_workspace_root: "{{ zuul_user_dir | default(ansible_user_dir) }}" + + tasks: + - name: Default optional selective provider inputs + ansible.builtin.set_fact: + s2i_ci_namespace: "{{ s2i_ci_namespace | default('openstack') }}" + s2i_ci_tag: >- + {{ s2i_ci_tag | default( + (zuul.change | default('build') | string) ~ '-' ~ + (zuul.patchset | default('latest') | string) + ) }} + s2i_ci_stream: "{{ s2i_ci_stream | default('master') }}" + s2i_ci_parallel: >- + {{ s2i_ci_parallel | + default(ansible_facts.processor_vcpus | default(1)) }} + s2i_ci_images: "{{ s2i_ci_images | default('all') }}" + s2i_ci_image_mappings: "{{ s2i_ci_image_mappings | default({}) }}" + + - name: Validate the selected container project + ansible.builtin.assert: + that: + - s2i_ci_container_project in zuul.projects + fail_msg: The selected container project is not in the Zuul workspace + + - name: Resolve the inventory-described container repository + ansible.builtin.set_fact: + s2i_ci_container_repo: >- + {{ s2i_ci_workspace_root }}/{{ + zuul.projects[s2i_ci_container_project].src_dir }} + + - name: Inspect the container build entry point on the builder + ansible.builtin.stat: + path: "{{ s2i_ci_container_repo }}/build.sh" + follow: true + register: s2i_ci_build_entry_point + + - name: Canonicalize the builder workspace root + ansible.builtin.command: + argv: [realpath, --canonicalize-existing, "{{ s2i_ci_workspace_root }}"] + register: s2i_ci_workspace_root_realpath + changed_when: false + failed_when: false + + - name: Canonicalize the container repository on the builder + ansible.builtin.command: + argv: [realpath, --canonicalize-existing, "{{ s2i_ci_container_repo }}"] + register: s2i_ci_container_repo_realpath + changed_when: false + failed_when: false + + - name: Validate selective provider inputs + ansible.builtin.assert: + that: + - s2i_ci_build_entry_point.stat.isreg | default(false) + - s2i_ci_workspace_root_realpath.rc == 0 + - s2i_ci_container_repo_realpath.rc == 0 + - s2i_ci_container_repo_realpath.stdout.startswith( + s2i_ci_workspace_root_realpath.stdout + '/') + - >- + s2i_ci_images == 'all' or + (s2i_ci_images is sequence and s2i_ci_images is not string and + s2i_ci_images | length > 0 and + s2i_ci_images | select('string') | list | length == + s2i_ci_images | length and + s2i_ci_images | + reject('match', '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') | + list | length == 0 and + s2i_ci_images | unique | list | length == s2i_ci_images | length) + - s2i_ci_image_mappings is mapping + - s2i_ci_tag is string + - "',' not in s2i_ci_tag" + fail_msg: Selective provider inputs are invalid + + - name: Record the requested target expression + ansible.builtin.set_fact: + s2i_ci_target_expression: >- + {{ 'all' if s2i_ci_images == 'all' + else ((['base'] + s2i_ci_images) | unique | list | join(',')) }} + s2i_ci_effective_image_mappings: {} + s2i_ci_mapping_sources: {} + + - name: Resolve selected image targets + ansible.builtin.command: + argv: + - "{{ s2i_ci_container_repo }}/build.sh" + - resolve + - "{{ s2i_ci_target_expression }}" + args: + chdir: "{{ s2i_ci_container_repo }}" + register: s2i_ci_resolved_images + changed_when: false + + - name: Record normalized selected targets + ansible.builtin.set_fact: + s2i_ci_selected_images: "{{ s2i_ci_resolved_images.stdout_lines }}" + + - name: Validate optional mapping overrides + ansible.builtin.include_tasks: validate-mapping-overrides.yaml + + - name: Load tracked image mappings + ansible.builtin.include_tasks: load-image-mappings.yaml + + - name: Load and validate selected image metadata + ansible.builtin.include_tasks: image-metadata-item.yaml + loop: "{{ s2i_ci_selected_images }}" + loop_control: + loop_var: s2i_ci_image + label: "{{ s2i_ci_image }}" + + - name: Validate effective deployment key ownership + ansible.builtin.include_tasks: validate-deployment-keys.yaml + + - name: Load the normalized registry connection + ansible.builtin.slurp: + src: "{{ s2i_ci_registry_connection_file }}" + register: s2i_ci_registry_connection_data + no_log: true + + - name: Decode the normalized registry connection + ansible.builtin.set_fact: + s2i_ci_registry: >- + {{ s2i_ci_registry_connection_data.content | b64decode | from_json }} + no_log: true + + - name: Validate normalized registry connection + ansible.builtin.assert: + that: + - s2i_ci_registry is mapping + - s2i_ci_registry.endpoint is string + - s2i_ci_registry.endpoint | length > 0 + - s2i_ci_registry.public_host is string + - s2i_ci_registry.public_host | length > 0 + - s2i_ci_registry.public_port | int > 0 + - s2i_ci_registry.auth_file | default('') is string + - s2i_ci_registry.cert_dir | default('') is string + + - name: Resolve intended exact image references before mutation + ansible.builtin.command: + argv: + - "{{ s2i_ci_container_repo }}/build.sh" + - refs + - "{{ s2i_ci_target_expression }}" + chdir: "{{ s2i_ci_container_repo }}" + environment: + STREAM: "{{ s2i_ci_stream }}" + REGISTRY: "{{ s2i_ci_registry.endpoint }}" + NAMESPACE: "{{ s2i_ci_namespace }}" + TAG: "{{ s2i_ci_tag }}" + register: s2i_ci_intended_refs + changed_when: false + + - name: Record intended exact image references + ansible.builtin.set_fact: + s2i_ci_intended_images: >- + {{ s2i_ci_intended_refs.stdout_lines | reject('equalto', '') | list }} + + - name: Validate intended exact image references + ansible.builtin.assert: + that: + - s2i_ci_intended_images | length == s2i_ci_selected_images | length + - s2i_ci_intended_images | unique | list | length == + s2i_ci_intended_images | length + - s2i_ci_intended_images | + select('match', '^' + s2i_ci_registry.endpoint | regex_escape + + '/' + s2i_ci_namespace + '/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$') | + list | length == s2i_ci_intended_images | length + fail_msg: Intended image references are incomplete or unsafe + + - name: Persist intended references for partial-failure cleanup + ansible.builtin.copy: + dest: "{{ s2i_ci_log_dir }}/intended-images.json" + mode: "0644" + content: | + {{ { + 'registry': s2i_ci_registry.endpoint, + 'namespace': s2i_ci_namespace, + 'images': s2i_ci_intended_images, + 'selected_images': s2i_ci_selected_images, + 'target': s2i_ci_target_expression + } | to_nice_json }} + + - name: Build selected images from committed source pins + ansible.builtin.command: + argv: + - "{{ s2i_ci_container_repo }}/build.sh" + - build-parallel + - "{{ s2i_ci_target_expression }}" + chdir: "{{ s2i_ci_container_repo }}" + environment: + STREAM: "{{ s2i_ci_stream }}" + REGISTRY: "{{ s2i_ci_registry.endpoint }}" + NAMESPACE: "{{ s2i_ci_namespace }}" + TAG: "{{ s2i_ci_tag }}" + PARALLEL: "{{ s2i_ci_parallel | string }}" + BUILD_LOGS_DIR: "{{ s2i_ci_log_dir }}" + register: s2i_ci_build + changed_when: s2i_ci_build.rc == 0 + + - name: Save build command output + ansible.builtin.copy: + dest: "{{ s2i_ci_log_dir }}/build.log" + mode: "0644" + content: | + {{ s2i_ci_build.stdout }} + {{ s2i_ci_build.stderr }} + + - name: Push selected images to the buildset registry + ansible.builtin.command: + argv: + - "{{ s2i_ci_container_repo }}/build.sh" + - push + - "{{ s2i_ci_target_expression }}" + chdir: "{{ s2i_ci_container_repo }}" + environment: + STREAM: "{{ s2i_ci_stream }}" + REGISTRY: "{{ s2i_ci_registry.endpoint }}" + NAMESPACE: "{{ s2i_ci_namespace }}" + TAG: "{{ s2i_ci_tag }}" + REGISTRY_AUTH_FILE: "{{ s2i_ci_registry.auth_file | default('') }}" + REGISTRY_CERT_DIR: "{{ s2i_ci_registry.cert_dir | default('') }}" + register: s2i_ci_push + changed_when: s2i_ci_push.rc == 0 + + - name: Save push command output + ansible.builtin.copy: + dest: "{{ s2i_ci_log_dir }}/push.log" + mode: "0644" + content: | + {{ s2i_ci_push.stdout }} + {{ s2i_ci_push.stderr }} + + - name: Resolve successfully published image references + ansible.builtin.command: + argv: + - "{{ s2i_ci_container_repo }}/build.sh" + - refs + - "{{ s2i_ci_target_expression }}" + chdir: "{{ s2i_ci_container_repo }}" + environment: + STREAM: "{{ s2i_ci_stream }}" + REGISTRY: "{{ s2i_ci_registry.endpoint }}" + NAMESPACE: "{{ s2i_ci_namespace }}" + TAG: "{{ s2i_ci_tag }}" + register: s2i_ci_refs + changed_when: false + + - name: Record exact builder-local published image references + ansible.builtin.set_fact: + s2i_ci_local_published_images: >- + {{ s2i_ci_refs.stdout_lines | reject('equalto', '') | list }} + + - name: Require intended and published references to match + ansible.builtin.assert: + that: + - s2i_ci_local_published_images == s2i_ci_intended_images + fail_msg: Published references differ from the pre-recorded intent + + - name: Record the public buildset registry endpoint + ansible.builtin.set_fact: + s2i_ci_public_registry_endpoint: >- + {{ s2i_ci_registry.public_host }}:{{ s2i_ci_registry.public_port }} + + - name: Convert published references to the public buildset endpoint + ansible.builtin.set_fact: + s2i_ci_published_images: >- + {{ s2i_ci_local_published_images | + map('regex_replace', + '^' + (s2i_ci_registry.endpoint | regex_escape), + s2i_ci_public_registry_endpoint) | list }} + + - name: Validate public exact published references + ansible.builtin.assert: + that: + - s2i_ci_published_images | length == + s2i_ci_local_published_images | length + - s2i_ci_published_images | + select('match', '^' + + (s2i_ci_public_registry_endpoint | regex_escape) + '/') | + list | length == s2i_ci_published_images | length + - s2i_ci_published_images | + select('search', s2i_ci_registry.endpoint | regex_escape) | + list | length == 0 + fail_msg: Public references must not expose the builder-local alias + + - name: Pull published images back from the registry + ansible.builtin.command: + argv: >- + {{ ['podman', 'pull'] + + (['--authfile', s2i_ci_registry.auth_file] + if s2i_ci_registry.auth_file | default('') | length > 0 else []) + + (['--cert-dir', s2i_ci_registry.cert_dir] + if s2i_ci_registry.cert_dir | default('') | length > 0 else []) + + [item] }} + loop: "{{ s2i_ci_local_published_images }}" + changed_when: true + + - name: Initialize exact reference expansion + ansible.builtin.set_fact: + s2i_ci_target_references: {} + s2i_ci_local_target_references: {} + s2i_ci_custom_container_images: {} + + - name: Map selected targets to exact references + ansible.builtin.include_tasks: published-image-item.yaml + loop: "{{ s2i_ci_selected_images }}" + loop_control: + loop_var: s2i_ci_image + index_var: s2i_ci_image_index + label: "{{ s2i_ci_image }}" + + - name: Expand exact references into deployment keys + ansible.builtin.include_tasks: deployment-map-item.yaml + loop: "{{ s2i_ci_selected_images }}" + loop_control: + loop_var: s2i_ci_image + label: "{{ s2i_ci_image }}" + + - name: Save published image metadata + ansible.builtin.copy: + dest: "{{ s2i_ci_log_dir }}/published-images.json" + mode: "0644" + content: | + {{ { + 'registry': s2i_ci_public_registry_endpoint, + 'namespace': s2i_ci_namespace, + 'tag': s2i_ci_tag, + 'target': s2i_ci_target_expression, + 'stream': s2i_ci_stream, + 'selected_images': s2i_ci_selected_images, + 'images': s2i_ci_published_images, + 'image_mappings': s2i_ci_effective_image_mappings, + 'mapping_sources': s2i_ci_mapping_sources, + 'custom_container_images': + s2i_ci_custom_container_images + } | to_nice_json }} diff --git a/playbooks/container-ci/shared/validate-deployment-keys.yaml b/playbooks/container-ci/shared/validate-deployment-keys.yaml new file mode 100644 index 00000000..f9750707 --- /dev/null +++ b/playbooks/container-ci/shared/validate-deployment-keys.yaml @@ -0,0 +1,12 @@ +--- +- name: Record all effective deployment keys + ansible.builtin.set_fact: + s2i_ci_all_deployment_keys: >- + {{ s2i_ci_effective_image_mappings.values() | flatten | list }} + +- name: Reject duplicate deployment keys + ansible.builtin.assert: + that: + - s2i_ci_all_deployment_keys | unique | list | length == + s2i_ci_all_deployment_keys | length + fail_msg: A deployment key may be assigned to only one image diff --git a/playbooks/container-ci/shared/validate-mapping-overrides.yaml b/playbooks/container-ci/shared/validate-mapping-overrides.yaml new file mode 100644 index 00000000..081c990a --- /dev/null +++ b/playbooks/container-ci/shared/validate-mapping-overrides.yaml @@ -0,0 +1,8 @@ +--- +- name: Reject mapping overrides for unselected images + ansible.builtin.assert: + that: + - s2i_ci_image_mappings is mapping + - s2i_ci_image_mappings.keys() | + difference(s2i_ci_selected_images) | length == 0 + fail_msg: Image mapping overrides must name selected image targets diff --git a/playbooks/container-ci/shared/validate-registry.yaml b/playbooks/container-ci/shared/validate-registry.yaml new file mode 100644 index 00000000..cad25f04 --- /dev/null +++ b/playbooks/container-ci/shared/validate-registry.yaml @@ -0,0 +1,109 @@ +--- +- name: Validate the prepared image registry connection + hosts: builder + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_log_dir: "{{ s2i_ci_output_dir }}/logs/container-build" + s2i_ci_registry_connection_file: >- + {{ s2i_ci_output_dir }}/.s2i-ci-registry-connection.json + s2i_ci_validation_source_image: registry.access.redhat.com/ubi10/ubi-minimal:latest + s2i_ci_validation_repository: s2i-validation/ubi10-minimal:latest + + tasks: + - name: Load the normalized registry connection + ansible.builtin.slurp: + src: "{{ s2i_ci_registry_connection_file }}" + register: s2i_ci_registry_connection_data + + - name: Decode the normalized registry connection + ansible.builtin.set_fact: + s2i_ci_registry_connection: >- + {{ s2i_ci_registry_connection_data.content | b64decode | from_json }} + + - name: Require normalized registry connection data + ansible.builtin.assert: + that: + - s2i_ci_registry_connection is mapping + - s2i_ci_registry_connection.endpoint is string + - s2i_ci_registry_connection.endpoint | length > 0 + - s2i_ci_registry_connection.public_host is string + - s2i_ci_registry_connection.public_host | length > 0 + - s2i_ci_registry_connection.public_port | int > 0 + - s2i_ci_registry_connection.auth_file | default('') is string + - s2i_ci_registry_connection.cert_dir | default('') is string + + - name: Set normalized registry command parameters + ansible.builtin.set_fact: + s2i_ci_registry_endpoint: "{{ s2i_ci_registry_connection.endpoint }}" + s2i_ci_registry_auth_file: >- + {{ s2i_ci_registry_connection.auth_file | default('') }} + s2i_ci_registry_cert_dir: >- + {{ s2i_ci_registry_connection.cert_dir | default('') }} + + - name: Pull the UBI 10 base image + ansible.builtin.command: + argv: + - podman + - pull + - "{{ s2i_ci_validation_source_image }}" + register: s2i_ci_ubi_pull + changed_when: "'Copying blob' in s2i_ci_ubi_pull.stdout" + + - name: Tag the UBI 10 image for registry validation + ansible.builtin.command: + argv: + - podman + - tag + - "{{ s2i_ci_validation_source_image }}" + - "{{ s2i_ci_registry_endpoint }}/{{ s2i_ci_validation_repository }}" + changed_when: true + + - name: Push the UBI 10 validation image + ansible.builtin.command: + argv: >- + {{ ['podman', 'push', '--remove-signatures'] + + (['--authfile', s2i_ci_registry_auth_file] + if s2i_ci_registry_auth_file | length > 0 else []) + + (['--cert-dir', s2i_ci_registry_cert_dir] + if s2i_ci_registry_cert_dir | length > 0 else []) + + [s2i_ci_registry_endpoint + '/' + s2i_ci_validation_repository] }} + changed_when: true + + - name: Remove the validation tag before pulling it back + ansible.builtin.command: + argv: + - podman + - untag + - "{{ s2i_ci_registry_endpoint }}/{{ s2i_ci_validation_repository }}" + changed_when: true + + - name: Pull the UBI 10 validation image back from the registry + ansible.builtin.command: + argv: >- + {{ ['podman', 'pull'] + + (['--authfile', s2i_ci_registry_auth_file] + if s2i_ci_registry_auth_file | length > 0 else []) + + (['--cert-dir', s2i_ci_registry_cert_dir] + if s2i_ci_registry_cert_dir | length > 0 else []) + + [s2i_ci_registry_endpoint + '/' + s2i_ci_validation_repository] }} + changed_when: false + + - name: Inspect the UBI 10 validation image from the registry + ansible.builtin.command: + argv: + - podman + - image + - inspect + - "{{ s2i_ci_registry_endpoint }}/{{ s2i_ci_validation_repository }}" + changed_when: false + + - name: Record non-secret registry state + ansible.builtin.copy: + dest: "{{ s2i_ci_log_dir }}/registry-state.json" + mode: "0644" + content: | + {{ { + 'endpoint': s2i_ci_registry_endpoint, + 'validation_source': s2i_ci_validation_source_image, + 'validation_image': s2i_ci_registry_endpoint + '/' + s2i_ci_validation_repository + } | to_nice_json }} diff --git a/playbooks/container-ci/shared/validate-tracked-mapping-item.yaml b/playbooks/container-ci/shared/validate-tracked-mapping-item.yaml new file mode 100644 index 00000000..95f885bc --- /dev/null +++ b/playbooks/container-ci/shared/validate-tracked-mapping-item.yaml @@ -0,0 +1,24 @@ +--- +- name: Select tracked deployment keys for validation + ansible.builtin.set_fact: + s2i_ci_tracked_mapping_keys: >- + {{ s2i_ci_tracked_image_mappings[s2i_ci_tracked_mapping_target] }} + +- name: Validate tracked deployment keys + ansible.builtin.assert: + that: + - s2i_ci_tracked_mapping_keys is sequence + - s2i_ci_tracked_mapping_keys is not string + - s2i_ci_tracked_mapping_keys | select('string') | list | length == + s2i_ci_tracked_mapping_keys | length + - s2i_ci_tracked_mapping_keys | + reject('match', '^[A-Za-z][A-Za-z0-9]*$') | list | length == 0 + - s2i_ci_tracked_mapping_keys | unique | list | length == + s2i_ci_tracked_mapping_keys | length + fail_msg: >- + Invalid tracked image mapping for {{ s2i_ci_tracked_mapping_target }} + +- name: Accumulate tracked deployment keys + ansible.builtin.set_fact: + s2i_ci_tracked_deployment_keys: >- + {{ s2i_ci_tracked_deployment_keys + s2i_ci_tracked_mapping_keys }} diff --git a/playbooks/container-ci/zuul/content-provider-return.yaml b/playbooks/container-ci/zuul/content-provider-return.yaml new file mode 100644 index 00000000..310e42b7 --- /dev/null +++ b/playbooks/container-ci/zuul/content-provider-return.yaml @@ -0,0 +1,40 @@ +--- +- name: Return selective image metadata and pause the provider + zuul_return: + data: + zuul: + pause: true + s2i_ci_content: + registry: "{{ s2i_ci_public_registry_endpoint }}" + registry_host: "{{ s2i_ci_registry.public_host }}" + registry_port: "{{ s2i_ci_registry.public_port }}" + namespace: "{{ s2i_ci_namespace }}" + tag: "{{ s2i_ci_tag }}" + target: "{{ s2i_ci_target_expression }}" + stream: "{{ s2i_ci_stream }}" + selected_images: "{{ s2i_ci_selected_images }}" + images: "{{ s2i_ci_published_images }}" + image_mappings: "{{ s2i_ci_effective_image_mappings }}" + mapping_sources: "{{ s2i_ci_mapping_sources }}" + custom_container_images: "{{ s2i_ci_custom_container_images }}" + # Namespaced as s2i_content_provider_* to avoid colliding with the + # standard openstack-k8s-operators content provider's variables when + # both run as parents of the same consumer job. For standalone use + # (s2i provider only), consumers reference these directly. + s2i_content_provider_os_custom_container_images: >- + {{ s2i_ci_custom_container_images }} + # Intentional string sentinel -- consumers test `!= "null"` or check + # s2i_content_provider_registry_available instead. Cannot be actual + # null because Zuul zuul_return merges dicts and null would not + # override a parent job's existing value. + s2i_content_provider_os_registry_url: "null" + s2i_content_provider_os_registry_namespace: "" + s2i_content_provider_os_registry_tag: "" + s2i_content_provider_dlrn_md5_hash: "" + s2i_content_provider_gating_repo_available: false + s2i_content_provider_gating_repo_url: "" + s2i_content_provider_registry_available: true + s2i_content_provider_registry_ip: "{{ s2i_ci_registry.public_host }}" + s2i_content_provider_registry_ip_port: >- + {{ s2i_ci_public_registry_endpoint }} + s2i_cifmw_build_images_output: {} diff --git a/playbooks/container-ci/zuul/post.yaml b/playbooks/container-ci/zuul/post.yaml new file mode 100644 index 00000000..c4624961 --- /dev/null +++ b/playbooks/container-ci/zuul/post.yaml @@ -0,0 +1,124 @@ +--- +- name: Collect Zuul container logs + hosts: builder + gather_facts: false + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_log_dir: "{{ s2i_ci_output_dir }}/logs/container-build" + + tasks: + - name: Ensure the container build log directory exists + ansible.builtin.file: + path: "{{ s2i_ci_log_dir }}" + state: directory + mode: "0755" + failed_when: false + + - name: Collect buildset registry logs + block: + - name: Run the trusted container log collection role + ansible.builtin.include_role: + name: collect-container-logs + vars: + container_command: podman + rescue: + - name: Report non-fatal Zuul container log collection failure + ansible.builtin.debug: + msg: Container log collection failed; registry cleanup will continue + +- name: Remove exact workflow images + ansible.builtin.import_playbook: ../shared/cleanup-images.yaml + +- name: Remove the project-owned Zuul buildset registry + hosts: builder + gather_facts: false + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_registry_root: "{{ ansible_user_dir }}/buildset_registry" + s2i_ci_registry_connection_file: >- + {{ s2i_ci_output_dir }}/.s2i-ci-registry-connection.json + s2i_ci_registry_port: 5000 + + tasks: + - name: Check buildset registry ownership + ansible.builtin.stat: + path: "{{ s2i_ci_registry_root }}/.s2i-ci-registry-owner.json" + register: s2i_ci_registry_owner_marker + + - name: Default buildset registry ownership to unloaded + ansible.builtin.set_fact: + s2i_ci_registry_owner_loaded: false + s2i_ci_registry_owner_is_valid: false + + - name: Load buildset registry ownership + when: s2i_ci_registry_owner_marker.stat.exists | default(false) + block: + - name: Read the buildset registry ownership marker + ansible.builtin.slurp: + src: "{{ s2i_ci_registry_root }}/.s2i-ci-registry-owner.json" + register: s2i_ci_registry_owner_data + + - name: Decode the buildset registry ownership marker + ansible.builtin.set_fact: + s2i_ci_registry_owner: >- + {{ s2i_ci_registry_owner_data.content | b64decode | from_json }} + + - name: Record successful ownership loading + ansible.builtin.set_fact: + s2i_ci_registry_owner_loaded: true + rescue: + - name: Refuse unreadable registry ownership metadata + ansible.builtin.fail: + msg: Refusing to clean a registry whose ownership marker is invalid + + - name: Validate buildset registry ownership + when: s2i_ci_registry_owner_loaded | bool + ansible.builtin.assert: + that: + - s2i_ci_registry_owner.port | default(-1) | int == + s2i_ci_registry_port | int + - s2i_ci_registry_owner.container | default('') in + ['buildset_registry', 'buildset_registry_' + s2i_ci_registry_port | string] + fail_msg: Refusing to clean a registry with an invalid ownership marker + + - name: Record valid buildset registry ownership + when: s2i_ci_registry_owner_loaded | bool + ansible.builtin.set_fact: + s2i_ci_registry_owner_is_valid: true + + - name: Remove the project-owned buildset registry container + when: + - s2i_ci_registry_owner_loaded | bool + - s2i_ci_registry_owner_is_valid | bool + ansible.builtin.command: + argv: + - podman + - rm + - --force + - "{{ s2i_ci_registry_owner.container }}" + register: s2i_ci_removed_buildset_registry + changed_when: s2i_ci_removed_buildset_registry.stdout | length > 0 + + - name: Stop the project-owned buildset registry tunnel + when: + - s2i_ci_registry_owner_loaded | bool + - s2i_ci_registry_owner_is_valid | bool + - s2i_ci_registry_owner.tunnel_pid | default('') | length > 0 + ansible.builtin.command: + argv: [kill, "{{ s2i_ci_registry_owner.tunnel_pid }}"] + register: s2i_ci_stopped_registry_tunnel + changed_when: s2i_ci_stopped_registry_tunnel.rc == 0 + failed_when: false + + - name: Remove project-owned buildset registry state + when: + - s2i_ci_registry_owner_loaded | bool + - s2i_ci_registry_owner_is_valid | bool + ansible.builtin.file: + path: "{{ s2i_ci_registry_root }}" + state: absent + + - name: Remove persisted buildset registry connection data + ansible.builtin.file: + path: "{{ s2i_ci_registry_connection_file }}" + state: absent diff --git a/playbooks/container-ci/zuul/pre.yaml b/playbooks/container-ci/zuul/pre.yaml new file mode 100644 index 00000000..53ee08e8 --- /dev/null +++ b/playbooks/container-ci/zuul/pre.yaml @@ -0,0 +1,147 @@ +--- +- name: Prepare shared build tools + ansible.builtin.import_playbook: ../shared/prepare-host.yaml + +- name: Prepare the Zuul buildset registry + hosts: builder + vars: + s2i_ci_output_dir: "{{ ansible_user_dir }}/zuul-output" + s2i_ci_registry_root: "{{ ansible_user_dir }}/buildset_registry" + s2i_ci_registry_connection_file: >- + {{ s2i_ci_output_dir }}/.s2i-ci-registry-connection.json + s2i_ci_registry_port: 5000 + s2i_ci_registry_image: quay.io/zuul-ci/zuul-registry@sha256:0bc02a2eed546daa570ebb18829579057cefceefd9ee15660dcf5a1a752d8f78 + + tasks: + - name: Record whether this job owns the buildset registry + ansible.builtin.set_fact: + s2i_ci_owns_buildset_registry: "{{ buildset_registry is not defined }}" + + - name: Compute the buildset registry container name + ansible.builtin.set_fact: + s2i_ci_registry_container_name: >- + {{ 'buildset_registry' if s2i_ci_registry_port | int == 5000 + else 'buildset_registry_' + s2i_ci_registry_port | string }} + + - name: Discard a stale ownership marker for an inherited registry + when: not s2i_ci_owns_buildset_registry | bool + ansible.builtin.file: + path: "{{ s2i_ci_registry_root }}/.s2i-ci-registry-owner.json" + state: absent + + - name: Remove a stale project-owned buildset registry container + when: s2i_ci_owns_buildset_registry | bool + ansible.builtin.command: + argv: [podman, rm, --force, "{{ s2i_ci_registry_container_name }}"] + register: s2i_ci_stale_buildset_registry + changed_when: s2i_ci_stale_buildset_registry.stdout | length > 0 + failed_when: s2i_ci_stale_buildset_registry.rc not in [0, 1] + + - name: Start the Zuul buildset registry + when: s2i_ci_owns_buildset_registry | bool + ansible.builtin.include_role: + name: run-buildset-registry + vars: + buildset_registry_root: "{{ s2i_ci_registry_root }}" + buildset_registry_port: "{{ s2i_ci_registry_port }}" + buildset_registry_image: "{{ s2i_ci_registry_image }}" + container_command: podman + + - name: Find the socat tunnel PID for the buildset registry + when: s2i_ci_owns_buildset_registry | bool + ansible.builtin.shell: + cmd: >- + pgrep -f 'socat.*TCP.*LISTEN:{{ s2i_ci_registry_port }}' || true + executable: /bin/bash + register: s2i_ci_socat_pid_result + changed_when: false + + - name: Record ownership of the started buildset registry + when: s2i_ci_owns_buildset_registry | bool + ansible.builtin.copy: + dest: "{{ s2i_ci_registry_root }}/.s2i-ci-registry-owner.json" + mode: "0600" + content: | + {{ { + 'container': s2i_ci_registry_container_name, + 'port': s2i_ci_registry_port, + 'tunnel_pid': s2i_ci_socat_pid_result.stdout_lines | first | default('') + } | to_nice_json }} + + # Workaround: the run-buildset-registry role from zuul-jobs does not + # mount volumes with :Z labels. On SELinux-enforcing nodes the registry + # cannot access its TLS/conf directories. Remove and recreate with + # the correct labels. + - name: Remove the unlabeled buildset registry container + when: + - s2i_ci_owns_buildset_registry | bool + - ansible_selinux.status | default("disabled") == "enabled" + ansible.builtin.command: + argv: + - podman + - rm + - --force + - "{{ s2i_ci_registry_container_name }}" + changed_when: true + + - name: Start the buildset registry with SELinux-labeled volumes + when: + - s2i_ci_owns_buildset_registry | bool + - ansible_selinux.status | default("disabled") == "enabled" + ansible.builtin.command: + argv: + - podman + - run + - --detach + - "--name={{ s2i_ci_registry_container_name }}" + - --restart=always + - "--publish=1{{ s2i_ci_registry_port }}:5000" + - "--volume={{ s2i_ci_registry_root }}/tls:/tls:Z" + - "--volume={{ s2i_ci_registry_root }}/conf:/conf:Z" + - "{{ s2i_ci_registry_image }}" + - zuul-registry + - -d + changed_when: true + + - name: Wait for the buildset registry authentication endpoint + when: s2i_ci_owns_buildset_registry | bool + ansible.builtin.uri: + url: "https://127.0.0.1:{{ s2i_ci_registry_port }}/v2/" + validate_certs: false + status_code: 401 + register: s2i_ci_buildset_registry_api + retries: 30 + delay: 1 + until: s2i_ci_buildset_registry_api.status == 401 + + - name: Configure the host to use the Zuul buildset registry + ansible.builtin.include_role: + name: use-buildset-registry + vars: + buildset_registry_namespaces: + - [docker.io, https://registry-1.docker.io] + - [quay.io, https://quay.io] + - [gcr.io, https://gcr.io] + - [registry.k8s.io, https://registry.k8s.io] + - [registry.access.redhat.com, https://registry.access.redhat.com] + + - name: Set the Zuul buildset registry endpoint + ansible.builtin.set_fact: + s2i_ci_registry_endpoint: >- + {{ buildset_registry_alias }}:{{ buildset_registry.port }} + + - name: Write the normalized registry connection + ansible.builtin.copy: + dest: "{{ s2i_ci_registry_connection_file }}" + mode: "0600" + content: | + {{ { + 'endpoint': s2i_ci_registry_endpoint, + 'public_host': buildset_registry.host, + 'public_port': buildset_registry.port, + 'auth_file': '', + 'cert_dir': '' + } | to_nice_json }} + +- name: Validate the normalized buildset registry + ansible.builtin.import_playbook: ../shared/validate-registry.yaml diff --git a/playbooks/container-ci/zuul/run.yaml b/playbooks/container-ci/zuul/run.yaml new file mode 100644 index 00000000..e8afdfdf --- /dev/null +++ b/playbooks/container-ci/zuul/run.yaml @@ -0,0 +1,12 @@ +--- +- name: Run shared selective image publication + ansible.builtin.import_playbook: ../shared/run.yaml + +- name: Return published image metadata to Zuul + hosts: builder + gather_facts: false + + tasks: + - name: Return content-provider metadata to Zuul + when: s2i_ci_content_provider | default(false) | bool + ansible.builtin.include_tasks: content-provider-return.yaml diff --git a/tests/test_build.sh b/tests/test_build.sh new file mode 100755 index 00000000..b676fd43 --- /dev/null +++ b/tests/test_build.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Tests for build.sh build, refs, resolve, and build-parallel actions. +# +# Creates a minimal containers tree with a fake buildah so tests run +# offline and without real container builds. +# +# Usage: +# bash tests/test_build.sh +# tox -e test +# +set -uo pipefail + +# ── Test runner ────────────────────────────────────────────────────────── + +_PASS=0 +_FAIL=0 + +assert() { + local desc="$1" + shift + if "$@"; then + return 0 + fi + echo " ASSERTION FAILED: ${desc}" + echo " command: $*" + return 1 +} + +assert_file_exists() { assert "file exists: $1" test -f "$1"; } +assert_grep() { assert "grep '$1' in $2" grep -q "$1" "$2"; } +assert_no_grep() { + if grep -q "$1" "$2" 2>/dev/null; then + echo " ASSERTION FAILED: '$1' should not appear in $2" + return 1 + fi +} + +run_test() { + local name="$1" + + _setup_fixture + + local rc=0 + ( set -e; "${name}" ) || rc=$? + + if [[ ${rc} -eq 0 ]]; then + echo " PASS ${name}" + ((_PASS++)) + else + echo " FAIL ${name}" + ((_FAIL++)) + if [[ -f "${TEST_DIR}/build.log" ]]; then + echo " --- build.log (last 20 lines) ---" + tail -20 "${TEST_DIR}/build.log" | sed 's/^/ /' + echo " ---" + fi + fi + + _teardown_fixture +} + +# ── Fixture ────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TEST_DIR="" + +_setup_fixture() { + TEST_DIR="$(mktemp -d)" + + # Symlink build.sh + ln -s "${SCRIPT_DIR}/build.sh" "${TEST_DIR}/build.sh" + + # Containers tree: base + two images + for target in base alpha/one beta/two; do + local image_root="${TEST_DIR}/containers/${target}" + mkdir -p "${image_root}" + echo "FROM scratch" > "${image_root}/Containerfile" + local project="${target%%/*}" + local project_root="${TEST_DIR}/containers/${project}" + touch "${project_root}/requirements.lock.master" + if [[ "${target}" == */* ]]; then + mkdir -p "${project_root}/src/${project}" + fi + done + + # Fake buildah (bash, not python -- no python dependency) + mkdir -p "${TEST_DIR}/bin" + cat > "${TEST_DIR}/bin/buildah" <<'FAKE_BUILDAH' +#!/usr/bin/env bash +set -eu +case "$1" in + bud) + for i in $(seq 2 $#); do + if [[ "${!i}" == "-f" ]]; then + next=$((i + 1)) + cf="${!next}" + dir="$(dirname "${cf}")" + image="$(basename "${dir}")" + parent="$(basename "$(dirname "${dir}")")" + [[ "${parent}" != "containers" ]] && image="${parent}/${image}" + if [[ "${image}" != "base" ]]; then + echo "LIVE ${image}" + sleep 0.5 + fi + if [[ -n "${FAIL_IMAGE:-}" ]] && [[ "${image}" == *"${FAIL_IMAGE}" ]]; then + echo "FAIL ${image}" >&2 + exit 9 + fi + echo "DONE ${image}" + exit 0 + fi + done + exit 2 + ;; + inspect|push) + exit 0 + ;; + *) + exit 2 + ;; +esac +FAKE_BUILDAH + chmod +x "${TEST_DIR}/bin/buildah" + + # Logs directory + mkdir -p "${TEST_DIR}/logs" +} + +_teardown_fixture() { + [[ -n "${TEST_DIR}" ]] && rm -rf "${TEST_DIR}" +} + +_run() { + local action="$1" + shift + ( + cd "${TEST_DIR}" + export PATH="${TEST_DIR}/bin:${PATH}" + export STREAM=master + export REGISTRY=registry.test:5000 + export NAMESPACE=openstack + export TAG=test + export PARALLEL=2 + export BUILD_LOGS_DIR="${TEST_DIR}/logs" + ./build.sh "${action}" "$@" + ) +} + +# ── Tests ──────────────────────────────────────────────────────────────── + +test_explicit_union_preserves_order_and_deduplicates() { + local output + output="$(_run refs "beta/two,alpha/one,beta/two" 2>/dev/null)" + + local line_two line_one + line_two="$(echo "${output}" | grep -n "openstack-two" | head -1 | cut -d: -f1)" + line_one="$(echo "${output}" | grep -n "openstack-one" | head -1 | cut -d: -f1)" + assert "two before one" test "${line_two}" -lt "${line_one}" + + local count + count="$(echo "${output}" | wc -l | tr -d ' ')" + assert "exactly 2 refs (deduplicated)" test "${count}" -eq 2 +} + +test_single_target_has_no_base() { + local output + output="$(_run refs "alpha/one" 2>/dev/null)" + + local count + count="$(echo "${output}" | wc -l | tr -d ' ')" + assert "exactly 1 ref" test "${count}" -eq 1 + assert "contains one" echo "${output}" | grep -qF "openstack-one:test" +} + +test_resolve_all_returns_machine_readable_targets() { + local output + output="$(_run resolve all 2>/dev/null)" + + local found_base found_alpha found_beta + found_base="$(echo "${output}" | grep -c "^base$" || true)" + found_alpha="$(echo "${output}" | grep -c "^alpha/one$" || true)" + found_beta="$(echo "${output}" | grep -c "^beta/two$" || true)" + + assert "contains base" test "${found_base}" -ge 1 + assert "contains alpha/one" test "${found_alpha}" -ge 1 + assert "contains beta/two" test "${found_beta}" -ge 1 +} + +test_refs_rejects_unknown_target() { + local rc=0 + local stderr + stderr="$(_run refs "alpha/one,unknown/image" 2>&1 >/dev/null)" || rc=$? + + assert "non-zero exit" test "${rc}" -ne 0 + local has_error + has_error="$(echo "${stderr}" | grep -c "Unknown image or project" || true)" + assert "error message present" test "${has_error}" -ge 1 +} + +test_parallel_build_produces_logs() { + _run build-parallel "alpha/one,beta/two" >"${TEST_DIR}/build.log" 2>&1 || true + + assert_file_exists "${TEST_DIR}/logs/alpha_one.log" + assert_file_exists "${TEST_DIR}/logs/beta_two.log" +} + +test_parallel_build_shows_live_output() { + local output + output="$(_run build-parallel "alpha/one,beta/two" 2>&1 || true)" + + echo "${output}" > "${TEST_DIR}/build.log" + assert_grep '\[alpha/one\] LIVE alpha/one' "${TEST_DIR}/build.log" + assert_grep '\[beta/two\] LIVE beta/two' "${TEST_DIR}/build.log" +} + +test_parallel_failure_propagates() { + local rc=0 + ( + cd "${TEST_DIR}" + export PATH="${TEST_DIR}/bin:${PATH}" + export STREAM=master REGISTRY=registry.test:5000 NAMESPACE=openstack + export TAG=test PARALLEL=2 BUILD_LOGS_DIR="${TEST_DIR}/logs" + export FAIL_IMAGE=two + ./build.sh build-parallel "alpha/one,beta/two" + ) >"${TEST_DIR}/build.log" 2>&1 || rc=$? + + assert "non-zero exit" test "${rc}" -ne 0 + assert_grep "stopping remaining builds" "${TEST_DIR}/build.log" +} + +# ── Run all tests ──────────────────────────────────────────────────────── + +echo "=== build tests ===" +echo "" + +TESTS=( + test_explicit_union_preserves_order_and_deduplicates + test_single_target_has_no_base + test_resolve_all_returns_machine_readable_targets + test_refs_rejects_unknown_target + test_parallel_build_produces_logs + test_parallel_build_shows_live_output + test_parallel_failure_propagates +) + +for t in "${TESTS[@]}"; do + run_test "${t}" +done + +echo "" +echo "=== ${_PASS} passed, ${_FAIL} failed, 0 skipped ===" + +[[ ${_FAIL} -eq 0 ]] diff --git a/tox.ini b/tox.ini index ba6eb876..e53026df 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,7 @@ passenv = SKIP_HASH_UPDATE PIP_NO_BINARY REGISTRY_AUTH_FILE + REGISTRY_CERT_DIR PARALLEL BUILD_LOGS_DIR UPDATE_LOCKFILES_TARGETS @@ -47,6 +48,22 @@ setenv = commands = pre-commit run -a --show-diff-on-failure +[testenv:molecule] +description = Exercise provider registry and cleanup contracts with Molecule +deps = + ansible-core==2.18.7 + molecule==26.6.0 +setenv = + PYTHONPYCACHEPREFIX = {toxinidir}/.tmp/python-cache + MOLECULE_EPHEMERAL_DIRECTORY = {toxinidir}/.tmp/molecule/ephemeral/{envname} + ANSIBLE_HOME = {toxinidir}/.tmp/ansible/home + ANSIBLE_COLLECTIONS_PATH = {toxinidir}/.tmp/ansible/collections + ANSIBLE_LOCAL_TEMP = {toxinidir}/.tmp/ansible/local-tmp + ANSIBLE_ROLES_PATH = {toxinidir}/.tmp/ansible/roles +commands = + molecule reset -s provider-contract + molecule test -s provider-contract + [testenv:update-sources] description = Refresh source pins and generated lockfiles setenv = @@ -74,6 +91,7 @@ commands = [testenv:test] commands = bash {toxinidir}/tests/test_update_sources.sh + bash {toxinidir}/tests/test_build.sh [testenv:custom] commands = diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml new file mode 100644 index 00000000..99fe2b35 --- /dev/null +++ b/zuul.d/jobs.yaml @@ -0,0 +1,38 @@ +--- +- job: + name: s2i-openstack-containers-molecule + parent: tox + description: Run provider registry and cleanup contract scenarios. + nodeset: s2i-openstack-containers-image-builder + vars: + python_version: "3.12" + tox_envlist: molecule + +- nodeset: + name: s2i-openstack-containers-image-builder + nodes: + - name: builder + label: cloud-centos-10-stream + +- job: + name: s2i-openstack-container-content-provider + parent: base + description: | + Build and publish OpenStack service container images from committed + source pins for dependent jobs. This repository builds all maintained + images by default; child jobs can select a subset and add related + required projects. Speculative source consumption remains follow-up work. + timeout: 7200 + nodeset: s2i-openstack-containers-image-builder + required-projects: + - github.com/openstack-k8s-operators/s2i-openstack-containers + - opendev.org/zuul/zuul-jobs + pre-run: playbooks/container-ci/zuul/pre.yaml + run: playbooks/container-ci/zuul/run.yaml + post-run: playbooks/container-ci/zuul/post.yaml + vars: + s2i_ci_container_project: >- + github.com/openstack-k8s-operators/s2i-openstack-containers + s2i_ci_images: all + s2i_ci_stream: master + s2i_ci_content_provider: true diff --git a/zuul.d/projects.yaml b/zuul.d/projects.yaml index ca302dea..4bd2a3be 100644 --- a/zuul.d/projects.yaml +++ b/zuul.d/projects.yaml @@ -1,8 +1,12 @@ --- -# Minimal RDO github-check wiring so Zuul loads in-repo config from main. -# Expand with real jobs once webhook/status checks are confirmed. - project: name: openstack-k8s-operators/s2i-openstack-containers github-check: jobs: - - noop + - s2i-openstack-containers-molecule: + irrelevant-files: &documentation-only + - ^docs/.* + - ^.*\.md$ + - ^LICENSE.*$ + - s2i-openstack-container-content-provider: + irrelevant-files: *documentation-only