From 6a91ba27e2176c15f9d9194a967a736bca927354 Mon Sep 17 00:00:00 2001 From: Gianluca Zuccarelli Date: Wed, 5 Aug 2026 10:02:58 +0100 Subject: [PATCH 1/3] composes: add readJournalLogs helper Uses cockpit.spawn() to call journalctl directly rather than the cockpit/journal module, whose Deferred-based API doesn't reliably settle when the underlying spawn rejects. Co-authored-by: Claude Sonnet 4 --- .../onprem/composerApi/helpers/index.ts | 1 + .../composerApi/helpers/readJournalLogs.ts | 37 ++++++++++ .../helpers/tests/readJournalLogs.test.ts | 68 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/store/api/backend/onprem/composerApi/helpers/readJournalLogs.ts create mode 100644 src/store/api/backend/onprem/composerApi/helpers/tests/readJournalLogs.test.ts diff --git a/src/store/api/backend/onprem/composerApi/helpers/index.ts b/src/store/api/backend/onprem/composerApi/helpers/index.ts index b21d0ec061..8357c239cf 100644 --- a/src/store/api/backend/onprem/composerApi/helpers/index.ts +++ b/src/store/api/backend/onprem/composerApi/helpers/index.ts @@ -28,6 +28,7 @@ export type { UserOnPrem, } from './blueprintMapper'; export { imageStatusFromBuildlog } from './imageStatusFromBuildlog'; +export { readJournalLogs } from './readJournalLogs'; export { progressFromFile } from './progressFromFile'; export { uploadStatusFromFile } from './uploadStatusFromFile'; export { imageStatusFallback } from './imageStatusFallback'; diff --git a/src/store/api/backend/onprem/composerApi/helpers/readJournalLogs.ts b/src/store/api/backend/onprem/composerApi/helpers/readJournalLogs.ts new file mode 100644 index 0000000000..7335a324c4 --- /dev/null +++ b/src/store/api/backend/onprem/composerApi/helpers/readJournalLogs.ts @@ -0,0 +1,37 @@ +import cockpit from 'cockpit'; + +// Cap journal output so failure details stay concise in the UI. +const JOURNAL_MAX_LINES = 50; + +// We use cockpit.spawn() directly rather than the cockpit/journal +// module because journalctl() returns a Cockpit Deferred whose +// .done()/.fail() callbacks don't reliably resolve when the +// underlying spawn rejects. +export const readJournalLogs = async ( + composeId: string, +): Promise => { + try { + const output = await cockpit.spawn( + [ + 'journalctl', + '-q', + `--lines=${JOURNAL_MAX_LINES}`, + '--output=cat', + '--', + `_SYSTEMD_UNIT=cockpit-image-builder-${composeId}.service`, + ], + { superuser: 'try' }, + ); + + const text = (output as string).trim(); + if (text.length === 0) { + return undefined; + } + + return text; + } catch (e) { + // eslint-disable-next-line no-console + console.error(`Failed to read journal logs for ${composeId}:`, e); + return undefined; + } +}; diff --git a/src/store/api/backend/onprem/composerApi/helpers/tests/readJournalLogs.test.ts b/src/store/api/backend/onprem/composerApi/helpers/tests/readJournalLogs.test.ts new file mode 100644 index 0000000000..9771bfeac0 --- /dev/null +++ b/src/store/api/backend/onprem/composerApi/helpers/tests/readJournalLogs.test.ts @@ -0,0 +1,68 @@ +import cockpit from 'cockpit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readJournalLogs } from '../readJournalLogs'; + +vi.mock('cockpit', () => ({ + default: { + spawn: vi.fn(), + }, +})); + +describe('readJournalLogs', () => { + const composeId = 'abc-123'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns journal output for a compose', async () => { + vi.mocked(cockpit.spawn).mockResolvedValue( + 'Starting build...\nBuild failed with exit code 1\n', + ); + + const result = await readJournalLogs(composeId); + + expect(result).toBe('Starting build...\nBuild failed with exit code 1'); + }); + + it('passes correct journalctl arguments', async () => { + vi.mocked(cockpit.spawn).mockResolvedValue(''); + + await readJournalLogs(composeId); + + expect(cockpit.spawn).toHaveBeenCalledWith( + [ + 'journalctl', + '-q', + '--lines=50', + '--output=cat', + '--', + `_SYSTEMD_UNIT=cockpit-image-builder-${composeId}.service`, + ], + { superuser: 'try' }, + ); + }); + + it('returns undefined when no entries are returned', async () => { + vi.mocked(cockpit.spawn).mockResolvedValue(''); + + const result = await readJournalLogs(composeId); + + expect(result).toBeUndefined(); + }); + + it('returns undefined and logs when journalctl rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('access denied'); + vi.mocked(cockpit.spawn).mockRejectedValue(error); + + const result = await readJournalLogs(composeId); + + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + `Failed to read journal logs for ${composeId}:`, + error, + ); + }); +}); From a719f6517deca22ede043bb563a088340963b7da Mon Sep 17 00:00:00 2001 From: Gianluca Zuccarelli Date: Wed, 5 Aug 2026 10:03:11 +0100 Subject: [PATCH 2/3] composes: append journal output to build failure errors When a build fails and no buildlog or upload result is available, the error is generic. Reading the systemd journal for the transient unit surfaces richer diagnostic info in those cases. Co-authored-by: Claude Sonnet 4 --- src/store/api/backend/onprem/composerApi/composes.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/store/api/backend/onprem/composerApi/composes.ts b/src/store/api/backend/onprem/composerApi/composes.ts index bc70192b00..5c2079a558 100644 --- a/src/store/api/backend/onprem/composerApi/composes.ts +++ b/src/store/api/backend/onprem/composerApi/composes.ts @@ -15,6 +15,7 @@ import { mapHostedToOnPrem, progressFromFile, readComposes, + readJournalLogs, safeReadJsonFile, uploadStatusFromFile, } from './helpers'; @@ -255,11 +256,13 @@ export const composeEndpoints = (builder: OnPremBuilder) => ({ return status; } } else { + const journalLogs = await readJournalLogs(queryArgs.composeId); status.image_status.status = 'failure'; status.image_status.error = { id: 10, reason: 'image-builder process is not running and no result was found', + details: journalLogs, }; } } @@ -298,11 +301,13 @@ export const composeEndpoints = (builder: OnPremBuilder) => ({ }; } } else if (!unitActive) { + const journalLogs = await readJournalLogs(queryArgs.composeId); status.image_status.status = 'failure'; status.image_status.error = { id: 28, reason: 'image-builder process is not running and no upload result found', + details: journalLogs, }; } From ae8ccdaa1203bc8575411e4088ec5ee1248f2472 Mon Sep 17 00:00:00 2001 From: Gianluca Zuccarelli Date: Wed, 5 Aug 2026 10:05:30 +0100 Subject: [PATCH 3/3] scripts: add simulate-failed-build script Simulates a failed image build for manual testing of journal log output in the Cockpit UI. Uses a fixed UUID so repeated runs don't create multiple fake composes. Includes a --cleanup flag to remove the simulated compose directory and blueprint entry. Co-authored-by: Claude Sonnet 4 --- scripts/simulate-failed-build.sh | 131 +++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100755 scripts/simulate-failed-build.sh diff --git a/scripts/simulate-failed-build.sh b/scripts/simulate-failed-build.sh new file mode 100755 index 0000000000..8946f0ed3f --- /dev/null +++ b/scripts/simulate-failed-build.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# +# Simulates a failed image build for manual testing of journal log output +# in the Cockpit UI. +# +# Creates: +# 1. A transient systemd unit that logs messages and exits non-zero +# 2. A compose directory in /var/lib/cockpit-image-builder// (no buildlog) +# 3. A compose entry under an existing blueprint so getComposeStatus finds it +# +# Uses a fixed UUID so cleanup is straightforward and repeated runs don't +# create multiple fake composes. +# +# Usage: +# ./scripts/simulate-failed-build.sh +# ./scripts/simulate-failed-build.sh --cleanup + +set -euo pipefail + +UUID="00000000-0000-0000-0000-000000000000" + +usage() { + echo "Usage:" + echo " $0 Create a simulated failed build" + echo " $0 --cleanup Clean up a previous simulation" + exit 1 +} + +get_bp_dir() { + local blueprint_id="$1" + local state_dir="${XDG_STATE_HOME:-$HOME/.local/state}" + echo "${state_dir}/cockpit-image-builder/${blueprint_id}" +} + +cleanup() { + local blueprint_id="$1" + local bp_dir + bp_dir="$(get_bp_dir "$blueprint_id")" + + echo "Cleaning up simulated build ${UUID}..." + sudo rm -rf "/var/lib/cockpit-image-builder/${UUID}" + rm -f "${bp_dir}/${UUID}" + echo "Done." +} + +simulate() { + local blueprint_id="$1" + local bp_dir + bp_dir="$(get_bp_dir "$blueprint_id")" + + if [ ! -d "$bp_dir" ]; then + echo "Error: blueprint directory not found: ${bp_dir}" + echo "Create a blueprint in the Cockpit UI first, then re-run this script." + exit 1 + fi + + # Clean up any previous simulation first + sudo rm -rf "/var/lib/cockpit-image-builder/${UUID}" + rm -f "${bp_dir}/${UUID}" + + echo "==> Compose ID: ${UUID}" + echo "==> Blueprint: ${blueprint_id}" + echo "" + + # 1. Create the compose output directory (without a buildlog to trigger error id 10) + echo "Creating compose directory..." + sudo mkdir -p "/var/lib/cockpit-image-builder/${UUID}" + + # 2. Start a transient unit that logs diagnostic messages and fails + echo "Starting failing systemd unit..." + sudo systemd-run \ + --unit "cockpit-image-builder-${UUID}" \ + --collect \ + -- /bin/bash -c ' + echo "image-builder: starting build for compose" + echo "image-builder: resolving package dependencies" + echo "image-builder: fatal error - disk space exhausted on /var/lib" + echo "image-builder: build aborted" + exit 1 + ' + + # 3. Wait for the unit to finish (it should fail almost instantly) + echo "Waiting for unit to exit..." + sleep 2 + + # 4. Verify journal entries exist + echo "" + echo "==> Journal entries:" + journalctl -u "cockpit-image-builder-${UUID}.service" --no-pager -q || true + + # 5. Create the compose entry in the blueprint directory + echo "" + echo "Creating compose entry..." + cat > "${bp_dir}/${UUID}" <