fix(gateway): 초기화 예약 수명주기를 정합화
초기화 시작과 가오픈·정식 오픈 시각의 의미를 분리하고, 미래 가오픈 전에는 준비된 프로필을 RESERVED로 유지합니다.\n\n가오픈 시각에는 재빌드 없이 PREOPEN으로 전환하며 관리자 화면 도움말과 API·오케스트레이터·Chromium 회귀 검증을 보강합니다.
This commit is contained in:
@@ -158,6 +158,20 @@ export const planProfileReconcile = (
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveResetLifecycleStatus = (
|
||||
now: Date,
|
||||
preopenAt: Date | null,
|
||||
openAt: Date | null
|
||||
): Extract<GatewayProfileStatus, 'RESERVED' | 'PREOPEN' | 'RUNNING'> => {
|
||||
if (preopenAt && preopenAt.getTime() > now.getTime()) {
|
||||
return 'RESERVED';
|
||||
}
|
||||
if (openAt && openAt.getTime() > now.getTime()) {
|
||||
return 'PREOPEN';
|
||||
}
|
||||
return 'RUNNING';
|
||||
};
|
||||
|
||||
type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
||||
|
||||
interface GatewayAdminActionRecord {
|
||||
@@ -880,7 +894,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const now = this.now();
|
||||
const due = await this.repository.listReservedToStart(now);
|
||||
for (const profile of due) {
|
||||
if (!profile.preopenAt || !profile.openAt) {
|
||||
const preopenAt = parseDateTime(profile.preopenAt);
|
||||
const openAt = parseDateTime(profile.openAt);
|
||||
if (!preopenAt || !openAt) {
|
||||
await this.repository.updateLastError(
|
||||
profile.profileName,
|
||||
'Reserved profile is missing preopen/open schedule.'
|
||||
@@ -894,6 +910,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) {
|
||||
await this.repository.updateStatus(
|
||||
profile.profileName,
|
||||
resolveResetLifecycleStatus(now, preopenAt, openAt),
|
||||
{
|
||||
preopenAt: profile.preopenAt,
|
||||
openAt: profile.openAt,
|
||||
}
|
||||
);
|
||||
await this.repository.updateLastError(profile.profileName, null);
|
||||
continue;
|
||||
}
|
||||
const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
|
||||
if (!queued) {
|
||||
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
|
||||
@@ -1884,8 +1912,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
await assertLease?.();
|
||||
const completedAt = this.now().toISOString();
|
||||
const now = this.now();
|
||||
const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false;
|
||||
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
|
||||
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
|
||||
const publishedProfile = await updateClaimedProfile(
|
||||
{
|
||||
currentScenario: String(scenarioId),
|
||||
@@ -1917,30 +1944,37 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
);
|
||||
releasePrepared = true;
|
||||
const builtProfile = publishedProfile ?? {
|
||||
...profile,
|
||||
currentScenario: String(scenarioId),
|
||||
scenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildWorkspace: workspace.root,
|
||||
};
|
||||
await appendLog('switch', '초기화된 profile process를 시작합니다.');
|
||||
const started = await this.startProfile(builtProfile, assertLease);
|
||||
await appendLog('readiness', 'profile process readiness를 확인합니다.');
|
||||
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
|
||||
if (!ready) {
|
||||
if (started) {
|
||||
await this.stopProfile(builtProfile, assertLease);
|
||||
}
|
||||
const detail = started
|
||||
? 'reset completed but profile processes failed readiness'
|
||||
: 'reset completed but profile processes failed to start';
|
||||
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
|
||||
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
||||
if (desiredStatus === 'RESERVED') {
|
||||
await appendLog(
|
||||
'schedule',
|
||||
`${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.`
|
||||
);
|
||||
return { status: 'FAILED', detail };
|
||||
} else {
|
||||
const builtProfile = publishedProfile ?? {
|
||||
...profile,
|
||||
currentScenario: String(scenarioId),
|
||||
scenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildWorkspace: workspace.root,
|
||||
};
|
||||
await appendLog('switch', '초기화된 profile process를 시작합니다.');
|
||||
const started = await this.startProfile(builtProfile, assertLease);
|
||||
await appendLog('readiness', 'profile process readiness를 확인합니다.');
|
||||
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
|
||||
if (!ready) {
|
||||
if (started) {
|
||||
await this.stopProfile(builtProfile, assertLease);
|
||||
}
|
||||
const detail = started
|
||||
? 'reset completed but profile processes failed readiness'
|
||||
: 'reset completed but profile processes failed to start';
|
||||
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
|
||||
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
||||
);
|
||||
return { status: 'FAILED', detail };
|
||||
}
|
||||
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
|
||||
}
|
||||
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
|
||||
await updateClaimedProfile({ lastError: null }, async () => {
|
||||
await this.repository.updateLastError(profile.profileName, null);
|
||||
return this.repository.getProfile(profile.profileName);
|
||||
|
||||
@@ -700,6 +700,63 @@ describe('admin operation API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps reset start, preopen, and formal open as an ordered lifecycle', async () => {
|
||||
const harness = await buildCaller(async (input) => ({
|
||||
id: '77777777-7777-4777-8777-777777777777',
|
||||
profileName: input.profileName,
|
||||
type: 'RESET',
|
||||
status: 'QUEUED',
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef: input.sourceRef,
|
||||
payload: input.payload ?? {},
|
||||
requestedBy: input.requestedBy,
|
||||
scheduledAt: input.scheduledAt,
|
||||
createdAt: '2026-08-08T00:00:00.000Z',
|
||||
updatedAt: '2026-08-08T00:00:00.000Z',
|
||||
}));
|
||||
const install = {
|
||||
scenarioId: 1010,
|
||||
turnTermMinutes: 60,
|
||||
sync: false,
|
||||
fiction: 1 as const,
|
||||
extend: false,
|
||||
blockGeneralCreate: 0 as const,
|
||||
npcMode: 0 as const,
|
||||
showImgLevel: 0 as const,
|
||||
tournamentTrig: false,
|
||||
joinMode: 'full' as const,
|
||||
preopenAt: '2099-01-01T01:00:00.000Z',
|
||||
openAt: '2099-01-01T02:00:00.000Z',
|
||||
};
|
||||
|
||||
await harness.caller.admin.operations.requestReset({
|
||||
profileName: 'che:2',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: 'HEAD',
|
||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||
install,
|
||||
});
|
||||
|
||||
expect(harness.createdInputs[0]).toMatchObject({
|
||||
type: 'RESET',
|
||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||
payload: { install },
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.operations.requestReset({
|
||||
profileName: 'che:2',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: 'HEAD',
|
||||
scheduledAt: '2099-01-01T01:30:00.000Z',
|
||||
install,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'preopenAt cannot be earlier than scheduledAt.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
|
||||
@@ -48,6 +48,9 @@ const createHarness = (
|
||||
startGate?: Promise<void>,
|
||||
options: {
|
||||
profile?: GatewayProfileRecord;
|
||||
profiles?: GatewayProfileRecord[];
|
||||
reservedToStart?: GatewayProfileRecord[];
|
||||
now?: () => Date;
|
||||
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
|
||||
} = {}
|
||||
) => {
|
||||
@@ -59,10 +62,11 @@ const createHarness = (
|
||||
const started: ProcessDefinition[] = [];
|
||||
const stopped: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
const buildStatuses: string[] = [];
|
||||
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||
|
||||
const repository: GatewayProfileRepository = {
|
||||
listProfiles: async () => [harnessProfile],
|
||||
listProfiles: async () => options.profiles ?? [harnessProfile],
|
||||
getProfile: async () => harnessProfile,
|
||||
upsertProfile: async () => harnessProfile,
|
||||
updateCurrentScenario: async () => harnessProfile,
|
||||
@@ -70,9 +74,12 @@ const createHarness = (
|
||||
statuses.push(status);
|
||||
return { ...harnessProfile, status };
|
||||
},
|
||||
updateBuildStatus: async () => harnessProfile,
|
||||
updateBuildStatus: async (_profileName, status) => {
|
||||
buildStatuses.push(status);
|
||||
return { ...harnessProfile, buildStatus: status };
|
||||
},
|
||||
updateMeta: async () => harnessProfile,
|
||||
listReservedToStart: async () => [],
|
||||
listReservedToStart: async () => options.reservedToStart ?? [],
|
||||
findQueuedBuild: async () => null,
|
||||
updateLastError: async () => {},
|
||||
updateWorkspaceUsage: async () => {},
|
||||
@@ -167,10 +174,11 @@ const createHarness = (
|
||||
scheduleIntervalMs: 60_000,
|
||||
buildIntervalMs: 60_000,
|
||||
adminActionIntervalMs: 60_000,
|
||||
now: options.now,
|
||||
cancelGame: options.cancelGame,
|
||||
});
|
||||
|
||||
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
|
||||
return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs };
|
||||
};
|
||||
|
||||
describe('GatewayOrchestrator first-class operations', () => {
|
||||
@@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
expect(harness.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens a prepared reserved profile without rebuilding it again', async () => {
|
||||
const now = new Date('2030-01-01T01:00:00.000Z');
|
||||
const reservedProfile: GatewayProfileRecord = {
|
||||
...profile,
|
||||
status: 'RESERVED',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
|
||||
preopenAt: now.toISOString(),
|
||||
openAt: '2030-01-01T02:00:00.000Z',
|
||||
};
|
||||
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
|
||||
profile: reservedProfile,
|
||||
profiles: [],
|
||||
reservedToStart: [reservedProfile],
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
await harness.orchestrator.runScheduleNow();
|
||||
|
||||
expect(harness.statuses).toEqual(['PREOPEN']);
|
||||
expect(harness.buildStatuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts turns when a prepared reserved profile is handled after formal open', async () => {
|
||||
const now = new Date('2030-01-01T02:00:00.000Z');
|
||||
const reservedProfile: GatewayProfileRecord = {
|
||||
...profile,
|
||||
status: 'RESERVED',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
|
||||
preopenAt: '2030-01-01T01:00:00.000Z',
|
||||
openAt: now.toISOString(),
|
||||
};
|
||||
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
|
||||
profile: reservedProfile,
|
||||
profiles: [],
|
||||
reservedToStart: [reservedProfile],
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
await harness.orchestrator.runScheduleNow();
|
||||
|
||||
expect(harness.statuses).toEqual(['RUNNING']);
|
||||
expect(harness.buildStatuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains the legacy build queue for an unprepared reserved profile', async () => {
|
||||
const now = new Date('2030-01-01T01:00:00.000Z');
|
||||
const reservedProfile: GatewayProfileRecord = {
|
||||
...profile,
|
||||
status: 'RESERVED',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
buildStatus: 'IDLE',
|
||||
buildWorkspace: undefined,
|
||||
preopenAt: now.toISOString(),
|
||||
openAt: '2030-01-01T02:00:00.000Z',
|
||||
};
|
||||
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
|
||||
profile: reservedProfile,
|
||||
profiles: [],
|
||||
reservedToStart: [reservedProfile],
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
await harness.orchestrator.runScheduleNow();
|
||||
|
||||
expect(harness.statuses).toEqual([]);
|
||||
expect(harness.buildStatuses).toEqual(['QUEUED']);
|
||||
});
|
||||
|
||||
it('starts every profile process and records success', async () => {
|
||||
const harness = createHarness(buildOperation('START'));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildProcessDefinitions,
|
||||
buildWorkspaceCommands,
|
||||
planProfileReconcile,
|
||||
resolveResetLifecycleStatus,
|
||||
} from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
|
||||
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
|
||||
@@ -108,6 +109,29 @@ describe('planProfileReconcile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveResetLifecycleStatus', () => {
|
||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||
|
||||
it('keeps an initialized profile reserved until the configured preopen time', () => {
|
||||
expect(
|
||||
resolveResetLifecycleStatus(now, new Date('2030-01-01T01:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
|
||||
).toBe('RESERVED');
|
||||
});
|
||||
|
||||
it('moves through preopen before the formal open time', () => {
|
||||
expect(
|
||||
resolveResetLifecycleStatus(now, new Date('2029-12-31T23:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
|
||||
).toBe('PREOPEN');
|
||||
});
|
||||
|
||||
it('runs immediately when no future lifecycle boundary remains', () => {
|
||||
expect(resolveResetLifecycleStatus(now, null, null)).toBe('RUNNING');
|
||||
expect(
|
||||
resolveResetLifecycleStatus(now, new Date('2029-12-31T22:00:00.000Z'), new Date('2029-12-31T23:00:00.000Z'))
|
||||
).toBe('RUNNING');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProcessDefinitions', () => {
|
||||
const processConfig = {
|
||||
workspaceRoot: '/srv/sammo/main',
|
||||
|
||||
Reference in New Issue
Block a user