Skip to content
Open
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
21 changes: 11 additions & 10 deletions workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import {
MigrationPhase,
Artifact,
JobStatusEnum,
JobStatus,
Telemetry,
Phase,
} from '@red-hat-developer-hub/backstage-plugin-x2a-common';
Expand Down Expand Up @@ -335,20 +335,21 @@ async function processJobCompletion(
logger: RouterDeps['logger'],
job: JobWithToken,
): Promise<{ message: string }> {
let status: JobStatusEnum =
validatedRequest.status === 'success' ? 'success' : 'error';
let jobStatus = JobStatus.from(
validatedRequest.status === 'success' ? 'success' : 'error',
);
let errorDetails = validatedRequest.errorDetails || null;

if (status === 'success') {
status = await executePhaseActionsWithErrorHandling(
if (jobStatus.isSuccess()) {
jobStatus = await executePhaseActionsWithErrorHandling(
phase,
projectId,
validatedRequest,
x2aDatabase,
logger,
);

if (status === 'error') {
if (jobStatus.isError()) {
errorDetails = 'Phase actions failed';
}
}
Expand All @@ -357,7 +358,7 @@ async function processJobCompletion(

await x2aDatabase.updateJob({
id: validatedRequest.jobId,
status,
status: jobStatus.value,
finishedAt: new Date(),
errorDetails,
log: logs,
Expand All @@ -375,20 +376,20 @@ async function executePhaseActionsWithErrorHandling(
validatedRequest: CollectArtifactsRequestBody,
x2aDatabase: RouterDeps['x2aDatabase'],
logger: RouterDeps['logger'],
): Promise<JobStatusEnum> {
): Promise<JobStatus> {
try {
await executePhaseActions(phase, {
projectId,
artifacts: validatedRequest.artifacts ?? [],
x2aDatabase,
logger,
});
return 'success';
return JobStatus.SUCCESS;
} catch (error) {
logger.error(
`Phase actions failed for job ${validatedRequest.jobId}: ${error instanceof Error ? error.message : String(error)}`,
);
return 'error';
return JobStatus.ERROR;
}
}

Expand Down
7 changes: 2 additions & 5 deletions workspaces/x2a/plugins/x2a-backend/src/router/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { InputError, NotFoundError } from '@backstage/errors';
import {
ModulePhase,
Job,
JobStatus,
Phase,
} from '@red-hat-developer-hub/backstage-plugin-x2a-common';

Expand All @@ -37,11 +38,7 @@ async function sendJobLogs(
const { x2aDatabase, kubeService, logger } = deps;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');

if (
job.status === 'success' ||
job.status === 'error' ||
job.status === 'cancelled'
) {
if (JobStatus.from(job.status).isFinished()) {
logger.info(
`Job ${job.id} is finished (status: ${job.status}), returning logs from database`,
);
Expand Down
31 changes: 15 additions & 16 deletions workspaces/x2a/plugins/x2a-backend/src/router/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { InputError, NotFoundError } from '@backstage/errors';

import {
type ModulePhase,
JobStatus,
Phase,
} from '@red-hat-developer-hub/backstage-plugin-x2a-common';

Expand Down Expand Up @@ -261,27 +262,25 @@ export function registerModuleRoutes(
});

// Reconcile jobs that appear active against K8s
const activeJobs = existingJobs.filter(job =>
JobStatus.from(job.status).isActive(),
);
const reconciledJobs = await Promise.all(
existingJobs
.filter(job => ['pending', 'running'].includes(job.status))
.map(job =>
reconcileJobStatus(job, { kubeService, x2aDatabase, logger }),
),
activeJobs.map(job =>
reconcileJobStatus(job, { kubeService, x2aDatabase, logger }),
),
);
const hasActiveJob = reconciledJobs.some(job =>
['pending', 'running'].includes(job.status),
const activeJob = reconciledJobs.find(job =>
JobStatus.from(job.status).isActive(),
);

if (hasActiveJob) {
const activeJob = existingJobs.find(job =>
['pending', 'running'].includes(job.status),
);
if (activeJob) {
return res.status(409).json({
error: 'JobAlreadyRunning',
message: `A ${activeJob!.phase} job is already running for this module`,
message: `A ${activeJob.phase} job is already running for this module`,
details: 'Please wait for the current job to complete or cancel it',
activeJobId: activeJob!.id,
activeJobPhase: activeJob!.phase,
activeJobId: activeJob.id,
activeJobPhase: activeJob.phase,
});
}

Expand Down Expand Up @@ -328,7 +327,7 @@ export function registerModuleRoutes(

// Re-read the job to detect cancellation during the K8s creation window
const freshJob = await x2aDatabase.getJob({ id: job.id });
if (freshJob?.status === 'cancelled') {
if (freshJob && JobStatus.from(freshJob.status).isCancelled()) {
try {
await kubeService.deleteJob(k8sJobName);
} catch (e) {
Expand Down Expand Up @@ -422,7 +421,7 @@ export function registerModuleRoutes(

const job = jobs[0];

if (!['pending', 'running'].includes(job.status)) {
if (!JobStatus.from(job.status).isActive()) {
return res.status(409).json({
error: 'JobNotCancellable',
message: `The ${phase} job is in "${job.status}" state and cannot be cancelled.`,
Expand Down
8 changes: 4 additions & 4 deletions workspaces/x2a/plugins/x2a-backend/src/router/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import express from 'express';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors';
import {
JobStatus,
x2aAdminWritePermission,
x2aUserPermission,
} from '@red-hat-developer-hub/backstage-plugin-x2a-common';
Expand Down Expand Up @@ -209,7 +210,7 @@ export function registerProjectRoutes(
// Cancel any active k8s jobs before deleting DB records
const jobs = await x2aDatabase.listJobsForProject({ projectId });
const activeJobs = jobs.filter(
job => ['pending', 'running'].includes(job.status) && job.k8sJobName,
job => JobStatus.from(job.status).isActive() && job.k8sJobName,
);
await Promise.all(
activeJobs.map(job => {
Expand Down Expand Up @@ -308,16 +309,15 @@ export function registerProjectRoutes(
// Check for existing running init job
const existingJobs = await x2aDatabase.listJobsForProject({ projectId });
const activeInitJobs = existingJobs.filter(
job =>
job.phase === 'init' && ['pending', 'running'].includes(job.status),
job => job.phase === 'init' && JobStatus.from(job.status).isActive(),
);
const reconciledInitJobs = await Promise.all(
activeInitJobs.map(job =>
reconcileJobStatus(job, { kubeService, x2aDatabase, logger }),
),
);
const hasActiveInitJob = reconciledInitJobs.some(job =>
['pending', 'running'].includes(job.status),
JobStatus.from(job.status).isActive(),
);

if (hasActiveInitJob) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import {
Job,
JobStatus,
Module,
ProjectStatus,
ProjectStatusState,
Expand Down Expand Up @@ -48,34 +49,39 @@ export function calculateProjectStatus(
};
}

const error = projectModules.filter(
module => module.status === 'error',
).length;
const finished = projectModules.filter(
module =>
module.status === 'success' && module.publish?.status === 'success',
).length;
const waiting = projectModules.filter(
module =>
module.status === 'success' &&
(!module.publish || module.publish.status === 'cancelled'),
).length;
const pending = projectModules.filter(
module => module.status === 'pending',
const modulesWithStatus = projectModules.map(module => ({
module,
status: module.status ? JobStatus.from(module.status) : undefined,
publishStatus: module.publish?.status
? JobStatus.from(module.publish.status)
: undefined,
}));

const error = modulesWithStatus.filter(m => m.status?.isError()).length;
const finished = modulesWithStatus.filter(
m => m.status?.isSuccess() && m.publishStatus?.isSuccess(),
).length;
const running = projectModules.filter(
module => module.status === 'running',
const waiting = modulesWithStatus.filter(
m =>
m.status?.isSuccess() &&
(!m.module.publish || m.publishStatus?.isCancelled()),
).length;
const cancelled = projectModules.filter(
module => module.status === 'cancelled',
const pending = modulesWithStatus.filter(m => m.status?.isPending()).length;
const running = modulesWithStatus.filter(m => m.status?.isRunning()).length;
const cancelled = modulesWithStatus.filter(m =>
m.status?.isCancelled(),
).length;

const initStatus = initJob?.status
? JobStatus.from(initJob.status)
: undefined;

let state: ProjectStatusState;
if (error > 0) {
state = 'failed'; // At least one module is in error state
} else if (['pending', 'running'].includes(initJob?.status ?? '')) {
} else if (initStatus?.isActive()) {
state = 'initializing'; // Project's init job is running or scheduling
} else if (initJob?.status === 'success') {
} else if (initStatus?.isSuccess()) {
if (total > 0 && finished === total) {
state = 'completed'; // All modules are in success state
} else if (total === 0 || pending + cancelled === total) {
Expand Down
44 changes: 44 additions & 0 deletions workspaces/x2a/plugins/x2a-common/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,50 @@ export interface Job {
telemetry?: Telemetry;
}

// @public (undocumented)
export class JobStatus {
// (undocumented)
static activeStatuses(): readonly JobStatus[];
// (undocumented)
static all(): readonly JobStatus[];
// (undocumented)
static readonly CANCELLED: JobStatus;
// (undocumented)
equals(other: JobStatus): boolean;
// (undocumented)
static readonly ERROR: JobStatus;
// (undocumented)
static finishedStatuses(): readonly JobStatus[];
// (undocumented)
static from(raw: string): JobStatus;
// (undocumented)
isActive(): boolean;
// (undocumented)
isCancelled(): boolean;
// (undocumented)
isError(): boolean;
// (undocumented)
isFinished(): boolean;
// (undocumented)
isPending(): boolean;
// (undocumented)
isRunning(): boolean;
// (undocumented)
isSuccess(): boolean;
// (undocumented)
static readonly PENDING: JobStatus;
// (undocumented)
static readonly RUNNING: JobStatus;
// (undocumented)
static readonly SUCCESS: JobStatus;
// (undocumented)
toString(): string;
// (undocumented)
readonly value: JobStatusEnum;
// (undocumented)
static values(): readonly JobStatusEnum[];
}

// @public (undocumented)
export type JobStatusEnum = 'pending' | 'running' | 'success' | 'error' | 'cancelled';

Expand Down
Loading
Loading