Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions scripts/simulate-failed-build.sh
Original file line number Diff line number Diff line change
@@ -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/<uuid>/ (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 <blueprint-id>
# ./scripts/simulate-failed-build.sh --cleanup <blueprint-id>

set -euo pipefail

UUID="00000000-0000-0000-0000-000000000000"

usage() {
echo "Usage:"
echo " $0 <blueprint-id> Create a simulated failed build"
echo " $0 --cleanup <blueprint-id> 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}" <<EOF
{
"distribution": "rhel-9",
"image_requests": [
{
"architecture": "x86_64",
"image_type": "guest-image",
"upload_request": { "type": "local" }
}
]
}
EOF

echo ""
echo "Done! Open the Cockpit Image Builder UI and check the failed compose"
echo "under blueprint '${blueprint_id}'. The error details should include"
echo "the journal output above."
echo ""
echo "To clean up: $0 --cleanup ${blueprint_id}"
}

if [ $# -lt 1 ]; then
usage
fi

case "$1" in
--cleanup)
[ $# -ne 2 ] && usage
cleanup "$2"
;;
--help|-h)
usage
;;
*)
[ $# -ne 1 ] && usage
simulate "$1"
;;
esac
5 changes: 5 additions & 0 deletions src/store/api/backend/onprem/composerApi/composes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
mapHostedToOnPrem,
progressFromFile,
readComposes,
readJournalLogs,
safeReadJsonFile,
uploadStatusFromFile,
} from './helpers';
Expand Down Expand Up @@ -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,
};
}
}
Expand Down Expand Up @@ -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,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | undefined> => {
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;
}
};
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading