Run profiles from commit worktrees
This commit is contained in:
@@ -24,6 +24,8 @@ GATEWAY_API_PORT=13000
|
|||||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||||
GATEWAY_REDIS_PREFIX=sammo:gateway
|
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||||
GATEWAY_DB_SCHEMA=public
|
GATEWAY_DB_SCHEMA=public
|
||||||
|
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
||||||
|
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
|
||||||
SESSION_TTL_SECONDS=604800
|
SESSION_TTL_SECONDS=604800
|
||||||
GAME_SESSION_TTL_SECONDS=21600
|
GAME_SESSION_TTL_SECONDS=21600
|
||||||
OAUTH_SESSION_TTL_SECONDS=600
|
OAUTH_SESSION_TTL_SECONDS=600
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
|||||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||||
import { isRecord } from '@sammo-ts/common';
|
import { isRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { BuildRunner } from './buildRunner.js';
|
import type { BuildCommand, BuildRunner } from './buildRunner.js';
|
||||||
import type { ProcessManager } from './processManager.js';
|
import type { ProcessManager } from './processManager.js';
|
||||||
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
|
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
|
||||||
import type { GitWorkspaceManager } from './workspaceManager.js';
|
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||||
@@ -267,7 +267,7 @@ const parseInstallOptions = (
|
|||||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
||||||
|
|
||||||
const buildProcessDefinitions = (
|
export const buildProcessDefinitions = (
|
||||||
profile: GatewayProfileRecord,
|
profile: GatewayProfileRecord,
|
||||||
config: GatewayProcessConfig
|
config: GatewayProcessConfig
|
||||||
): {
|
): {
|
||||||
@@ -277,8 +277,9 @@ const buildProcessDefinitions = (
|
|||||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
const apiCwd = path.join(config.workspaceRoot, 'app', 'game-api');
|
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||||
const daemonCwd = path.join(config.workspaceRoot, 'app', 'game-engine');
|
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||||
|
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
|
||||||
const apiScript = path.join(apiCwd, 'dist', 'index.js');
|
const apiScript = path.join(apiCwd, 'dist', 'index.js');
|
||||||
const daemonScript = path.join(daemonCwd, 'dist', 'index.js');
|
const daemonScript = path.join(daemonCwd, 'dist', 'index.js');
|
||||||
const apiEnv = {
|
const apiEnv = {
|
||||||
@@ -312,6 +313,37 @@ const buildProcessDefinitions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildWorkspaceCommands = (
|
||||||
|
workspaceRoot: string,
|
||||||
|
needsInstall: boolean,
|
||||||
|
env?: Record<string, string>
|
||||||
|
): BuildCommand[] => {
|
||||||
|
const commands: BuildCommand[] = [];
|
||||||
|
if (needsInstall) {
|
||||||
|
commands.push({
|
||||||
|
command: 'pnpm',
|
||||||
|
args: ['install'],
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const packageName of [
|
||||||
|
'@sammo-ts/common',
|
||||||
|
'@sammo-ts/infra',
|
||||||
|
'@sammo-ts/logic',
|
||||||
|
'@sammo-ts/game-api',
|
||||||
|
'@sammo-ts/game-engine',
|
||||||
|
]) {
|
||||||
|
commands.push({
|
||||||
|
command: 'pnpm',
|
||||||
|
args: ['--filter', packageName, 'build'],
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
};
|
||||||
|
|
||||||
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
|
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
|
||||||
profileNames.map((profileName) => {
|
profileNames.map((profileName) => {
|
||||||
const apiName = buildProcessName(profileName, 'api');
|
const apiName = buildProcessName(profileName, 'api');
|
||||||
@@ -699,7 +731,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
openAt: openAt ? openAt.toISOString() : null,
|
openAt: openAt ? openAt.toISOString() : null,
|
||||||
scheduledStartAt: action.scheduledAt ?? null,
|
scheduledStartAt: action.scheduledAt ?? null,
|
||||||
});
|
});
|
||||||
await this.startProfile(activeProfile);
|
const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile;
|
||||||
|
await this.startProfile(builtProfile);
|
||||||
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
@@ -756,33 +789,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||||
const lastUsedAt = this.now().toISOString();
|
const lastUsedAt = this.now().toISOString();
|
||||||
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
|
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
|
||||||
const commands: Array<{
|
const commands = buildWorkspaceCommands(
|
||||||
command: string;
|
workspace.root,
|
||||||
args: string[];
|
workspace.needsInstall,
|
||||||
cwd: string;
|
this.processConfig.baseEnv
|
||||||
env?: Record<string, string>;
|
|
||||||
}> = [];
|
|
||||||
if (workspace.needsInstall) {
|
|
||||||
commands.push({
|
|
||||||
command: 'pnpm',
|
|
||||||
args: ['install'],
|
|
||||||
cwd: workspace.root,
|
|
||||||
env: this.processConfig.baseEnv,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
commands.push(
|
|
||||||
{
|
|
||||||
command: 'pnpm',
|
|
||||||
args: ['--filter', '@sammo-ts/game-api', 'build'],
|
|
||||||
cwd: workspace.root,
|
|
||||||
env: this.processConfig.baseEnv,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: 'pnpm',
|
|
||||||
args: ['--filter', '@sammo-ts/game-engine', 'build'],
|
|
||||||
cwd: workspace.root,
|
|
||||||
env: this.processConfig.baseEnv,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return this.buildRunner.run(commands);
|
return this.buildRunner.run(commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { planProfileReconcile } from '../src/orchestrator/gatewayOrchestrator.js';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildProcessDefinitions,
|
||||||
|
buildWorkspaceCommands,
|
||||||
|
planProfileReconcile,
|
||||||
|
} from '../src/orchestrator/gatewayOrchestrator.js';
|
||||||
|
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
|
||||||
|
|
||||||
|
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
|
||||||
|
profileName: 'che:2',
|
||||||
|
profile: 'che',
|
||||||
|
scenario: '2',
|
||||||
|
apiPort: 15003,
|
||||||
|
status: 'RUNNING',
|
||||||
|
buildStatus: 'SUCCEEDED',
|
||||||
|
buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
buildWorkspace,
|
||||||
|
meta: {},
|
||||||
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
describe('planProfileReconcile', () => {
|
describe('planProfileReconcile', () => {
|
||||||
it('starts missing processes for running profiles', () => {
|
it('starts missing processes for running profiles', () => {
|
||||||
@@ -48,3 +69,45 @@ describe('planProfileReconcile', () => {
|
|||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildProcessDefinitions', () => {
|
||||||
|
const processConfig = {
|
||||||
|
workspaceRoot: '/srv/sammo/main',
|
||||||
|
redisKeyPrefix: 'sammo:gateway',
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('runs a built profile from its commit worktree', () => {
|
||||||
|
const buildWorkspace = '/srv/sammo/worktrees/0123456789abcdef';
|
||||||
|
const definitions = buildProcessDefinitions(buildProfile(buildWorkspace), processConfig);
|
||||||
|
|
||||||
|
expect(definitions.api.cwd).toBe(path.join(buildWorkspace, 'app', 'game-api'));
|
||||||
|
expect(definitions.api.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'));
|
||||||
|
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||||
|
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps main as the runtime for profiles without a commit worktree', () => {
|
||||||
|
const definitions = buildProcessDefinitions(buildProfile(), processConfig);
|
||||||
|
|
||||||
|
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
|
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildWorkspaceCommands', () => {
|
||||||
|
it('installs and builds runtime dependencies before the profile processes', () => {
|
||||||
|
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
|
||||||
|
const commands = buildWorkspaceCommands(workspaceRoot, true);
|
||||||
|
|
||||||
|
expect(commands.map(({ args }) => args)).toEqual([
|
||||||
|
['install'],
|
||||||
|
['--filter', '@sammo-ts/common', 'build'],
|
||||||
|
['--filter', '@sammo-ts/infra', 'build'],
|
||||||
|
['--filter', '@sammo-ts/logic', 'build'],
|
||||||
|
['--filter', '@sammo-ts/game-api', 'build'],
|
||||||
|
['--filter', '@sammo-ts/game-engine', 'build'],
|
||||||
|
]);
|
||||||
|
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,9 +87,14 @@ Gateway runs a lightweight cron loop (setInterval) that:
|
|||||||
- Each workspace stores `lastUsedAt` in DB so cleanup can remove stale worktrees.
|
- Each workspace stores `lastUsedAt` in DB so cleanup can remove stale worktrees.
|
||||||
- Cleanup is invoked manually by admin API and removes worktrees unused for 6+ months.
|
- Cleanup is invoked manually by admin API and removes worktrees unused for 6+ months.
|
||||||
- Build runs `pnpm install` when workspace is created, then executes
|
- Build runs `pnpm install` when workspace is created, then executes
|
||||||
|
the common, infra, and logic dependency builds before
|
||||||
`pnpm --filter @sammo-ts/game-api build` and
|
`pnpm --filter @sammo-ts/game-api build` and
|
||||||
`pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure.
|
`pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure.
|
||||||
- On success, status moves to `PREOPEN` for reserved builds or stays unchanged for manual builds.
|
- On success, status moves to `PREOPEN` for reserved builds or stays unchanged for manual builds.
|
||||||
|
- PM2 starts API/daemon from the profile row's `buildWorkspace`, so profiles
|
||||||
|
pinned to different commits execute the artifacts from their own detached
|
||||||
|
worktrees. A profile without `buildWorkspace` intentionally falls back to the
|
||||||
|
main workspace; this is the `hwe`/main compatibility path.
|
||||||
|
|
||||||
## Current Implementation Status
|
## Current Implementation Status
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,36 @@ handle /che/* {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
redir /kwe /kwe/ 308
|
||||||
|
@kweApi path /kwe/api /kwe/api/*
|
||||||
|
handle @kweApi {
|
||||||
|
reverse_proxy http://172.30.1.54:15005 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_down -Strict-Transport-Security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle /kwe/* {
|
||||||
|
reverse_proxy http://172.30.1.54:15004 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_down -Strict-Transport-Security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redir /twe /twe/ 308
|
||||||
|
@tweApi path /twe/api /twe/api/*
|
||||||
|
handle @tweApi {
|
||||||
|
reverse_proxy http://172.30.1.54:15007 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_down -Strict-Transport-Security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle /twe/* {
|
||||||
|
reverse_proxy http://172.30.1.54:15006 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_down -Strict-Transport-Security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
redir /hwe /hwe/ 308
|
redir /hwe /hwe/ 308
|
||||||
@hweApi path /hwe/api /hwe/api/*
|
@hweApi path /hwe/api /hwe/api/*
|
||||||
handle @hweApi {
|
handle @hweApi {
|
||||||
@@ -69,6 +99,10 @@ strip하지 않으므로 backend의 tRPC/SSE path도 public path 전체를 사
|
|||||||
| gateway API | 15001 | `GATEWAY_API_HOST=0.0.0.0`, `GATEWAY_API_PORT=15001`, `GATEWAY_TRPC_PATH=/gateway/api/trpc` |
|
| gateway API | 15001 | `GATEWAY_API_HOST=0.0.0.0`, `GATEWAY_API_PORT=15001`, `GATEWAY_TRPC_PATH=/gateway/api/trpc` |
|
||||||
| che frontend | 15002 | `VITE_APP_BASE_PATH=/che/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/che/api/trpc`, `VITE_GAME_SSE_URL=/che/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=che` |
|
| che frontend | 15002 | `VITE_APP_BASE_PATH=/che/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/che/api/trpc`, `VITE_GAME_SSE_URL=/che/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=che` |
|
||||||
| che API | 15003 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15003`, `GAME_TRPC_PATH=/che/api/trpc`, `GAME_API_EVENTS_PATH=/che/api/events`, `GAME_UPLOAD_PATH=/che/api/uploads`, `PROFILE=che` |
|
| che API | 15003 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15003`, `GAME_TRPC_PATH=/che/api/trpc`, `GAME_API_EVENTS_PATH=/che/api/events`, `GAME_UPLOAD_PATH=/che/api/uploads`, `PROFILE=che` |
|
||||||
|
| kwe frontend | 15004 | `VITE_APP_BASE_PATH=/kwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/kwe/api/trpc`, `VITE_GAME_SSE_URL=/kwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=kwe` |
|
||||||
|
| kwe API | 15005 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15005`, `GAME_TRPC_PATH=/kwe/api/trpc`, `GAME_API_EVENTS_PATH=/kwe/api/events`, `GAME_UPLOAD_PATH=/kwe/api/uploads`, `PROFILE=kwe` |
|
||||||
|
| twe frontend | 15006 | `VITE_APP_BASE_PATH=/twe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/twe/api/trpc`, `VITE_GAME_SSE_URL=/twe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=twe` |
|
||||||
|
| twe API | 15007 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15007`, `GAME_TRPC_PATH=/twe/api/trpc`, `GAME_API_EVENTS_PATH=/twe/api/events`, `GAME_UPLOAD_PATH=/twe/api/uploads`, `PROFILE=twe` |
|
||||||
| hwe frontend | 15014 | `VITE_APP_BASE_PATH=/hwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/hwe/api/trpc`, `VITE_GAME_SSE_URL=/hwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=hwe` |
|
| hwe frontend | 15014 | `VITE_APP_BASE_PATH=/hwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/hwe/api/trpc`, `VITE_GAME_SSE_URL=/hwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=hwe` |
|
||||||
| hwe API | 15015 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15015`, `GAME_TRPC_PATH=/hwe/api/trpc`, `GAME_API_EVENTS_PATH=/hwe/api/events`, `GAME_UPLOAD_PATH=/hwe/api/uploads`, `PROFILE=hwe` |
|
| hwe API | 15015 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15015`, `GAME_TRPC_PATH=/hwe/api/trpc`, `GAME_API_EVENTS_PATH=/hwe/api/events`, `GAME_UPLOAD_PATH=/hwe/api/uploads`, `PROFILE=hwe` |
|
||||||
|
|
||||||
@@ -85,7 +119,7 @@ strip하지 않으므로 backend의 tRPC/SSE path도 public path 전체를 사
|
|||||||
2. 각 upstream 대신 요청 URI와 listen port를 돌려주는 mock HTTP server를
|
2. 각 upstream 대신 요청 URI와 listen port를 돌려주는 mock HTTP server를
|
||||||
붙여 public path가 올바른 port로 전달되고 prefix가 보존되는지 확인한다.
|
붙여 public path가 올바른 port로 전달되고 prefix가 보존되는지 확인한다.
|
||||||
3. frontend를 임시 output directory에 build하고 `index.html`의 module/CSS
|
3. frontend를 임시 output directory에 build하고 `index.html`의 module/CSS
|
||||||
URL이 각각 `/gateway/`, `/che/`, `/hwe/`로 시작하는지 확인한다.
|
URL이 각각 `/gateway/`, `/che/`, `/kwe/`, `/twe/`, `/hwe/`로 시작하는지 확인한다.
|
||||||
4. bundle에서 외부 API/SSE URL과 `/image` asset base를 확인한다.
|
4. bundle에서 외부 API/SSE URL과 `/image` asset base를 확인한다.
|
||||||
|
|
||||||
저장소의 `tools/e2e-routing-check/Caddyfile`은 18080에서 public Caddy
|
저장소의 `tools/e2e-routing-check/Caddyfile`은 18080에서 public Caddy
|
||||||
|
|||||||
Reference in New Issue
Block a user