diff --git a/.github/scripts/build-extension.sh b/.github/scripts/build-extension.sh new file mode 100755 index 00000000..5dc00774 --- /dev/null +++ b/.github/scripts/build-extension.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# build-extension.sh — one dispatcher for RHEL and Ubuntu FIPS builds. +# +# Matrix inputs (from workflow env): +# MATRIX_FAMILY rhel-core | rhel-fips | ubuntu-fips +# MATRIX_VERSION 8/9 (rhel) or 20.04/22.04 (ubuntu-fips) +# MATRIX_TAG full registry tag to push +# MATRIX_DOCKERFILE path to the Dockerfile +# MATRIX_CONTEXT build context dir +# CREDS_DIR where materialize_credentials.sh wrote secret files +# +# All families use BuildKit --secret so credentials never enter image layers. + +set -euo pipefail + +: "${MATRIX_FAMILY:?}" +: "${MATRIX_TAG:?}" +: "${MATRIX_DOCKERFILE:?}" +: "${MATRIX_CONTEXT:?}" +: "${CREDS_DIR:?}" + +export DOCKER_BUILDKIT=1 + +# Sanity: the Dockerfile must use BuildKit --mount=type=secret. If we ever +# regressed the RHEL Dockerfile port, refuse to build rather than bake +# credentials into image layers via --build-arg. +if [[ "$MATRIX_FAMILY" == rhel-* ]]; then + if ! grep -q 'mount=type=secret,id=rhsm' "$MATRIX_DOCKERFILE"; then + echo "::error::$MATRIX_DOCKERFILE does not use --mount=type=secret,id=rhsm_username/rhsm_password" + exit 1 + fi +fi + +echo "→ Building $MATRIX_TAG from $MATRIX_DOCKERFILE (context: $MATRIX_CONTEXT)" + +case "$MATRIX_FAMILY" in + rhel-core|rhel-fips) + docker build \ + --secret id=rhsm_username,src="$CREDS_DIR/rhsm_username" \ + --secret id=rhsm_password,src="$CREDS_DIR/rhsm_password" \ + --label "canvos.build=${MATRIX_FAMILY}-${MATRIX_VERSION}" \ + -t "$MATRIX_TAG" \ + -f "$MATRIX_DOCKERFILE" \ + "$MATRIX_CONTEXT" + ;; + + ubuntu-fips) + docker build \ + --secret id=pro-attach-config,src="$CREDS_DIR/pro-attach-config.yaml" \ + --label "canvos.build=${MATRIX_FAMILY}-${MATRIX_VERSION}" \ + -t "$MATRIX_TAG" \ + -f "$MATRIX_DOCKERFILE" \ + "$MATRIX_CONTEXT" + ;; + + *) + echo "::error::Unknown MATRIX_FAMILY: $MATRIX_FAMILY" + exit 1 + ;; +esac + +echo "→ Pushing $MATRIX_TAG" +docker push "$MATRIX_TAG" diff --git a/.github/scripts/decrypt-creds.sh b/.github/scripts/decrypt-creds.sh new file mode 100755 index 00000000..3d824a8d --- /dev/null +++ b/.github/scripts/decrypt-creds.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# decrypt-creds.sh — runs inside the extensions job of base-images.yaml. +# +# Reads three ciphertext env vars (safe to have in `env:` because ciphertext +# is useless without the private key) and the private key (from a repo secret, +# so GitHub Actions has already added it to the mask set). Decrypts each, +# calls ::add-mask:: on the plaintext BEFORE any subsequent step can log it, +# and exports the plaintext to $GITHUB_ENV. +# +# Invariants — do not break: +# - Never `set -x` +# - Never `echo` a plaintext value +# - Never write plaintext to a file other than $GITHUB_ENV (post-mask) +# - Wipe the private key file on exit +# +# Inputs (env vars, set by the workflow step): +# RHEL_USER_CIPHER base64(age(username)) may be empty +# RHEL_PASS_CIPHER base64(age(password)) may be empty +# PRO_TOKEN_CIPHER base64(age(ubuntu pro)) may be empty +# DECRYPT_KEY age private key (masked) required if any cipher is non-empty +# +# Outputs (via $GITHUB_ENV): +# RHSM_USER (if RHEL_USER_CIPHER provided) +# RHSM_PASS (if RHEL_PASS_CIPHER provided) +# UBUNTU_PRO_TOKEN (if PRO_TOKEN_CIPHER provided) + +set -euo pipefail + +: "${GITHUB_ENV:?}" + +any_cipher="${RHEL_USER_CIPHER:-}${RHEL_PASS_CIPHER:-}${PRO_TOKEN_CIPHER:-}" +if [ -z "$any_cipher" ]; then + echo "No ciphertext inputs supplied — nothing to decrypt." + exit 0 +fi + +if [ -z "${DECRYPT_KEY:-}" ]; then + echo "::error::DECRYPT_KEY is empty — set the WORKFLOW_DECRYPT_KEY repo secret to the age private key." + exit 1 +fi + +if ! command -v age >/dev/null 2>&1; then + echo "::error::age is not installed on this runner. Add a setup step before Decrypt credentials." + exit 1 +fi + +# Write the private key to a per-process file with restricted permissions. +# Trap wipes it on exit (success or failure). +key_file="$(mktemp)" +chmod 600 "$key_file" +trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT +printf '%s' "$DECRYPT_KEY" > "$key_file" + +decrypt_one() { + # Args: $1 = base64(age(plaintext)) + # Reads plaintext to stdout. Never echoes the input. + printf '%s' "$1" | base64 -d 2>/dev/null | age --decrypt -i "$key_file" +} + +mask_and_export() { + # Args: $1 = env var name, $2 = plaintext value + # Order matters: ::add-mask:: must run BEFORE the value ever reaches + # $GITHUB_ENV, because GitHub reads $GITHUB_ENV into the process env + # for subsequent steps and any step that lists this var in its `env:` + # block would otherwise get the plaintext dumped. + local name="$1" value="$2" + if [ -z "$value" ]; then + # Empty decrypt result is treated as "nothing to export" — do NOT + # write an empty env line (that would blank out any inherited value). + echo "::warning::$name decrypted to empty string — skipping export" + return 0 + fi + # ::add-mask:: line is intercepted by the runner; the value is not + # written to the log. + printf '::add-mask::%s\n' "$value" + # Single-line assignment (no here-doc). RHSM usernames/passwords and + # Ubuntu Pro tokens are single-line values by nature. + printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV" +} + +if [ -n "${RHEL_USER_CIPHER:-}" ]; then + plain="$(decrypt_one "$RHEL_USER_CIPHER")" + mask_and_export "RHSM_USER" "$plain" + unset plain +fi + +if [ -n "${RHEL_PASS_CIPHER:-}" ]; then + plain="$(decrypt_one "$RHEL_PASS_CIPHER")" + mask_and_export "RHSM_PASS" "$plain" + unset plain +fi + +if [ -n "${PRO_TOKEN_CIPHER:-}" ]; then + plain="$(decrypt_one "$PRO_TOKEN_CIPHER")" + mask_and_export "UBUNTU_PRO_TOKEN" "$plain" + unset plain +fi + +echo "Credentials decrypted, masked, and exported to \$GITHUB_ENV (values not shown)." diff --git a/.github/scripts/materialize_credentials.sh b/.github/scripts/materialize_credentials.sh new file mode 100755 index 00000000..d117a1b8 --- /dev/null +++ b/.github/scripts/materialize_credentials.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# materialize_credentials.sh — write credentials for THIS extensions-matrix +# row to $RUNNER_TEMP/creds with umask 077, then export CREDS_DIR to +# $GITHUB_ENV so build-extension.sh can pass them via BuildKit --secret. +# +# Never echo the values. Never assign values to $GITHUB_ENV. No `set -x`. + +set -euo pipefail + +: "${RUNNER_TEMP:?RUNNER_TEMP not set — this must run inside GitHub Actions}" +: "${MATRIX_FAMILY:?MATRIX_FAMILY not set}" + +umask 077 +creds_dir="$RUNNER_TEMP/creds" +mkdir -p "$creds_dir" + +case "$MATRIX_FAMILY" in + rhel-core|rhel-fips) + if [ -z "${RHSM_USER:-}" ] || [ -z "${RHSM_PASS:-}" ]; then + echo "::error::$MATRIX_FAMILY build requires RHSM username+password" + exit 1 + fi + printf '%s' "$RHSM_USER" > "$creds_dir/rhsm_username" + printf '%s' "$RHSM_PASS" > "$creds_dir/rhsm_password" + ;; + + ubuntu-fips) + if [ -z "${UBUNTU_PRO_TOKEN:-}" ]; then + echo "::error::ubuntu-fips build requires ubuntu_pro_token" + exit 1 + fi + # Render pro-attach-config.yaml on the fly. The committed template + # in ubuntu-fips/*/pro-attach-config.yaml has "REPLACE_WITH_TOKEN" + # and must not be used. + cat > "$creds_dir/pro-attach-config.yaml" <> "$GITHUB_ENV" +echo "Credentials materialized for family=$MATRIX_FAMILY (values not shown)." diff --git a/.github/workflows/base-images.yaml b/.github/workflows/base-images.yaml index bc15ec9c..eee88fcf 100644 --- a/.github/workflows/base-images.yaml +++ b/.github/workflows/base-images.yaml @@ -1,10 +1,27 @@ name: Build Kairos Init Base Images +# Unified base-image build. +# +# Preserves the existing kairos-init workflow (kairosify on Ubuntu, OpenSUSE, +# SLEM; Trusted Boot for Ubuntu 24) and adds two extensions gated on user +# input: +# +# fips=true + rhel_subscription_* → build RHEL 8/9 FIPS base images +# + no fips → also build RHEL 8/9 non-FIPS +# fips=true + ubuntu_pro_token → build Ubuntu 20.04/22.04 FIPS +# +# Blank credential input → that family is skipped with a warning. If fips is +# checked but the required credential is empty → hard error before any +# container starts. +# +# All families push to the same $registry_prefix on us-east1-docker.pkg.dev +# using US_EAST_JSON_KEY_B64. + on: workflow_dispatch: inputs: base_os_image: - description: "Base OS Image" + description: "Base OS Image (kairosify override; leave blank for the default set)" required: false type: string default: "" @@ -32,17 +49,42 @@ on: type: string default: "v4.0.1" trusted_boot: - description: "Trusted Boot" + description: "Trusted Boot (kairosify: build the Ubuntu 24.04 UKI variant)" required: false type: boolean default: false + fips: + description: "FIPS (build RHEL FIPS and Ubuntu FIPS variants — needs creds)" + required: false + type: boolean + default: false + rhel_subscription_username_encrypted: + description: "RHSM username — age-encrypted ciphertext (see scripts/encrypt-creds.sh; blank -> skip RHEL builds)" + required: false + type: string + default: "" + rhel_subscription_password_encrypted: + description: "RHSM password — age-encrypted ciphertext (blank -> skip RHEL builds)" + required: false + type: string + default: "" + ubuntu_pro_token_encrypted: + description: "Ubuntu Pro token — age-encrypted ciphertext (required when fips=true)" + required: false + type: string + default: "" registry_prefix: description: "Registry prefix for output images" required: false type: string default: "us-east1-docker.pkg.dev/spectro-images/dev/pe-8215/edge" +permissions: + contents: read + jobs: + + # ── Kairosify (Ubuntu / OpenSUSE / SLEM / TB) — unchanged from prior ─── generate-matrix: runs-on: Luet-BigRunner outputs: @@ -65,27 +107,17 @@ jobs: matrix = [] if base_os_image: - # Custom base OS image + # base_os_image is an explicit "just build this one custom OS" + # override — stays exclusive of the default combinations. simple_name = base_os_image.split('/')[-1].replace(':', '-') tag = f"{registry_prefix}/kairos-custom:{simple_name}-core-{arch}-{model}-{kairos_version}" if trusted_boot: tag += "-uki" - - matrix.append({ - "base_os": base_os_image, - "tag": tag, - }) - - elif trusted_boot: - # UKI builds - only Ubuntu 24.04 - tag = f"{registry_prefix}/kairos-ubuntu:24.04-core-{arch}-{model}-{kairos_version}-uki" - matrix.append({ - "base_os": "ubuntu:24.04", - "tag": tag, - }) - + matrix.append({"base_os": base_os_image, "tag": tag, "trusted_boot": trusted_boot}) else: - # Standard builds - all combinations + # Standard builds — always run so a single dispatch produces + # the full release set. Standard rows are NEVER UKI, even when + # the trusted_boot checkbox is on. combinations = [ ("ubuntu:20.04", "kairos-ubuntu:20.04-core"), ("ubuntu:22.04", "kairos-ubuntu:22.04-core"), @@ -93,17 +125,19 @@ jobs: ("opensuse/leap:15.6", "kairos-opensuse:leap-15.6-core"), ("registry.suse.com/suse/sle-micro-rancher/5.4:latest", "kairos-slem:5.4-core"), ] - for base_os, tag_prefix in combinations: tag = f"{registry_prefix}/{tag_prefix}-{arch}-{model}-{kairos_version}" - matrix.append({ - "base_os": base_os, - "tag": tag, - }) + matrix.append({"base_os": base_os, "tag": tag, "trusted_boot": False}) + + # trusted_boot ADDS the Ubuntu 24.04 UKI variant on top of the + # standard set — not a replacement. Same additive semantics as + # the `fips` checkbox in the extensions job. + if trusted_boot: + tag = f"{registry_prefix}/kairos-ubuntu:24.04-core-{arch}-{model}-{kairos_version}-uki" + matrix.append({"base_os": "ubuntu:24.04", "tag": tag, "trusted_boot": True}) matrix_json = json.dumps(matrix) print(f"Generated matrix: {matrix_json}") - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: f.write(f"matrix={matrix_json}\n") EOF @@ -136,7 +170,7 @@ jobs: kairosify.args.BASE_OS_IMAGE=${{ matrix.base_os }} kairosify.args.KAIROS_INIT_IMAGE=${{ github.event.inputs.kairos_init_image }} kairosify.args.KAIROS_VERSION=${{ github.event.inputs.kairos_version }} - kairosify.args.TRUSTED_BOOT=${{ github.event.inputs.trusted_boot }} + kairosify.args.TRUSTED_BOOT=${{ matrix.trusted_boot }} kairosify.args.MODEL=${{ github.event.inputs.model }} kairosify.tags=${{ matrix.tag }} env: @@ -150,14 +184,183 @@ jobs: - name: Upload build results uses: actions/upload-artifact@v4 with: - name: build-result-${{ strategy.job-index }}-${{ hashFiles('**/matrix.*') }} + name: build-result-kairosify-${{ strategy.job-index }} path: /tmp/results/built_tags.txt - collect-outputs: - needs: kairosify + # ── Extensions matrix (RHEL + Ubuntu FIPS) ───────────────────────────── + generate-extensions-matrix: runs-on: Luet-BigRunner outputs: - built_tags: ${{ steps.combine-tags.outputs.tags }} + matrix: ${{ steps.set-matrix.outputs.matrix }} + empty: ${{ steps.set-matrix.outputs.empty }} + steps: + # No plaintext to mask in this job — only ciphertexts flow through, which + # are useless without the private key (held as a repo secret and only + # decrypted inside the extensions job). + + - name: Compute matrix + id: set-matrix + env: + REGISTRY_PREFIX: ${{ github.event.inputs.registry_prefix }} + ARCH: ${{ github.event.inputs.arch }} + MODEL: ${{ github.event.inputs.model }} + KAIROS_VERSION: ${{ github.event.inputs.kairos_version }} + IN_FIPS: ${{ github.event.inputs.fips }} + # HAS_* is a proxy: ciphertext non-empty ⇒ credential supplied. + HAS_RHSM_USER: ${{ github.event.inputs.rhel_subscription_username_encrypted != '' }} + HAS_RHSM_PASS: ${{ github.event.inputs.rhel_subscription_password_encrypted != '' }} + HAS_UBUNTU_PRO: ${{ github.event.inputs.ubuntu_pro_token_encrypted != '' }} + run: | + python3 <<'PY' + import json, os, sys + + def bool_(v): return str(v).lower() == "true" + + registry = os.environ["REGISTRY_PREFIX"] + arch = os.environ["ARCH"] + model = os.environ["MODEL"] + kver = os.environ["KAIROS_VERSION"] + fips = bool_(os.environ["IN_FIPS"]) + has_rhsm = bool_(os.environ["HAS_RHSM_USER"]) and bool_(os.environ["HAS_RHSM_PASS"]) + has_pro = bool_(os.environ["HAS_UBUNTU_PRO"]) + + if fips and not has_pro: + print("::error::fips=true but ubuntu_pro_token is empty — Ubuntu FIPS needs it.") + sys.exit(1) + + rows = [] + # RHEL 8/9 std — gated on RHSM + if has_rhsm: + for v in ("8", "9"): + rows.append({ + "family": "rhel-core", + "version": v, + "tag": f"{registry}/kairos-rhel:{v}-core-{arch}-{model}-{kver}", + "dockerfile": f"rhel-core-images/Dockerfile.rhel{v}", + "build_context": "rhel-core-images", + }) + else: + print("::warning::rhel_subscription_username/password blank — skipping RHEL 8/9 std.") + + # RHEL 8/9 FIPS — gated on RHSM + fips checkbox + if fips: + if has_rhsm: + for v in ("8", "9"): + rows.append({ + "family": "rhel-fips", + "version": v, + "tag": f"{registry}/kairos-rhel:{v}-core-{arch}-{model}-{kver}-fips", + "dockerfile": f"rhel-fips/Dockerfile.rhel{v}", + "build_context": "rhel-fips", + }) + else: + print("::warning::fips=true but no RHSM creds — skipping RHEL 8/9 FIPS.") + + # Ubuntu FIPS 20.04, 22.04 (24.04 not present on kairos-init branch). + # Build context = the version subdir (matches each version's own + # build.sh); the Dockerfile refers to fix.sh / modules.fips by + # bare filename which must resolve in the context root. + for v in ("20.04", "22.04"): + dockerfile = "ubuntu-fips/20.04/Dockerfile" if v == "20.04" \ + else f"ubuntu-fips/{v}/Dockerfile.ubuntu{v}-fips" + rows.append({ + "family": "ubuntu-fips", + "version": v, + "tag": f"{registry}/kairos-ubuntu:{v}-core-{arch}-{model}-{kver}-fips", + "dockerfile": dockerfile, + "build_context": f"ubuntu-fips/{v}", + }) + + empty = "true" if not rows else "false" + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"matrix={json.dumps({'include': rows})}\n") + f.write(f"empty={empty}\n") + print(json.dumps({"include": rows}, indent=2)) + PY + + extensions: + needs: generate-extensions-matrix + if: needs.generate-extensions-matrix.outputs.empty != 'true' + runs-on: Luet-BigRunner + strategy: + matrix: ${{ fromJson(needs.generate-extensions-matrix.outputs.matrix) }} + fail-fast: false + env: + MATRIX_FAMILY: ${{ matrix.family }} + MATRIX_VERSION: ${{ matrix.version }} + MATRIX_TAG: ${{ matrix.tag }} + MATRIX_DOCKERFILE: ${{ matrix.dockerfile }} + MATRIX_CONTEXT: ${{ matrix.build_context }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + run: echo "${{ secrets.US_EAST_JSON_KEY_B64 }}" | base64 -d | docker login -u _json_key --password-stdin us-east1-docker.pkg.dev + + - name: Install age + run: | + # `age` isn't pre-installed on Luet-BigRunner; fetch the static + # binary release and put it on PATH. + if ! command -v age >/dev/null 2>&1; then + curl -sSL -o /tmp/age.tar.gz \ + https://github.com/FiloSottile/age/releases/download/v1.2.1/age-v1.2.1-linux-amd64.tar.gz + tar -xzf /tmp/age.tar.gz -C /tmp + sudo install -m 0755 /tmp/age/age /usr/local/bin/age + rm -rf /tmp/age /tmp/age.tar.gz + fi + age --version + + - name: Decrypt credentials + env: + # Ciphertexts are safe to have in an env dump — they cannot be + # decrypted without the private key. + RHEL_USER_CIPHER: ${{ github.event.inputs.rhel_subscription_username_encrypted }} + RHEL_PASS_CIPHER: ${{ github.event.inputs.rhel_subscription_password_encrypted }} + PRO_TOKEN_CIPHER: ${{ github.event.inputs.ubuntu_pro_token_encrypted }} + # Private key from repo secret — GitHub Actions auto-masks + # secrets.* values from the moment they enter the runner, so this + # env dump shows `DECRYPT_KEY: ***`. + DECRYPT_KEY: ${{ secrets.WORKFLOW_DECRYPT_KEY }} + run: bash .github/scripts/decrypt-creds.sh + + - name: Materialize credentials + # RHSM_USER / RHSM_PASS / UBUNTU_PRO_TOKEN arrive via $GITHUB_ENV set + # by the Decrypt step above. They're already masked; we don't + # re-declare them here, because listing them in `env:` would trigger + # an env dump in the log (masked, but still noise). + run: bash .github/scripts/materialize_credentials.sh + + - name: Build and push + run: bash .github/scripts/build-extension.sh + + - name: Cleanup credentials + if: always() + run: | + if [ -n "${CREDS_DIR:-}" ] && [ -d "$CREDS_DIR" ]; then + find "$CREDS_DIR" -type f -exec shred -u {} + 2>/dev/null || \ + find "$CREDS_DIR" -type f -delete + rmdir "$CREDS_DIR" 2>/dev/null || true + fi + + - name: Save build result + run: | + mkdir -p /tmp/results + echo "${{ matrix.tag }}" >> /tmp/results/built_tags.txt + + - name: Upload build results + uses: actions/upload-artifact@v4 + with: + name: build-result-extensions-${{ strategy.job-index }} + path: /tmp/results/built_tags.txt + + # ── Aggregate all tags into the run summary ──────────────────────────── + collect-outputs: + needs: [kairosify, extensions] + if: always() + runs-on: Luet-BigRunner steps: - name: Download all build results uses: actions/download-artifact@v4 @@ -169,20 +372,22 @@ jobs: id: combine-tags run: | ALL_TAGS=$(find /tmp/results -name "*.txt" -type f -exec cat {} \; | grep -v '^$' | jq -R -s -c 'split("\n") | map(select(length > 0))') - echo "tags=$ALL_TAGS" >> $GITHUB_OUTPUT - echo "All built tags: $ALL_TAGS" + echo "tags=$ALL_TAGS" >> "$GITHUB_OUTPUT" - name: Summary run: | - echo "## Kairosify Build Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Input Parameters:**" >> $GITHUB_STEP_SUMMARY - echo "- Base OS Image: ${{ github.event.inputs.base_os_image || 'Default combinations' }}" >> $GITHUB_STEP_SUMMARY - echo "- Kairos Init Image: ${{ github.event.inputs.kairos_init_image }}" >> $GITHUB_STEP_SUMMARY - echo "- Architecture: ${{ github.event.inputs.arch }}" >> $GITHUB_STEP_SUMMARY - echo "- Model: ${{ github.event.inputs.model }}" >> $GITHUB_STEP_SUMMARY - echo "- Kairos Version: ${{ github.event.inputs.kairos_version }}" >> $GITHUB_STEP_SUMMARY - echo "- Trusted Boot: ${{ github.event.inputs.trusted_boot }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Built Images:**" >> $GITHUB_STEP_SUMMARY - echo '${{ steps.combine-tags.outputs.tags }}' | jq -r '.[] | "- " + .' >> $GITHUB_STEP_SUMMARY + { + echo "## Base Image Build Summary" + echo "" + echo "**Input Parameters:**" + echo "- Base OS Image: ${{ github.event.inputs.base_os_image || 'Default combinations' }}" + echo "- Kairos Init Image: ${{ github.event.inputs.kairos_init_image }}" + echo "- Architecture: ${{ github.event.inputs.arch }}" + echo "- Model: ${{ github.event.inputs.model }}" + echo "- Kairos Version: ${{ github.event.inputs.kairos_version }}" + echo "- Trusted Boot: ${{ github.event.inputs.trusted_boot }}" + echo "- FIPS: ${{ github.event.inputs.fips }}" + echo "" + echo "**Built Images:**" + echo '${{ steps.combine-tags.outputs.tags }}' | jq -r '.[] | "- " + .' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/rhel-core-images/Dockerfile.rhel8 b/rhel-core-images/Dockerfile.rhel8 index 229bb369..f213d6a6 100644 --- a/rhel-core-images/Dockerfile.rhel8 +++ b/rhel-core-images/Dockerfile.rhel8 @@ -1,17 +1,20 @@ +# syntax=docker/dockerfile:1 FROM quay.io/kairos/kairos-init:v0.8.12 AS kairos-init FROM registry.access.redhat.com/ubi8/ubi-init:8.7-10 -ARG USERNAME -ARG PASSWORD ARG KAIROS_VERSION=v3.5.9 RUN dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm -y -# Subscription manager in redhat does not run directly in containers unless you run on a redhat host, hence we remove the rhsm-host, login to the redhat subscription and add the repos -RUN rm /etc/rhsm-host && subscription-manager register --username ${USERNAME} --password ${PASSWORD} \ +# Register with Red Hat, enable the appstream repo, install packages. +# Uses BuildKit secrets (docker build --secret id=rhsm_username,src=... --secret id=rhsm_password,src=...) +# so credentials are mounted only for the lifetime of this RUN and never enter image layers. +RUN --mount=type=secret,id=rhsm_username \ + --mount=type=secret,id=rhsm_password \ + sh -c 'rm /etc/rhsm-host && subscription-manager register --username "$(cat /run/secrets/rhsm_username)" --password "$(cat /run/secrets/rhsm_password)" \ && yum repolist \ && subscription-manager attach --auto \ && subscription-manager repos --enable rhel-8-for-x86_64-appstream-rpms \ - && yum repolist + && yum repolist' RUN --mount=type=bind,from=kairos-init,src=/kairos-init,dst=/kairos-init /kairos-init -l debug -m "generic" -t "false" --version "${KAIROS_VERSION}" diff --git a/rhel-core-images/Dockerfile.rhel9 b/rhel-core-images/Dockerfile.rhel9 index 00aef07b..911a2da6 100644 --- a/rhel-core-images/Dockerfile.rhel9 +++ b/rhel-core-images/Dockerfile.rhel9 @@ -1,17 +1,20 @@ +# syntax=docker/dockerfile:1 FROM quay.io/kairos/kairos-init:v0.8.12 AS kairos-init FROM registry.access.redhat.com/ubi9-init:9.4-6 -ARG USERNAME -ARG PASSWORD ARG KAIROS_VERSION=v3.5.9 RUN dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm -y -# Subscription manager in redhat does not run directly in containers unless you run on a redhat host, hence we remove the rhsm-host, login to the redhat subscription and add the repos -RUN rm /etc/rhsm-host && subscription-manager register --username ${USERNAME} --password ${PASSWORD} \ +# Register with Red Hat, enable the appstream repo, install packages. +# Uses BuildKit secrets (docker build --secret id=rhsm_username,src=... --secret id=rhsm_password,src=...) +# so credentials are mounted only for the lifetime of this RUN and never enter image layers. +RUN --mount=type=secret,id=rhsm_username \ + --mount=type=secret,id=rhsm_password \ + sh -c 'rm /etc/rhsm-host && subscription-manager register --username "$(cat /run/secrets/rhsm_username)" --password "$(cat /run/secrets/rhsm_password)" \ && yum repolist \ && subscription-manager attach --auto \ && subscription-manager repos --enable rhel-9-for-x86_64-appstream-rpms \ - && yum repolist + && yum repolist' RUN --mount=type=bind,from=kairos-init,src=/kairos-init,dst=/kairos-init /kairos-init -l debug --version "${KAIROS_VERSION}" -m "generic" -t "false" diff --git a/rhel-fips/Dockerfile.rhel8 b/rhel-fips/Dockerfile.rhel8 index 36ee48e5..9a25f533 100644 --- a/rhel-fips/Dockerfile.rhel8 +++ b/rhel-fips/Dockerfile.rhel8 @@ -1,11 +1,9 @@ +# syntax=docker/dockerfile:1 # Kairos init image FROM quay.io/kairos/kairos-init:v0.8.12 AS kairos-init FROM registry.access.redhat.com/ubi8/ubi-init:8.7-10 AS base -ARG USERNAME -ARG PASSWORD - # Generate os-release file ARG KAIROS_VERSION=v3.5.9 @@ -13,12 +11,16 @@ ARG KAIROS_VERSION=v3.5.9 ENV DEBIAN_FRONTEND=noninteractive RUN dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm -y -# Subscription manager in redhat does not run directly in containers unless you run on a redhat host, hence we remove the rhsm-host, login to the redhat subscription and add the repos -RUN rm /etc/rhsm-host && subscription-manager register --username ${USERNAME} --password ${PASSWORD} \ +# Register with Red Hat, enable the appstream repo, install packages. +# Uses BuildKit secrets (docker build --secret id=rhsm_username,src=... --secret id=rhsm_password,src=...) +# so credentials are mounted only for the lifetime of this RUN and never enter image layers. +RUN --mount=type=secret,id=rhsm_username \ + --mount=type=secret,id=rhsm_password \ + sh -c 'rm /etc/rhsm-host && subscription-manager register --username "$(cat /run/secrets/rhsm_username)" --password "$(cat /run/secrets/rhsm_password)" \ && yum repolist \ && subscription-manager attach --auto \ && subscription-manager repos --enable rhel-8-for-x86_64-appstream-rpms \ - && yum repolist + && yum repolist' RUN echo "install_weak_deps=False" >> /etc/dnf/dnf.conf COPY overlay/rhel8/ / @@ -115,3 +117,8 @@ RUN rm -rf /boot/initramfs-* COPY overlay/rhel8/ / RUN if [ ! -f /boot/efi/EFI/redhat/shim.efi ]; then cp /boot/efi/EFI/redhat/shimx64.efi /boot/efi/EFI/redhat/shim.efi; fi + +# Detach the subscription so the built image is not tied to the builder's RHSM +# account. Clean up entitlement/consumer state that would otherwise persist. +RUN subscription-manager unregister || true \ + && rm -rf /etc/pki/entitlement/* /var/lib/rhsm/consumer/* /var/lib/rhsm/facts/* /var/lib/rhsm/repo_server_val/* 2>/dev/null || true diff --git a/rhel-fips/Dockerfile.rhel9 b/rhel-fips/Dockerfile.rhel9 index 930d9fc3..3f9f9371 100644 --- a/rhel-fips/Dockerfile.rhel9 +++ b/rhel-fips/Dockerfile.rhel9 @@ -1,11 +1,9 @@ +# syntax=docker/dockerfile:1 # Kairos init image FROM quay.io/kairos/kairos-init:v0.8.12 AS kairos-init FROM registry.access.redhat.com/ubi9-init:9.4-6 AS base -ARG USERNAME -ARG PASSWORD - # Generate os-release file ARG KAIROS_VERSION=v3.5.9 @@ -13,12 +11,16 @@ ARG KAIROS_VERSION=v3.5.9 ENV DEBIAN_FRONTEND=noninteractive RUN dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm -y -# Subscription manager in redhat does not run directly in containers unless you run on a redhat host, hence we remove the rhsm-host, login to the redhat subscription and add the repos -RUN rm /etc/rhsm-host && subscription-manager register --username ${USERNAME} --password ${PASSWORD} \ +# Register with Red Hat, enable the appstream repo, install packages. +# Uses BuildKit secrets (docker build --secret id=rhsm_username,src=... --secret id=rhsm_password,src=...) +# so credentials are mounted only for the lifetime of this RUN and never enter image layers. +RUN --mount=type=secret,id=rhsm_username \ + --mount=type=secret,id=rhsm_password \ + sh -c 'rm /etc/rhsm-host && subscription-manager register --username "$(cat /run/secrets/rhsm_username)" --password "$(cat /run/secrets/rhsm_password)" \ && yum repolist \ && subscription-manager attach --auto \ && subscription-manager repos --enable rhel-9-for-x86_64-appstream-rpms \ - && yum repolist + && yum repolist' RUN echo "install_weak_deps=False" >> /etc/dnf/dnf.conf COPY overlay/rhel9/ / @@ -113,3 +115,8 @@ RUN echo "SELINUX=disabled" > /etc/selinux/config RUN rm -rf /boot/initramfs-* COPY overlay/rhel9/ / + +# Detach the subscription so the built image is not tied to the builder's RHSM +# account. Clean up entitlement/consumer state that would otherwise persist. +RUN subscription-manager unregister || true \ + && rm -rf /etc/pki/entitlement/* /var/lib/rhsm/consumer/* /var/lib/rhsm/facts/* /var/lib/rhsm/repo_server_val/* 2>/dev/null || true diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..096099a8 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,83 @@ +# Release helper scripts + +## `encrypt-creds.sh` + +Encrypts release credentials (RHSM user/pass, Ubuntu Pro token) so they can +be pasted into the **Build Kairos Init Base Images** workflow without ever +appearing in cleartext anywhere. + +### One-time setup + +Install `age` locally: + +```bash +# macOS +brew install age + +# Ubuntu / Debian +sudo apt install age + +# Anything else — grab a binary release +https://github.com/FiloSottile/age/releases +``` + +### Every time you dispatch the workflow + +From a clean checkout of this repo: + +```bash +./scripts/encrypt-creds.sh +``` + +You'll be prompted three times (silently). Leave a prompt blank to skip +that credential — the workflow will then skip the corresponding image +family. + +The script prints three ciphertext blocks. Copy each block into the +matching field of the workflow_dispatch form on GitHub: + +| Prompt asked | Workflow input to paste into | +|---|---| +| RHSM username | `rhel_subscription_username_encrypted` | +| RHSM password | `rhel_subscription_password_encrypted` | +| Ubuntu Pro token | `ubuntu_pro_token_encrypted` | + +### Why encryption + +The workflow_dispatch UI shows raw string inputs in cleartext in the +"Inputs" panel, in log env dumps, and in run-summary APIs. Ciphertexts are +useless without the private key — which lives only as the repo secret +`WORKFLOW_DECRYPT_KEY` and never as a workflow input. See +`.github/scripts/decrypt-creds.sh` for the workflow-side decrypt logic. + +### Threat model + +**Protected:** +- Cleartext credentials in Actions log stream (only ciphertext appears) +- Cleartext in the dispatch Inputs panel (only ciphertext appears) +- Log retention exposing old creds (ciphertext + no key = useless) +- Screenshots of the dispatch form or run URL (ciphertext safe to share) + +**Not protected:** +- A repo-write-access user modifying the workflow to echo decrypted values. + Mitigate with branch protection on the workflow file and required + reviewers on the release environment. +- Compromise of your local machine while running `encrypt-creds.sh` — the + plaintext exists in your shell during the run. +- Compromise of the private key (repo secret). Rotation procedure below. + +### Rotation + +If the private key is compromised or on scheduled rotation: + +1. Generate a new keypair: + ```bash + age-keygen -o new-workflow-decrypt.key + ``` +2. Replace `team.age.pub` in the repo with the new public key. +3. Update the `WORKFLOW_DECRYPT_KEY` repo secret with the new private key. +4. Old ciphertexts stop working — users re-encrypt using the new + `team.age.pub` on their next dispatch. + +A team member leaving is **not** a rotation trigger — they never held the +private key. diff --git a/scripts/encrypt-creds.sh b/scripts/encrypt-creds.sh new file mode 100755 index 00000000..da53ffb5 --- /dev/null +++ b/scripts/encrypt-creds.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# encrypt-creds.sh — encrypt release credentials for the base-images workflow. +# +# What this does: +# - Prompts (silently) for RHSM username, RHSM password, Ubuntu Pro token +# - Encrypts each with age using the repo's team.age.pub +# - Base64-encodes the ciphertext so it fits on one line +# - Prints copy-pasteable blocks for the workflow_dispatch form +# +# What this NEVER does: +# - Echo your plaintext credentials to the terminal +# - Write plaintext credentials to any file +# - Send anything over the network +# +# Requirements: +# - `age` v1.x (brew install age | apt install age | https://age-encryption.org) +# - `base64` (BSD or GNU — the script handles both) +# +# Usage: +# scripts/encrypt-creds.sh +# +# Leave any prompt blank if you don't need that family (e.g. no Ubuntu FIPS → +# leave the Pro token blank). + +set -euo pipefail + +# Do NOT enable `set -x` — that would echo values. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +pubkey_file="$repo_root/team.age.pub" + +if ! command -v age >/dev/null 2>&1; then + echo "error: 'age' not installed. Install with 'brew install age' or see https://age-encryption.org" >&2 + exit 1 +fi + +if [ ! -f "$pubkey_file" ]; then + echo "error: $pubkey_file not found. Run this script from a clean checkout." >&2 + exit 1 +fi + +pubkey="$(grep -oE 'age1[a-z0-9]+' "$pubkey_file" | head -1)" +if [ -z "$pubkey" ]; then + echo "error: could not parse a public key from $pubkey_file" >&2 + exit 1 +fi + +# base64 -w0 on GNU, `base64` on BSD does not wrap by default → normalize. +b64_flat() { + if base64 --help 2>&1 | grep -q -- '-w'; then + base64 -w0 + else + base64 | tr -d '\n' + fi +} + +encrypt_one() { + # Read stdin, encrypt with age (binary), base64-encode single-line. + # NOTE: `age -r ` reads plaintext from stdin, writes binary + # ciphertext to stdout. base64 turns it into a paste-safe string. + age -r "$pubkey" | b64_flat +} + +echo "This will encrypt each credential using $pubkey_file" +echo "Leave a prompt blank to skip that credential." +echo + +# `read -s` hides typed input. Trailing echo restores the newline. +IFS= read -r -s -p "RHSM username: " rhsm_user; echo +IFS= read -r -s -p "RHSM password: " rhsm_pass; echo +IFS= read -r -s -p "Ubuntu Pro token: " ubuntu_pro; echo + +enc_rhsm_user="" +enc_rhsm_pass="" +enc_ubuntu_pro="" + +if [ -n "$rhsm_user" ]; then + enc_rhsm_user="$(printf '%s' "$rhsm_user" | encrypt_one)" +fi +if [ -n "$rhsm_pass" ]; then + enc_rhsm_pass="$(printf '%s' "$rhsm_pass" | encrypt_one)" +fi +if [ -n "$ubuntu_pro" ]; then + enc_ubuntu_pro="$(printf '%s' "$ubuntu_pro" | encrypt_one)" +fi + +# Zero-out the plaintext vars as soon as possible. +unset rhsm_user rhsm_pass ubuntu_pro + +echo +echo "════════════════════════════════════════════════════════════════════" +echo " Copy each non-empty block below into the workflow_dispatch form." +echo " Blank blocks = skip that credential (the workflow will skip the" +echo " corresponding image family)." +echo "════════════════════════════════════════════════════════════════════" +echo +printf '─── rhel_subscription_username_encrypted ───\n%s\n\n' "$enc_rhsm_user" +printf '─── rhel_subscription_password_encrypted ───\n%s\n\n' "$enc_rhsm_pass" +printf '─── ubuntu_pro_token_encrypted ───\n%s\n\n' "$enc_ubuntu_pro" diff --git a/team.age.pub b/team.age.pub new file mode 100644 index 00000000..d9c5825e --- /dev/null +++ b/team.age.pub @@ -0,0 +1 @@ +age1tprv782vqnae0lc3shr7uvea4vhmf7qp3reyqnyyqs0sru0la9wqca6p2y