feat(release): 정적 프런트엔드와 격리 빌더를 도입한다
Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다. 릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api';
|
||||
import { resolveFrontendServeMode, type FrontendServeMode } from '@sammo-ts/gateway-api';
|
||||
import { resolvePostgresPoolMax } from '@sammo-ts/infra';
|
||||
|
||||
const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => {
|
||||
@@ -24,6 +25,11 @@ export interface ReleaseControllerConfig {
|
||||
gatewayApiPort: number;
|
||||
gatewayFrontendPort: number;
|
||||
gatewayBasePath: string;
|
||||
frontendServeMode?: FrontendServeMode;
|
||||
frontendArtifactRoot?: string;
|
||||
frontendReadinessOrigin?: string;
|
||||
releaseBuilderUrl?: string;
|
||||
activeReleaseGitRef?: string;
|
||||
pollIntervalMs: number;
|
||||
readinessTimeoutMs: number;
|
||||
postgresPoolMax: number;
|
||||
@@ -50,6 +56,11 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process.
|
||||
gatewayApiPort: parsePositiveInt(env.GATEWAY_API_PORT, 15001, 'GATEWAY_API_PORT'),
|
||||
gatewayFrontendPort: parsePositiveInt(env.GATEWAY_FRONTEND_PORT, 15000, 'GATEWAY_FRONTEND_PORT'),
|
||||
gatewayBasePath: env.GATEWAY_BASE_PATH?.trim() || '/gateway',
|
||||
frontendServeMode: resolveFrontendServeMode(env.FRONTEND_SERVE_MODE),
|
||||
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
|
||||
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
|
||||
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
|
||||
activeReleaseGitRef: env.GATEWAY_ACTIVE_RELEASE_GIT_REF?.trim() || undefined,
|
||||
pollIntervalMs: parsePositiveInt(env.RELEASE_CONTROLLER_POLL_MS, 5000, 'RELEASE_CONTROLLER_POLL_MS'),
|
||||
readinessTimeoutMs: parsePositiveInt(
|
||||
env.RELEASE_CONTROLLER_READINESS_TIMEOUT_MS,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GitWorkspaceManager,
|
||||
Pm2ProcessManager,
|
||||
PnpmBuildRunner,
|
||||
createReleaseBuildRunner,
|
||||
} from '@sammo-ts/gateway-api';
|
||||
|
||||
import { resolveReleaseControllerConfig } from './config.js';
|
||||
@@ -28,6 +29,7 @@ const main = async (): Promise<void> => {
|
||||
baseEnv: config.baseEnv,
|
||||
});
|
||||
const buildRunner = new PnpmBuildRunner();
|
||||
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner);
|
||||
const processManager = new Pm2ProcessManager();
|
||||
const controller = new GatewayReleaseController(repository, workspaceManager, buildRunner, processManager, config);
|
||||
const command = process.argv[2] ?? 'daemon';
|
||||
@@ -57,7 +59,8 @@ const main = async (): Promise<void> => {
|
||||
sourceMode,
|
||||
sourceRef,
|
||||
workspaceManager,
|
||||
buildRunner,
|
||||
buildRunner: releaseBuildRunner,
|
||||
migrationRunner: buildRunner,
|
||||
processManager,
|
||||
config,
|
||||
});
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
type GitWorkspaceManager,
|
||||
type ProcessDefinition,
|
||||
type ProcessManager,
|
||||
createReleaseBuildRunner,
|
||||
FrontendArtifactManager,
|
||||
readReleaseManifest,
|
||||
sanitizeManagedProcessEnv,
|
||||
sanitizeReleaseBuildEnv,
|
||||
} from '@sammo-ts/gateway-api';
|
||||
import { resolvePostgresPoolMax } from '@sammo-ts/infra';
|
||||
|
||||
@@ -27,7 +30,7 @@ import type { ReleaseControllerConfig } from './config.js';
|
||||
const LEASE_DURATION_MS = 10 * 60_000;
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
const CANCELLATION_POLL_INTERVAL_MS = 500;
|
||||
const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
|
||||
const MANAGED_PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
|
||||
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
|
||||
export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
@@ -48,13 +51,14 @@ const buildGatewayReleaseCommands = (
|
||||
needsInstall: boolean,
|
||||
config: ReleaseControllerConfig
|
||||
): BuildCommand[] => {
|
||||
const env = {
|
||||
...sanitizeManagedProcessEnv(config.baseEnv),
|
||||
const env = sanitizeReleaseBuildEnv({
|
||||
...config.baseEnv,
|
||||
NODE_OPTIONS: config.baseEnv.RELEASE_BUILD_NODE_OPTIONS ?? config.baseEnv.NODE_OPTIONS,
|
||||
VITE_APP_BASE_PATH: config.gatewayBasePath,
|
||||
VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`,
|
||||
VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc',
|
||||
VITE_GAME_WEB_URL_TEMPLATE: '/{profile}/',
|
||||
};
|
||||
});
|
||||
return [
|
||||
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
|
||||
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env),
|
||||
@@ -97,7 +101,7 @@ export const buildGatewayProcessDefinitions = (
|
||||
GATEWAY_API_PORT: String(config.gatewayApiPort),
|
||||
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
|
||||
};
|
||||
return [
|
||||
const definitions: ProcessDefinition[] = [
|
||||
{
|
||||
name: 'sammo:gateway-api',
|
||||
script: apiScript,
|
||||
@@ -108,13 +112,6 @@ export const buildGatewayProcessDefinitions = (
|
||||
GATEWAY_ROLE: 'api',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sammo:gateway-frontend',
|
||||
script: frontendScript,
|
||||
cwd: frontendCwd,
|
||||
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
|
||||
env,
|
||||
},
|
||||
{
|
||||
name: 'sammo:gateway-orchestrator',
|
||||
script: apiScript,
|
||||
@@ -126,6 +123,16 @@ export const buildGatewayProcessDefinitions = (
|
||||
},
|
||||
},
|
||||
];
|
||||
if (config.frontendServeMode !== 'static') {
|
||||
definitions.splice(1, 0, {
|
||||
name: 'sammo:gateway-frontend',
|
||||
script: frontendScript,
|
||||
cwd: frontendCwd,
|
||||
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
|
||||
env,
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
};
|
||||
|
||||
const isMissingProcessError = (error: unknown): boolean =>
|
||||
@@ -133,6 +140,8 @@ const isMissingProcessError = (error: unknown): boolean =>
|
||||
|
||||
export class GatewayReleaseController {
|
||||
private readonly ownerId = randomUUID();
|
||||
private readonly releaseBuildRunner: BuildRunner;
|
||||
private readonly artifactManager: FrontendArtifactManager;
|
||||
|
||||
constructor(
|
||||
private readonly repository: GatewayReleaseRepository,
|
||||
@@ -142,7 +151,10 @@ export class GatewayReleaseController {
|
||||
private readonly config: ReleaseControllerConfig,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
private readonly fetchImpl: typeof fetch = fetch
|
||||
) {}
|
||||
) {
|
||||
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner, fetchImpl);
|
||||
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
|
||||
}
|
||||
|
||||
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
|
||||
const [state, processes, workspaces] = await Promise.all([
|
||||
@@ -317,12 +329,20 @@ export class GatewayReleaseController {
|
||||
const manifest = await readReleaseManifest(workspace.root);
|
||||
assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']);
|
||||
await this.appendLog(operation.id, 'build', 'Gateway 구성 요소를 빌드합니다.');
|
||||
const build = await this.buildRunner.run(
|
||||
const build = await this.releaseBuildRunner.run(
|
||||
buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config),
|
||||
this.buildProgress(operation.id, 'build'),
|
||||
{ signal }
|
||||
);
|
||||
if (!build.ok) throw new Error(`Gateway release build failed: ${build.output.slice(-4000)}`);
|
||||
const stagedArtifact =
|
||||
this.config.frontendServeMode === 'static'
|
||||
? await this.artifactManager.stage({
|
||||
frontendKey: 'gateway',
|
||||
sourceRoot: path.join(workspace.root, 'app', 'gateway-frontend', 'dist'),
|
||||
commitSha,
|
||||
})
|
||||
: null;
|
||||
await this.appendLog(operation.id, 'migration', 'Gateway database migration을 적용합니다.');
|
||||
await this.assertOperationLease(operation.id);
|
||||
const migration = await this.buildRunner.run(
|
||||
@@ -339,8 +359,15 @@ export class GatewayReleaseController {
|
||||
await this.appendLog(operation.id, 'switch', '기존 Gateway process를 정지합니다.');
|
||||
await this.assertOperationLease(operation.id);
|
||||
await this.stopManagedProcesses(operation.id);
|
||||
const previousArtifactReleaseId =
|
||||
this.config.frontendServeMode === 'static'
|
||||
? await this.artifactManager.readCurrentReleaseId('gateway')
|
||||
: null;
|
||||
try {
|
||||
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id);
|
||||
if (stagedArtifact) {
|
||||
await this.artifactManager.activate('gateway', stagedArtifact.releaseId);
|
||||
}
|
||||
await this.waitForReadiness(operation.id);
|
||||
} catch (error) {
|
||||
await this.appendLog(
|
||||
@@ -350,6 +377,13 @@ export class GatewayReleaseController {
|
||||
'ERROR'
|
||||
);
|
||||
await this.stopManagedProcesses(operation.id);
|
||||
if (this.config.frontendServeMode === 'static') {
|
||||
if (previousArtifactReleaseId) {
|
||||
await this.artifactManager.activate('gateway', previousArtifactReleaseId);
|
||||
} else {
|
||||
await this.artifactManager.deactivate('gateway');
|
||||
}
|
||||
}
|
||||
if (previousDefinitions.length) {
|
||||
await this.startDefinitions(previousDefinitions, operation.id);
|
||||
await this.waitForReadiness(operation.id);
|
||||
@@ -357,12 +391,43 @@ export class GatewayReleaseController {
|
||||
throw error;
|
||||
}
|
||||
await this.appendLog(operation.id, 'publish', '검증된 Gateway 릴리스를 active 상태로 게시합니다.');
|
||||
await this.repository.publishRelease(operation.id, this.ownerId, {
|
||||
commitSha,
|
||||
workspace: workspace.root,
|
||||
previousCommitSha: state.activeCommitSha,
|
||||
previousWorkspace: state.activeWorkspace,
|
||||
});
|
||||
const activeReleaseGitRef = this.config.activeReleaseGitRef;
|
||||
const previousPersistentCommit = activeReleaseGitRef
|
||||
? await this.workspaceManager.readPersistentReleaseRef(activeReleaseGitRef)
|
||||
: null;
|
||||
if (activeReleaseGitRef) {
|
||||
await this.workspaceManager.compareAndSwapPersistentReleaseRef(
|
||||
activeReleaseGitRef,
|
||||
previousPersistentCommit,
|
||||
commitSha
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.repository.publishRelease(operation.id, this.ownerId, {
|
||||
commitSha,
|
||||
workspace: workspace.root,
|
||||
previousCommitSha: state.activeCommitSha,
|
||||
previousWorkspace: state.activeWorkspace,
|
||||
});
|
||||
} catch (error) {
|
||||
if (activeReleaseGitRef) {
|
||||
try {
|
||||
await this.workspaceManager.compareAndSwapPersistentReleaseRef(
|
||||
activeReleaseGitRef,
|
||||
commitSha,
|
||||
previousPersistentCommit
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
await this.appendLog(
|
||||
operation.id,
|
||||
'rollback',
|
||||
`Gateway bootstrap ref 복구 실패: ${String(rollbackError)}`,
|
||||
'ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async startDefinitions(definitions: ProcessDefinition[], operationId: string): Promise<void> {
|
||||
@@ -388,7 +453,7 @@ export class GatewayReleaseController {
|
||||
private async stopManagedProcesses(operationId: string): Promise<void> {
|
||||
const existing = new Set((await this.processManager.list()).map((process) => process.name));
|
||||
const failures: string[] = [];
|
||||
for (const name of [...PROCESS_NAMES].reverse()) {
|
||||
for (const name of [...MANAGED_PROCESS_NAMES].reverse()) {
|
||||
if (!existing.has(name)) continue;
|
||||
await this.appendLog(operationId, 'switch', `${name} process를 정리합니다.`);
|
||||
try {
|
||||
@@ -406,26 +471,36 @@ export class GatewayReleaseController {
|
||||
}
|
||||
|
||||
private async waitForReadiness(operationId: string): Promise<void> {
|
||||
await this.appendLog(operationId, 'readiness', 'Gateway API, frontend와 PM2 process readiness를 확인합니다.');
|
||||
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
|
||||
const deadline = Date.now() + this.config.readinessTimeoutMs;
|
||||
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
|
||||
const frontendUrl = `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
|
||||
const frontendUrl =
|
||||
this.config.frontendServeMode === 'static'
|
||||
? new URL(
|
||||
`${this.config.gatewayBasePath.replace(/\/$/u, '')}/`,
|
||||
this.config.frontendReadinessOrigin ?? 'http://caddy'
|
||||
).toString()
|
||||
: `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
|
||||
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
|
||||
(definition) => definition.name
|
||||
);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]);
|
||||
const [api, frontend] = await Promise.all([
|
||||
this.fetchImpl(apiUrl),
|
||||
this.fetchImpl(frontendUrl),
|
||||
]);
|
||||
const processes = await this.processManager.list();
|
||||
const expected = processes.filter((process) =>
|
||||
PROCESS_NAMES.includes(process.name as (typeof PROCESS_NAMES)[number])
|
||||
);
|
||||
const expected = processes.filter((process) => expectedNames.includes(process.name));
|
||||
const safe = expected.filter(
|
||||
(process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0
|
||||
);
|
||||
if (
|
||||
api.ok &&
|
||||
frontend.ok &&
|
||||
expected.length === PROCESS_NAMES.length &&
|
||||
safe.length === PROCESS_NAMES.length &&
|
||||
new Set(safe.map((process) => process.name)).size === PROCESS_NAMES.length
|
||||
expected.length === expectedNames.length &&
|
||||
safe.length === expectedNames.length &&
|
||||
new Set(safe.map((process) => process.name)).size === expectedNames.length
|
||||
) {
|
||||
await this.appendLog(operationId, 'readiness', 'Gateway readiness 확인을 통과했습니다.');
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ProcessManager,
|
||||
readReleaseManifest,
|
||||
sanitizeManagedProcessEnv,
|
||||
sanitizeReleaseBuildEnv,
|
||||
} from '@sammo-ts/gateway-api';
|
||||
|
||||
import type { ReleaseControllerConfig } from './config.js';
|
||||
@@ -22,7 +23,7 @@ const buildReleaseControllerCommands = (
|
||||
needsInstall: boolean,
|
||||
config: ReleaseControllerConfig
|
||||
): BuildCommand[] => {
|
||||
const env = sanitizeManagedProcessEnv(config.baseEnv);
|
||||
const env = sanitizeReleaseBuildEnv(config.baseEnv);
|
||||
return [
|
||||
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
|
||||
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env),
|
||||
@@ -56,6 +57,7 @@ export const upgradeReleaseController = async (options: {
|
||||
sourceRef: string;
|
||||
workspaceManager: GitWorkspaceManager;
|
||||
buildRunner: BuildRunner;
|
||||
migrationRunner?: BuildRunner;
|
||||
processManager: ProcessManager;
|
||||
config: ReleaseControllerConfig;
|
||||
readinessTimeoutMs?: number;
|
||||
@@ -71,7 +73,9 @@ export const upgradeReleaseController = async (options: {
|
||||
buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config)
|
||||
);
|
||||
if (!build.ok) throw new Error(`Release controller build failed: ${build.output.slice(-4000)}`);
|
||||
const migration = await options.buildRunner.run([buildGatewayMigrationCommand(workspace.root, options.config)]);
|
||||
const migration = await (options.migrationRunner ?? options.buildRunner).run([
|
||||
buildGatewayMigrationCommand(workspace.root, options.config),
|
||||
]);
|
||||
if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`);
|
||||
|
||||
const existing = (await options.processManager.list()).find((process) => process.name === CONTROLLER_PROCESS_NAME);
|
||||
|
||||
@@ -43,6 +43,15 @@ const createReleaseWorkspace = async (): Promise<string> => {
|
||||
components: ['gateway-api', 'gateway-frontend', 'release-controller'],
|
||||
})
|
||||
);
|
||||
await fs.mkdir(path.join(workspace, 'app', 'gateway-frontend', 'dist', 'assets'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'app', 'gateway-frontend', 'dist', 'index.html'),
|
||||
'<!doctype html><title>static gateway</title>'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'app', 'gateway-frontend', 'dist', 'assets', 'app-deadbeef.js'),
|
||||
'console.log("gateway")'
|
||||
);
|
||||
return workspace;
|
||||
};
|
||||
|
||||
@@ -148,6 +157,17 @@ it('runs Gateway preview from the frontend workspace dependency', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the Vite preview process when static artifact serving is selected', () => {
|
||||
const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', {
|
||||
...config,
|
||||
frontendServeMode: 'static',
|
||||
});
|
||||
expect(definitions.map((definition) => definition.name)).toEqual([
|
||||
'sammo:gateway-api',
|
||||
'sammo:gateway-orchestrator',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not forward release-controller PM2 identity to Gateway processes', () => {
|
||||
const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', {
|
||||
...config,
|
||||
@@ -181,6 +201,64 @@ it('rejects Gateway definitions before switching processes when Redis connection
|
||||
});
|
||||
|
||||
describe('GatewayReleaseController', () => {
|
||||
it('publishes the built Gateway frontend through the artifact pointer in static mode', async () => {
|
||||
const workspace = await createReleaseWorkspace();
|
||||
const harness = createRepository();
|
||||
const running = new Map<string, string>();
|
||||
const artifactRoot = path.join(workspace, 'artifact-volume');
|
||||
const requests: string[] = [];
|
||||
const releaseRefUpdates: Array<{ expected: string | null; next: string | null }> = [];
|
||||
const controller = new GatewayReleaseController(
|
||||
harness.repository,
|
||||
{
|
||||
resolveCommit: async () => SHA,
|
||||
prepare: async () => ({ root: workspace, created: true, needsInstall: false }),
|
||||
readPersistentReleaseRef: async () => OLD_SHA,
|
||||
compareAndSwapPersistentReleaseRef: async (
|
||||
_ref: string,
|
||||
expected: string | null,
|
||||
next: string | null
|
||||
) => {
|
||||
releaseRefUpdates.push({ expected, next });
|
||||
},
|
||||
} as unknown as GitWorkspaceManager,
|
||||
{ run: async () => ({ ok: true, exitCode: 0, output: '' }) },
|
||||
{
|
||||
list: async () =>
|
||||
[...running].map(([name, cwd]) => ({ name, cwd, status: 'online', restartCount: 0 })),
|
||||
start: async (definition) => {
|
||||
running.set(definition.name, definition.cwd);
|
||||
},
|
||||
stop: async () => {},
|
||||
delete: async (name) => {
|
||||
running.delete(name);
|
||||
},
|
||||
},
|
||||
{
|
||||
...config,
|
||||
frontendServeMode: 'static',
|
||||
frontendArtifactRoot: artifactRoot,
|
||||
frontendReadinessOrigin: 'http://caddy',
|
||||
activeReleaseGitRef: 'refs/sammo/active-gateway',
|
||||
},
|
||||
() => new Date('2026-08-01T00:00:00.000Z'),
|
||||
async (input) => {
|
||||
requests.push(String(input));
|
||||
return new Response('', { status: 200 });
|
||||
}
|
||||
);
|
||||
|
||||
await controller.runOnce();
|
||||
|
||||
expect([...running.keys()].sort()).toEqual(['sammo:gateway-api', 'sammo:gateway-orchestrator']);
|
||||
expect(await fs.readFile(path.join(artifactRoot, 'gateway', 'current', 'index.html'), 'utf8')).toContain(
|
||||
'static gateway'
|
||||
);
|
||||
expect(requests).toContain('http://caddy/gateway/');
|
||||
expect(releaseRefUpdates).toEqual([{ expected: OLD_SHA, next: SHA }]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
});
|
||||
|
||||
it('protects active, rollback, and running-process worktrees while delegating bounded cleanup', async () => {
|
||||
const active = '/srv/sammo/releases/active';
|
||||
const previous = '/srv/sammo/releases/previous';
|
||||
|
||||
Reference in New Issue
Block a user