Merge branch origin/codex/implement-deployment-orchestrator-in-meta-repo into main

This commit is contained in:
Alexa Amundson
2025-11-21 00:05:52 -06:00
10 changed files with 509 additions and 747 deletions

38
.github/workflows/deploy-all.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Deploy All Services
on:
workflow_dispatch:
inputs:
serviceId:
description: "Optional service id to deploy"
required: false
type: string
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
NODE_ENV: production
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Install dependencies
run: npm install
- name: Deploy services
run: |
if [ -n "${{ github.event.inputs.serviceId }}" ]; then
npm run deploy:service -- ${{ github.event.inputs.serviceId }}
else
npm run deploy:all
fi

29
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,29 @@
# Deployment Orchestration
The `BlackRoad-Operating-System` repository is the control plane for deploying the wider BlackRoad OS services. It contains a single registry of services, simple TypeScript utilities for deploys and health checks, and a GitHub Actions workflow for one-click releases.
## Service registry
Service metadata lives in `infra/services.json`. Each entry includes the repository location, Railway project and service names, and the health endpoint for monitoring.
### Adding a new service
1. Add a new object to `infra/services.json` with the required fields (`id`, `name`, `repo`, `kind`, `railwayProject`, `railwayService`, `domain`, `healthPath`).
2. Ensure the Railway project and service names match the target deployment.
3. Include the service `id` in the deploy order inside `scripts/deployAll.ts` if it should participate in the sequential rollout.
## Local commands
Install dependencies once with `npm install`, then use:
- `npm run deploy:all` — deploys all services sequentially via Railway.
- `npm run deploy:service -- <serviceId>` — deploys a single service from the registry.
- `npm run health:all` — checks the health endpoints for every registered service.
## GitHub Actions
Use the **Deploy All** workflow in the Actions tab (`.github/workflows/deploy-all.yml`).
- Trigger manually with **Run workflow** to deploy every service.
- Provide the optional `serviceId` input to deploy just one service using `deploy:service`.
- Deployments expect `RAILWAY_TOKEN` to be available as a GitHub Actions secret and run with `NODE_ENV=production`.

72
infra/services.json Normal file
View File

@@ -0,0 +1,72 @@
[
{
"id": "core",
"name": "BlackRoad OS Core",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-core",
"kind": "backend",
"railwayProject": "blackroad-operating-system",
"railwayService": "core",
"domain": "core.blackroad.systems",
"healthPath": "/health"
},
{
"id": "api",
"name": "BlackRoad OS API Gateway",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-api",
"kind": "backend",
"railwayProject": "blackroad-operating-system",
"railwayService": "api",
"domain": "api.blackroad.systems",
"healthPath": "/health"
},
{
"id": "operator",
"name": "BlackRoad OS Operator",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-operator",
"kind": "worker",
"railwayProject": "blackroad-operating-system",
"railwayService": "operator",
"domain": "operator.blackroad.systems",
"healthPath": "/health"
},
{
"id": "agents",
"name": "BlackRoad OS Agents",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-agents",
"kind": "worker",
"railwayProject": "blackroad-operating-system",
"railwayService": "agents",
"domain": "agents.blackroad.systems",
"healthPath": "/health"
},
{
"id": "console",
"name": "BlackRoad OS Prism Console",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-prism-console",
"kind": "frontend",
"railwayProject": "blackroad-operating-system",
"railwayService": "console",
"domain": "console.blackroad.systems",
"healthPath": "/health"
},
{
"id": "web",
"name": "BlackRoad OS Web",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-web",
"kind": "frontend",
"railwayProject": "blackroad-operating-system",
"railwayService": "app",
"domain": "blackroad.systems",
"healthPath": "/health"
},
{
"id": "docs",
"name": "BlackRoad OS Docs",
"repo": "https://github.com/BlackRoad-OS/blackroad-os-docs",
"kind": "frontend",
"railwayProject": "blackroad-operating-system",
"railwayService": "docs",
"domain": "docs.blackroad.systems",
"healthPath": "/health"
}
]

934
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,21 +10,18 @@
"build": "tsc --noEmit", "build": "tsc --noEmit",
"lint": "eslint . --ext .ts,.js || true", "lint": "eslint . --ext .ts,.js || true",
"test": "echo \"No automated tests yet\"", "test": "echo \"No automated tests yet\"",
"check:health": "ts-node tools/health-check.ts" "check:health": "ts-node --esm tools/health-check.ts",
"deploy:service": "ts-node --esm scripts/deployService.ts",
"deploy:all": "ts-node --esm scripts/deployAll.ts",
"health:all": "ts-node --esm scripts/checkHealth.ts"
}, },
"dependencies": { "dependencies": {
"express": "^4.21.0" "express": "^4.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"ts-node": "^10.9.2", "node-fetch": "^3.0.0",
"typescript": "^5.4.3" "ts-node": "^10.0.0",
"type": "module", "typescript": "^5.0.0"
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"express": "^4.21.0"
} }
} }

35
scripts/checkHealth.ts Normal file
View File

@@ -0,0 +1,35 @@
import fetch from "node-fetch";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { ServiceConfig } from "./types.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..", "");
const servicesPath = path.join(rootDir, "infra", "services.json");
async function loadServices(): Promise<ServiceConfig[]> {
const data = await fs.readFile(servicesPath, "utf-8");
return JSON.parse(data) as ServiceConfig[];
}
async function checkServiceHealth(service: ServiceConfig): Promise<string> {
const url = `https://${service.domain}${service.healthPath}`;
try {
const response = await fetch(url, { method: "GET" });
return `[OK] ${service.id} ${response.status}`;
} catch (error) {
return `[FAIL] ${service.id} ${(error as Error).message}`;
}
}
async function main(): Promise<void> {
const services = await loadServices();
for (const service of services) {
const result = await checkServiceHealth(service);
console.log(result);
}
}
main();

32
scripts/deployAll.ts Normal file
View File

@@ -0,0 +1,32 @@
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { deployServiceById } from "./deployService.js";
import { ServiceConfig } from "./types.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..", "");
const servicesPath = path.join(rootDir, "infra", "services.json");
async function loadServices(): Promise<ServiceConfig[]> {
const data = await fs.readFile(servicesPath, "utf-8");
return JSON.parse(data) as ServiceConfig[];
}
async function main(): Promise<void> {
const desiredOrder = ["core", "api", "operator", "agents", "console", "web", "docs"];
const services = await loadServices();
const servicesById = new Map(services.map((service) => [service.id, service] as const));
for (const id of desiredOrder) {
const service = servicesById.get(id);
if (!service) {
console.warn(`Skipping ${id} because it is not defined in infra/services.json`);
continue;
}
await deployServiceById(service.id);
}
}
main();

86
scripts/deployService.ts Normal file
View File

@@ -0,0 +1,86 @@
import { spawn } from "child_process";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { ServiceConfig } from "./types.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..", "");
const servicesPath = path.join(rootDir, "infra", "services.json");
async function loadServices(): Promise<ServiceConfig[]> {
const data = await fs.readFile(servicesPath, "utf-8");
return JSON.parse(data) as ServiceConfig[];
}
function runRailwayDeploy(service: ServiceConfig): Promise<number> {
return new Promise((resolve, reject) => {
const args = [
"up",
"--project",
service.railwayProject,
"--service",
service.railwayService,
];
console.log(`\n🚀 Deploying ${service.name} (${service.id})`);
console.log(` Command: railway ${args.join(" ")}`);
const child = spawn("railway", args, {
stdio: "inherit",
shell: process.platform === "win32",
env: {
...process.env,
NODE_ENV: process.env.NODE_ENV ?? "production",
},
});
child.on("error", (error) => {
reject(error);
});
child.on("close", (code) => {
if (code === null) {
reject(new Error("Railway command terminated unexpectedly"));
return;
}
console.log(`✅ Finished ${service.id} with exit code ${code}`);
resolve(code);
});
});
}
export async function deployServiceById(serviceId: string): Promise<void> {
const services = await loadServices();
const service = services.find((entry) => entry.id === serviceId);
if (!service) {
console.error(`Service with id "${serviceId}" not found.`);
console.error("Available service ids:", services.map((s) => s.id).join(", "));
process.exitCode = 1;
return;
}
try {
const exitCode = await runRailwayDeploy(service);
if (exitCode !== 0) {
process.exitCode = exitCode;
}
} catch (error) {
console.error(`Deployment failed for ${serviceId}:`, error);
process.exitCode = 1;
}
}
async function main(): Promise<void> {
const serviceId = process.argv[2];
if (!serviceId) {
console.error("Usage: ts-node deployService.ts <serviceId>");
process.exit(1);
}
await deployServiceById(serviceId);
}
main();

10
scripts/types.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface ServiceConfig {
id: string;
name: string;
repo: string;
kind: "backend" | "frontend" | "worker";
railwayProject: string;
railwayService: string;
domain: string;
healthPath: string;
}

View File

@@ -1,13 +1,16 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2021", "target": "ES2021",
"module": "CommonJS", "module": "NodeNext",
"moduleResolution": "Node", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"], "types": ["node"],
"lib": ["ES2021", "DOM"] "lib": ["ES2021", "DOM"]
}, },
"include": ["tools/**/*.ts", "os-spec/**/*.json"] "include": ["scripts/**/*.ts", "tools/**/*.ts", "infra/**/*.json"],
"ts-node": {
"esm": true
}
} }