diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f397fd395..b55ae81e3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -103,6 +103,9 @@ jobs: - name: Run bats (ublue-image-info.sh) run: bats tests/test_ublue_image_info.bats + - name: Run bats (ublue-image-resolve) + run: bats tests/test_ublue_image_resolve.bats + - name: Run bats (profile.d — caffeinate.sh + uutils.sh) run: bats tests/test_profile_d.bats diff --git a/Justfile b/Justfile index dec258f31..9f77cfe68 100644 --- a/Justfile +++ b/Justfile @@ -18,6 +18,7 @@ test: bats tests/test_ublue_fastfetch.bats bats tests/test_ublue_motd.bats bats tests/test_ublue_image_info.bats + bats tests/test_ublue_image_resolve.bats bats tests/test_profile_d.bats bats tests/test_dynamic_wallpaper.bats bats tests/test_geoclue_latitude.bats diff --git a/docs/TESTING.md b/docs/TESTING.md index 98ee356a3..1f07b9cb6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -62,7 +62,7 @@ Scripts exempt from behavioral testing (shellcheck-only): | `usr/share/ublue-os/bling/env.sh` | Sourced helper — sets env vars only | | `usr/share/ublue-os/user-setup.hooks.d/20-dynamic-wallpaper.sh` | One-shot hook — logic tested indirectly via setup integration tests | | `usr/bin/ublue-motd` | Display-only wrapper — cosmetic tput/glow call, no decision logic | -| `usr/bin/ublue-image-info.sh` | Read-only reporting wrapper — jq + rpm-ostree status, no branching that affects system state | +| `usr/bin/ublue-image-info.sh` | Read-only reporting wrapper — delegates resolution to `ublue-image-resolve` (tested separately) plus rpm-ostree status | **Adding an exemption:** add a row to this table with a one-sentence justification. Do not add exemptions for scripts with branching logic. @@ -90,6 +90,7 @@ Do not add exemptions for scripts with branching logic. | `tests/test_changelog.bats` | `changelog.just` — LTS/non-LTS repo selection, URL construction, exit behaviour | | `tests/test_ublue_fastfetch.bats` | `ublue-fastfetch` — config reads, shuffle branch, DEFAULT_THEME export to ublue-bling-fastfetch | | `tests/test_theming_hook.bats` | `10-theming.sh` — Framework/Thelio branches and setup idempotency | +| `tests/test_ublue_image_resolve.bats` | `ublue-image-resolve` — bootc-status-first tag/name/ref resolution, digest and ported-registry refs, image-info.json fallback | ## Quality Epic diff --git a/docs/skills/shell-scripts.md b/docs/skills/shell-scripts.md index 9bf0d4dd1..6da61d1b1 100644 --- a/docs/skills/shell-scripts.md +++ b/docs/skills/shell-scripts.md @@ -153,6 +153,30 @@ Apply to any script reading system files via stdin redirect. --- +### image-info.json is build-time state — use `ublue-image-resolve` for live identity + +`/usr/share/ublue-os/image-info.json` is baked when the image is built, so it +describes what the filesystem was *built* as, not what is booted. After a rebase +it lies (see [#820](https://github.com/projectbluefin/common/issues/820)). + +Ask `/usr/libexec/ublue-image-resolve` instead. It reads +`.status.booted.image.image.image` from `bootc status --json` and falls back to +the baked file when bootc is absent, non-zero, or reports no booted image. + +```bash +IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}" +IMAGE_TAG="$("${IMAGE_RESOLVE}" image-tag)" # stable +IMAGE_NAME="$("${IMAGE_RESOLVE}" image-name)" # bluefin-lts +IMAGE_PATH="$("${IMAGE_RESOLVE}" image-path)" # ghcr.io/projectbluefin/bluefin-lts +``` + +Never split a ref on the first `:` — registry ports (`registry:5000/foo:tag`) +and digests (`repo:tag@sha256:...`) both break that. The resolver handles both. + +In bats, mock `bootc` onto PATH and point `IMAGE_RESOLVE` at the real script. + +--- + ### Assert env-var export against the subshell consumer, not exec `exec` inherits all shell variables whether exported or not — asserting `DEFAULT_THEME` diff --git a/system_files/bluefin/usr/share/ublue-os/just/changelog.just b/system_files/bluefin/usr/share/ublue-os/just/changelog.just index 0bdb16b15..714cdf26e 100644 --- a/system_files/bluefin/usr/share/ublue-os/just/changelog.just +++ b/system_files/bluefin/usr/share/ublue-os/just/changelog.just @@ -7,12 +7,13 @@ changelogs: exec bctl changelogs fi - # Get Image Tag - IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}" - TAG="$(jq -r '.["image-tag"]' < "${IMAGE_INFO_FILE}")" + # Get Image Tag from the live system (bootc status), not the build-time + # image-info.json, which goes stale after a rebase. + IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}" + TAG="$("${IMAGE_RESOLVE}" image-tag || true)" # Select the correct upstream repo based on image stream - IMAGE_NAME="$(jq -r '.["image-name"] // empty' < "${IMAGE_INFO_FILE}")" + IMAGE_NAME="$("${IMAGE_RESOLVE}" image-name || true)" if [[ "$IMAGE_NAME" == "dakota" ]]; then REPO="projectbluefin/dakota" elif [[ "$IMAGE_NAME" =~ ^bluefin-lts ]] || [[ "$TAG" =~ ^lts ]]; then diff --git a/system_files/bluefin/usr/share/ublue-os/just/system.just b/system_files/bluefin/usr/share/ublue-os/just/system.just index 7269ea3cc..1b1fbec53 100644 --- a/system_files/bluefin/usr/share/ublue-os/just/system.just +++ b/system_files/bluefin/usr/share/ublue-os/just/system.just @@ -245,11 +245,11 @@ toggle-testing: exec bctl toggle-testing fi set -euo pipefail - IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}" - IMAGE_TAG="$(jq -r '."image-tag"' < "${IMAGE_INFO_FILE}")" - IMAGE_REF="$(jq -r '."image-ref"' < "${IMAGE_INFO_FILE}")" - # Strip transport prefix (ostree-image-signed:docker://, ostree-unverified-registry:, etc.) - IMAGE_PATH="$(sed -E 's|^.*://||; s|^[a-z-]+:||' <<< "${IMAGE_REF}")" + # Resolve the live image (bootc status) rather than the build-time + # image-info.json, which goes stale after a rebase. + IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}" + IMAGE_TAG="$("${IMAGE_RESOLVE}" image-tag)" + IMAGE_PATH="$("${IMAGE_RESOLVE}" image-path)" if [[ "${IMAGE_TAG}" == *testing* ]]; then if [[ "${IMAGE_TAG}" == "testing" ]]; then NEW_TAG="stable" diff --git a/system_files/shared/usr/bin/ublue-image-info.sh b/system_files/shared/usr/bin/ublue-image-info.sh index 6f9d79f9a..378fc02b2 100755 --- a/system_files/shared/usr/bin/ublue-image-info.sh +++ b/system_files/shared/usr/bin/ublue-image-info.sh @@ -1,8 +1,13 @@ #!/usr/bin/bash -# shellcheck disable=2046 -IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}" -echo -n "$(jq -r '"\(.["image-name"]):\(.["image-tag"])"' < "${IMAGE_INFO_FILE}")" +# Resolve from the live system (bootc status) so a rebase is reflected here, +# falling back to the build-time image-info.json inside the resolver. +IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}" +IMAGE_NAME="$("${IMAGE_RESOLVE}" image-name 2>/dev/null || true)" +IMAGE_TAG="$("${IMAGE_RESOLVE}" image-tag 2>/dev/null || true)" +if [[ -n "${IMAGE_NAME}" || -n "${IMAGE_TAG}" ]]; then + echo -n "${IMAGE_NAME}:${IMAGE_TAG}" +fi if [[ "$(rpm-ostree status --booted)" =~ "signed" ]]; then echo -n " 🔐" diff --git a/system_files/shared/usr/libexec/ublue-image-resolve b/system_files/shared/usr/libexec/ublue-image-resolve new file mode 100755 index 000000000..49d7721e6 --- /dev/null +++ b/system_files/shared/usr/libexec/ublue-image-resolve @@ -0,0 +1,128 @@ +#!/usr/bin/bash +# Resolve the identity of the image the system is actually running. +# +# `/usr/share/ublue-os/image-info.json` is baked at build time and describes the +# image the filesystem was *built* as. After a rebase it no longer matches what +# is booted, so `bootc status` is the source of truth here and the baked file is +# only a fallback for non-bootc hosts, containers, and early boot. +# +# Usage: ublue-image-resolve +# Prints the resolved value on stdout, or nothing (exit 1) if unresolvable. + +set -uo pipefail + +IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}" + +have() { command -v "$1" >/dev/null 2>&1; } + +# Read a field from the build-time image-info.json. +read_baked_field() { + local key="$1" value + + have jq || return 1 + [[ -r "${IMAGE_INFO_FILE}" ]] || return 1 + + value="$(jq -r --arg k "${key}" '.[$k] // empty' <"${IMAGE_INFO_FILE}" 2>/dev/null)" || return 1 + [[ -n "${value}" && "${value}" != "null" ]] || return 1 + + printf '%s' "${value}" +} + +# Full ref of the booted image according to bootc, e.g. +# ghcr.io/projectbluefin/bluefin-lts:stable +# Schema: .status.booted.image.image.image (bootc HostStatus -> BootEntry -> +# ImageStatus -> ImageReference). +read_live_ref() { + local json ref + + have bootc || return 1 + have jq || return 1 + + json="$(bootc status --json 2>/dev/null)" || return 1 + [[ -n "${json}" ]] || return 1 + + ref="$(jq -r '.status.booted.image.image.image // empty' <<<"${json}" 2>/dev/null)" || return 1 + [[ -n "${ref}" && "${ref}" != "null" ]] || return 1 + + printf '%s' "${ref}" +} + +# Last path component of a ref, with any @digest suffix removed. Splitting on +# the final "/" keeps registry ports (registry:5000/foo/bar:tag) intact. +ref_basename() { + local ref="${1%@*}" + printf '%s' "${ref##*/}" +} + +ref_tag() { + local base + base="$(ref_basename "$1")" + [[ "${base}" == *:* ]] || return 1 + printf '%s' "${base##*:}" +} + +ref_name() { + local base + base="$(ref_basename "$1")" + printf '%s' "${base%%:*}" +} + +# Registry path of a ref with any @digest and :tag removed, e.g. +# ghcr.io/projectbluefin/bluefin-lts. Only the final path component is +# considered, so a registry port survives. +ref_path() { + local ref="${1%@*}" base + base="${ref##*/}" + if [[ "${base}" == *:* ]]; then + printf '%s' "${ref%:*}" + else + printf '%s' "${ref}" + fi +} + +# The baked image-ref carries an ostree transport prefix +# (ostree-image-signed:docker://...). bootc refs never do, so this is only ever +# applied to the fallback value. +strip_transport() { + local ref="$1" + ref="${ref##*://}" + [[ "${ref}" =~ ^[a-z][a-z0-9-]*:[^0-9] ]] && ref="${ref#*:}" + printf '%s' "${ref}" +} + +main() { + local field="${1:-}" ref="" value="" + + case "${field}" in + image-name | image-tag | image-path) ;; + *) + printf 'usage: %s \n' "${0##*/}" >&2 + return 2 + ;; + esac + + ref="$(read_live_ref)" || ref="" + + if [[ -n "${ref}" ]]; then + case "${field}" in + image-path) value="$(ref_path "${ref}")" || value="" ;; + image-name) value="$(ref_name "${ref}")" || value="" ;; + image-tag) value="$(ref_tag "${ref}")" || value="" ;; + esac + fi + + # Digest-pinned refs carry no tag, and non-bootc hosts carry no ref at all. + if [[ -z "${value}" ]]; then + if [[ "${field}" == "image-path" ]]; then + ref="$(read_baked_field image-ref)" || ref="" + [[ -z "${ref}" ]] || value="$(ref_path "$(strip_transport "${ref}")")" + else + value="$(read_baked_field "${field}")" || value="" + fi + fi + [[ -n "${value}" ]] || return 1 + + printf '%s' "${value}" +} + +main "$@" diff --git a/tests/test_changelog.bats b/tests/test_changelog.bats index bdd4a5bb3..7dcba57bf 100644 --- a/tests/test_changelog.bats +++ b/tests/test_changelog.bats @@ -86,8 +86,24 @@ EOF chmod +x "${MOCKDIR}/grep" # Keep the fallback recipe under test; a developer's host bctl must not - # short-circuit repository selection before the mocked curl calls. - export PATH="${MOCKDIR}:$(printf '%s' "${PATH}" | tr ':' '\n' | grep -v '/.local/bin' | paste -sd: -)" + # short-circuit repository selection before the mocked curl calls. Drop + # every PATH entry that actually provides bctl, not just ~/.local/bin. + export PATH="${MOCKDIR}:$(printf '%s' "${PATH}" | tr ':' '\n' | while read -r d; do + [ -x "${d}/bctl" ] || printf '%s\n' "${d}" + done | paste -sd: -)" + + # Mock the live-image resolver (see ublue-image-resolve). changelog.just + # asks it for the booted tag/name instead of reading image-info.json. + cat > "${MOCKDIR}/ublue-image-resolve" << 'RESOLVE_MOCK' +#!/bin/bash +case "$1" in + image-tag) echo "${MOCK_TAG:-latest}" ;; + image-name) echo "${MOCK_NAME:-bluefin}" ;; + *) exit 2 ;; +esac +RESOLVE_MOCK + chmod +x "${MOCKDIR}/ublue-image-resolve" + export IMAGE_RESOLVE="${MOCKDIR}/ublue-image-resolve" SCRIPT_FILE="${WORKDIR}/changelog.sh" _extract_script "${SCRIPT_FILE}" diff --git a/tests/test_ublue_image_info.bats b/tests/test_ublue_image_info.bats index 482ba36c4..16bc5e9b7 100644 --- a/tests/test_ublue_image_info.bats +++ b/tests/test_ublue_image_info.bats @@ -1,6 +1,7 @@ #!/usr/bin/env bats SCRIPT_UNDER_TEST="${BATS_TEST_DIRNAME}/../system_files/shared/usr/bin/ublue-image-info.sh" +IMAGE_RESOLVE_SCRIPT="${BATS_TEST_DIRNAME}/../system_files/shared/usr/libexec/ublue-image-resolve" WORKDIR="" MOCKDIR="" FIXTURE="" @@ -50,11 +51,21 @@ write_image_info_fixture() { EOF } +write_bootc_mock() { + local ref="$1" + + printf '{"status":{"booted":{"image":{"image":{"image":"%s"}}}}}\n' "${ref}" \ + > "${WORKDIR}/bootc-status.json" + write_mock "bootc" "#!/usr/bin/bash +cat '${WORKDIR}/bootc-status.json'" +} + run_script() { local image_info_file="$1" local path_value="$2" - run env IMAGE_INFO_FILE="${image_info_file}" PATH="${path_value}" /usr/bin/bash "${SCRIPT_UNDER_TEST}" + run env IMAGE_INFO_FILE="${image_info_file}" IMAGE_RESOLVE="${IMAGE_RESOLVE_SCRIPT}" \ + PATH="${path_value}" /usr/bin/bash "${SCRIPT_UNDER_TEST}" } @test "ublue-image-info: prints image name, tag, and locked status with fixture data" { @@ -86,8 +97,7 @@ run_script() { run_script "${WORKDIR}/missing-image-info.json" "${MOCKDIR}:${PATH}" [ "${status}" -eq 0 ] - [[ "${output}" == *"${WORKDIR}/missing-image-info.json: No such file or directory"* ]] - [[ "${output}" == *" 🔐" ]] + [ "${output}" = " 🔐" ] } @test "ublue-image-info: handles missing jq without failing" { @@ -97,6 +107,17 @@ run_script() { run_script "${FIXTURE}" "${MOCKDIR}" [ "${status}" -eq 0 ] - [[ "${output}" == *"jq: command not found"* ]] - [[ "${output}" == *" 🔐" ]] + [ "${output}" = " 🔐" ] +} + +@test "ublue-image-info: live bootc tag wins over stale image-info.json" { + write_jq_mock + write_rpm_ostree_mock 'echo "State: booted deployment signed"' + write_image_info_fixture "bluefin" "latest" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable" + + run_script "${FIXTURE}" "${MOCKDIR}:${PATH}" + + [ "${status}" -eq 0 ] + [ "${output}" = "bluefin-lts:stable 🔐" ] } diff --git a/tests/test_ublue_image_resolve.bats b/tests/test_ublue_image_resolve.bats new file mode 100644 index 000000000..9bb5a5320 --- /dev/null +++ b/tests/test_ublue_image_resolve.bats @@ -0,0 +1,302 @@ +#!/usr/bin/env bats +# Tests for system_files/shared/usr/libexec/ublue-image-resolve +# +# The resolver prefers `bootc status --json` (live system) over the build-time +# image-info.json, which goes stale once a user rebases. Tests mock `bootc` and +# `jq` onto PATH so no real system state is touched. +# +# Run: bats tests/test_ublue_image_resolve.bats + +RESOLVER="${BATS_TEST_DIRNAME}/../system_files/shared/usr/libexec/ublue-image-resolve" + +WORKDIR="" +MOCKDIR="" +FIXTURE="" + +setup() { + WORKDIR="${BATS_TEST_DIRNAME}/.test_ublue_image_resolve.$$.$RANDOM" + rm -rf "${WORKDIR}" + MOCKDIR="${WORKDIR}/bin" + FIXTURE="${WORKDIR}/image-info.json" + + mkdir -p "${MOCKDIR}" + + # jq is a passthrough to the real binary; tests that need jq absent simply + # omit it from PATH. + printf '#!/usr/bin/bash\n/usr/bin/jq "$@"\n' > "${MOCKDIR}/jq" + chmod +x "${MOCKDIR}/jq" +} + +teardown() { + rm -rf "${WORKDIR}" +} + +write_baked_info() { + cat > "${FIXTURE}" < "${WORKDIR}/bootc-status.json" + printf '#!/usr/bin/bash\n/usr/bin/cat %s\n' "${WORKDIR}/bootc-status.json" > "${MOCKDIR}/bootc" + chmod +x "${MOCKDIR}/bootc" +} + +# Mock bootc emitting arbitrary output with an arbitrary exit code. +write_bootc_raw_mock() { + local body="$1" rc="${2:-0}" + + printf '#!/usr/bin/bash\nprintf %%s %s\nexit %s\n' "'${body}'" "${rc}" > "${MOCKDIR}/bootc" + chmod +x "${MOCKDIR}/bootc" +} + +run_resolver() { + run env IMAGE_INFO_FILE="${FIXTURE}" PATH="${MOCKDIR}" /usr/bin/bash "${RESOLVER}" "$@" +} + +# --------------------------------------------------------------------------- +# bootc present — live state wins +# --------------------------------------------------------------------------- + +@test "resolve: live bootc tag beats a stale image-info.json" { + write_baked_info "bluefin" "latest" "ostree-image-signed:docker://ghcr.io/projectbluefin/bluefin:latest" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable" + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "stable" ] +} + +@test "resolve: live bootc image name beats a stale image-info.json" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable" + + run_resolver image-name + + [ "${status}" -eq 0 ] + [ "${output}" = "bluefin-lts" ] +} + +@test "resolve: image-path strips the tag from the live ref" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "ghcr.io/projectbluefin/bluefin-lts" ] +} + +@test "resolve: image-path strips both tag and digest from the live ref" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable@sha256:0123456789abcdef" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "ghcr.io/projectbluefin/bluefin-lts" ] +} + +@test "resolve: image-path keeps a registry port intact" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin" + write_bootc_mock "registry.example.com:5000/projectbluefin/bluefin:testing" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "registry.example.com:5000/projectbluefin/bluefin" ] +} + +@test "resolve: image-path keeps a ported registry with no tag intact" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin" + write_bootc_mock "registry.example.com:5000/projectbluefin/bluefin" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "registry.example.com:5000/projectbluefin/bluefin" ] +} + +# --------------------------------------------------------------------------- +# Ref parsing edge cases +# --------------------------------------------------------------------------- + +@test "resolve: tag-and-digest ref yields the tag, not the digest" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable@sha256:0123456789abcdef" + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "stable" ] +} + +@test "resolve: digest-only ref falls back to the baked tag" { + write_baked_info "bluefin" "gts" "ghcr.io/projectbluefin/bluefin:gts" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts@sha256:0123456789abcdef" + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "gts" ] +} + +@test "resolve: digest-only ref still yields the live image name" { + write_baked_info "bluefin" "gts" "ghcr.io/projectbluefin/bluefin:gts" + write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts@sha256:0123456789abcdef" + + run_resolver image-name + + [ "${status}" -eq 0 ] + [ "${output}" = "bluefin-lts" ] +} + +@test "resolve: registry with a port does not confuse tag parsing" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_mock "registry.example.com:5000/projectbluefin/bluefin:testing" + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "testing" ] +} + +@test "resolve: registry with a port does not confuse name parsing" { + write_baked_info "dakota" "latest" "ghcr.io/projectbluefin/dakota:latest" + write_bootc_mock "registry.example.com:5000/projectbluefin/bluefin:testing" + + run_resolver image-name + + [ "${status}" -eq 0 ] + [ "${output}" = "bluefin" ] +} + +@test "resolve: ported registry with no tag falls back to the baked tag" { + write_baked_info "bluefin" "gts" "ghcr.io/projectbluefin/bluefin:gts" + write_bootc_mock "registry.example.com:5000/projectbluefin/bluefin" + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "gts" ] +} + +# --------------------------------------------------------------------------- +# Fallback paths — bootc missing, failing, or useless +# --------------------------------------------------------------------------- + +@test "resolve: bootc absent falls back to image-info.json" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "latest" ] +} + +@test "resolve: bootc exiting non-zero falls back to image-info.json" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_raw_mock "error: must be run as root" 1 + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "latest" ] +} + +@test "resolve: empty bootc output falls back to image-info.json" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_raw_mock "" 0 + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "latest" ] +} + +@test "resolve: invalid bootc JSON falls back to image-info.json" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_raw_mock "not json at all" 0 + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "latest" ] +} + +@test "resolve: non-bootc host (booted null) falls back to image-info.json" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + write_bootc_raw_mock '{"status":{"booted":null}}' 0 + + run_resolver image-tag + + [ "${status}" -eq 0 ] + [ "${output}" = "latest" ] +} + +@test "resolve: baked image-ref transport prefix is stripped for image-path" { + write_baked_info "bluefin" "latest" "ostree-image-signed:docker://ghcr.io/projectbluefin/bluefin" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "ghcr.io/projectbluefin/bluefin" ] +} + +@test "resolve: baked image-ref without a transport scheme is stripped for image-path" { + write_baked_info "bluefin" "latest" "ostree-unverified-registry:ghcr.io/projectbluefin/bluefin" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "ghcr.io/projectbluefin/bluefin" ] +} + +@test "resolve: baked image-ref with a tag yields a tagless image-path" { + write_baked_info "bluefin" "latest" "ostree-image-signed:docker://ghcr.io/projectbluefin/bluefin:latest" + + run_resolver image-path + + [ "${status}" -eq 0 ] + [ "${output}" = "ghcr.io/projectbluefin/bluefin" ] +} + +# --------------------------------------------------------------------------- +# Nothing resolvable / misuse +# --------------------------------------------------------------------------- + +@test "resolve: missing image-info.json and no bootc exits non-zero with no output" { + run_resolver image-tag + + [ "${status}" -ne 0 ] + [ -z "${output}" ] +} + +@test "resolve: jq absent exits non-zero without crashing" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + rm -f "${MOCKDIR}/jq" + + run_resolver image-tag + + [ "${status}" -ne 0 ] + [ -z "${output}" ] +} + +@test "resolve: unknown field is rejected" { + write_baked_info "bluefin" "latest" "ghcr.io/projectbluefin/bluefin:latest" + + run_resolver image-flavor + + [ "${status}" -eq 2 ] + [[ "${output}" == *"usage:"* ]] +}