game API와 daemon, worker PM2 정의에 인코딩된 profile DATABASE_URL을 명시한다. migration 성공 뒤 runtime과 rollback이 raw POSTGRES 비밀번호로 실패하는 경로를 회귀 테스트한다.
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
|
|
import type { BuildCommand } from '../src/orchestrator/buildRunner.js';
|
|
import type { ProcessManager } from '../src/orchestrator/processManager.js';
|
|
import type {
|
|
GatewayClaimedProfileUpdate,
|
|
GatewayOperationRecord,
|
|
GatewayProfileRecord,
|
|
GatewayProfileRepository,
|
|
} from '../src/orchestrator/profileRepository.js';
|
|
import type { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
|
|
|
const SHA = '1111111111111111111111111111111111111111';
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
const createReleaseWorkspace = async (): Promise<string> => {
|
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-deploy-'));
|
|
temporaryDirectories.push(workspace);
|
|
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/gateway-migrations/20260801000000_gateway'), {
|
|
recursive: true,
|
|
});
|
|
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/migrations/20260801000000_game'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(workspace, 'release-manifest.json'),
|
|
JSON.stringify({
|
|
formatVersion: 1,
|
|
controllerProtocol: 1,
|
|
gatewaySchemaHead: '20260801000000_gateway',
|
|
gameSchemaHead: '20260801000000_game',
|
|
components: ['game-api', 'game-engine', 'game-frontend'],
|
|
})
|
|
);
|
|
await fs.mkdir(path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'index.html'),
|
|
'<!doctype html><title>static profile</title>'
|
|
);
|
|
await fs.writeFile(
|
|
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets', 'app-deadbeef.js'),
|
|
'console.log("static")'
|
|
);
|
|
return workspace;
|
|
};
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
|
|
});
|
|
|
|
describe('profile DEPLOY operation', () => {
|
|
it('migrates and atomically switches a static frontend without executing the reset seed path', async () => {
|
|
const workspace = await createReleaseWorkspace();
|
|
const profile: GatewayProfileRecord = {
|
|
profileName: 'che:1010',
|
|
profile: 'che',
|
|
instanceKey: '1010',
|
|
currentScenario: '1010',
|
|
scenario: '1010',
|
|
apiPort: 15003,
|
|
status: 'RUNNING',
|
|
buildStatus: 'SUCCEEDED',
|
|
buildCommitSha: '2222222222222222222222222222222222222222',
|
|
buildWorkspace: '/srv/sammo/old',
|
|
meta: {},
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
};
|
|
const operation: GatewayOperationRecord = {
|
|
id: '33333333-3333-4333-8333-333333333333',
|
|
profileName: profile.profileName,
|
|
type: 'DEPLOY',
|
|
status: 'RUNNING',
|
|
sourceMode: 'COMMIT',
|
|
sourceRef: SHA,
|
|
payload: {},
|
|
requestedBy: 'admin',
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
};
|
|
let nextOperation: GatewayOperationRecord | null = operation;
|
|
const patches: GatewayClaimedProfileUpdate[] = [];
|
|
const completions: string[] = [];
|
|
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
|
const repository: GatewayProfileRepository = {
|
|
listProfiles: async () => [profile],
|
|
getProfile: async () => profile,
|
|
upsertProfile: async () => profile,
|
|
updateCurrentScenario: async () => profile,
|
|
updateStatus: async () => profile,
|
|
updateBuildStatus: async () => profile,
|
|
updateMeta: async () => profile,
|
|
listReservedToStart: async () => [],
|
|
findQueuedBuild: async () => null,
|
|
updateLastError: async () => {},
|
|
updateWorkspaceUsage: async () => {},
|
|
clearWorkspaceUsage: async () => {},
|
|
listOperations: async () => [],
|
|
getOperation: async () => operation,
|
|
listOperationLogs: async () => [],
|
|
appendOperationLog: async (operationId, input) => {
|
|
logs.push(input);
|
|
return {
|
|
cursor: String(logs.length),
|
|
operationId,
|
|
createdAt: '2026-08-11T00:00:00.000Z',
|
|
...input,
|
|
};
|
|
},
|
|
createOperation: async () => operation,
|
|
claimNextOperation: async () => {
|
|
const value = nextOperation;
|
|
nextOperation = null;
|
|
return value;
|
|
},
|
|
renewOperationLease: async () => true,
|
|
pinOperationResolvedCommit: async () => true,
|
|
updateProfileForOperation: async (_id, _owner, _profileName, patch) => {
|
|
patches.push(patch);
|
|
return { ...profile, ...patch } as GatewayProfileRecord;
|
|
},
|
|
completeOperation: async (_id, status) => {
|
|
completions.push(status);
|
|
return { ...operation, status };
|
|
},
|
|
requeueOperation: async () => operation,
|
|
cancelOperation: async () => false,
|
|
retryOperation: async () => null,
|
|
};
|
|
const processNames = [
|
|
'sammo:che:1010:game-frontend',
|
|
'sammo:che:1010:game-api',
|
|
'sammo:che:1010:turn-daemon',
|
|
'sammo:che:1010:auction-worker',
|
|
'sammo:che:1010:battle-sim-worker',
|
|
'sammo:che:1010:tournament-worker',
|
|
];
|
|
const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend'));
|
|
const running = new Set(processNames);
|
|
const startedDefinitions: Array<Parameters<ProcessManager['start']>[0]> = [];
|
|
const processManager: ProcessManager = {
|
|
list: async () => [...running].map((name) => ({ name, status: 'online' })),
|
|
start: async (definition) => {
|
|
startedDefinitions.push(definition);
|
|
running.add(definition.name);
|
|
},
|
|
stop: async () => {},
|
|
delete: async (name) => {
|
|
running.delete(name);
|
|
},
|
|
};
|
|
const commandGroups: BuildCommand[][] = [];
|
|
const workspaceManager = {
|
|
resolveCommit: async () => SHA,
|
|
prepare: async () => ({ root: workspace, created: true, needsInstall: true }),
|
|
} as unknown as GitWorkspaceManager;
|
|
const orchestrator = new GatewayOrchestrator({
|
|
repository,
|
|
processManager,
|
|
buildRunner: {
|
|
run: async (commands, onProgress) => {
|
|
commandGroups.push(commands);
|
|
for (const command of commands) {
|
|
await onProgress?.({ type: 'COMMAND_START', command });
|
|
await onProgress?.({
|
|
type: 'OUTPUT',
|
|
stream: 'stdout',
|
|
message: 'built profile with postgresql://user:pass@integration.invalid/sammo',
|
|
});
|
|
await onProgress?.({ type: 'COMMAND_END', command, exitCode: 0 });
|
|
}
|
|
return { ok: true, exitCode: 0, output: '' };
|
|
},
|
|
},
|
|
workspaceManager,
|
|
processConfig: {
|
|
workspaceRoot: '/srv/sammo/controller',
|
|
redisKeyPrefix: 'sammo:test',
|
|
gameTokenSecret: 'test-secret',
|
|
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
|
|
frontendServeMode: 'static',
|
|
frontendArtifactRoot: path.join(workspace, 'artifact-volume'),
|
|
frontendReadinessOrigin: 'http://caddy',
|
|
baseEnv: {
|
|
GATEWAY_DATABASE_URL:
|
|
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=public',
|
|
POSTGRES_USER: 'user',
|
|
POSTGRES_PASSWORD: 'raw#password',
|
|
POSTGRES_HOST: 'integration.invalid',
|
|
POSTGRES_PORT: '5432',
|
|
POSTGRES_DB: 'sammo',
|
|
},
|
|
},
|
|
reconcileIntervalMs: 60_000,
|
|
scheduleIntervalMs: 60_000,
|
|
buildIntervalMs: 60_000,
|
|
adminActionIntervalMs: 60_000,
|
|
profileReadinessTimeoutMs: 10,
|
|
fetchImpl: async () => new Response('', { status: 200 }),
|
|
});
|
|
|
|
await orchestrator.runOperationsNow();
|
|
|
|
expect(commandGroups).toHaveLength(2);
|
|
expect(commandGroups[0]?.[0]?.args).toEqual(['install', '--frozen-lockfile']);
|
|
expect(commandGroups[0]?.[1]?.args).toContain('--filter=@sammo-ts/game-api');
|
|
expect(commandGroups[0]?.[1]?.args).not.toContain('--filter=@sammo-ts/gateway-api');
|
|
expect(commandGroups[0]?.[2]?.args).toContain('build:release');
|
|
expect(commandGroups[0]?.[2]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
|
expect(commandGroups[0]?.[2]?.args).toContain('--concurrency=1');
|
|
expect(commandGroups[0]?.[3]?.args).toEqual([
|
|
'tools/build-scripts/materialize-profile-frontend.mjs',
|
|
'che:1010',
|
|
]);
|
|
expect(commandGroups[0]?.[2]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
|
|
expect(commandGroups[0]?.[3]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
|
|
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
|
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
|
]);
|
|
expect(commandGroups[1]?.[0]?.env?.DATABASE_URL).toBe(
|
|
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che'
|
|
);
|
|
expect(startedDefinitions).toHaveLength(backendProcessNames.length);
|
|
for (const definition of startedDefinitions) {
|
|
expect(definition.env?.DATABASE_URL).toBe(
|
|
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che'
|
|
);
|
|
}
|
|
expect(commandGroups.flat().some((command) => command.env?.GATEWAY_ROLE === 'profile-seed')).toBe(false);
|
|
expect(patches.at(-1)).toMatchObject({
|
|
buildStatus: 'SUCCEEDED',
|
|
buildCommitSha: SHA,
|
|
buildWorkspace: workspace,
|
|
meta: { releaseSource: { mode: 'COMMIT', ref: SHA } },
|
|
});
|
|
expect(completions).toEqual(['SUCCEEDED']);
|
|
expect(logs).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ phase: 'resolve', message: `대상 커밋을 ${SHA}로 고정했습니다.` }),
|
|
expect.objectContaining({ phase: 'build', level: 'OUTPUT' }),
|
|
expect.objectContaining({ phase: 'migration', level: 'OUTPUT' }),
|
|
expect.objectContaining({ phase: 'readiness', message: 'profile readiness 확인을 통과했습니다.' }),
|
|
expect.objectContaining({ phase: 'complete', message: 'DB 보존 버전 업데이트가 완료되었습니다.' }),
|
|
])
|
|
);
|
|
expect(logs.map((entry) => entry.message).join('\n')).not.toContain('pass@integration.invalid');
|
|
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]');
|
|
expect([...running].sort()).toEqual([...backendProcessNames].sort());
|
|
expect(
|
|
await fs.readFile(path.join(workspace, 'artifact-volume', 'che', 'current', 'index.html'), 'utf8')
|
|
).toContain('static profile');
|
|
});
|
|
});
|