Add deployment orchestration skeleton

This commit is contained in:
Alexa Amundson
2025-11-21 00:07:27 -06:00
parent 9e5f17ab3e
commit ada9006b1d
10 changed files with 562 additions and 748 deletions

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

@@ -0,0 +1,39 @@
name: Deploy BlackRoad OS
on:
workflow_dispatch:
inputs:
serviceId:
description: "Optional service id to deploy (core, api, operator, agents, console, web, docs)"
required: false
default: ""
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Use Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm install
- name: Deploy
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
if [ -n "${{ github.event.inputs.serviceId }}" ]; then
echo "Deploying single service: ${{ github.event.inputs.serviceId }}"
npm run deploy:service -- ${{ github.event.inputs.serviceId }}
else
echo "Deploying all services"
npm run deploy:all
fi

36
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,36 @@
# BlackRoad OS Deployment
This repo is the *orchestrator* for all BlackRoad OS services.
## Commands
- `npm run deploy:all`
Deploys all services (core, api, operator, agents, console, web, docs) via Railway.
- `npm run deploy:service -- core`
Deploy only a single service by `id`.
- `npm run health:all`
Check health endpoints for all services using their public domains.
## Service Registry
All services are defined in `infra/services.json`.
To add or change a service:
1. Edit `infra/services.json` and update:
- `id`
- `railwayProject`
- `railwayService`
- `domain`
- `healthPath`
2. Make sure the Railway project/service names match.
3. Commit and push your changes.
## GitHub Actions
The workflow `.github/workflows/deploy-all.yml` lets you:
- Trigger **Deploy BlackRoad OS** from the Actions tab.
- Optionally pass `serviceId` to deploy just one service.

62
infra/services.json Normal file
View File

@@ -0,0 +1,62 @@
[
{
"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": "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"
}
]

962
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
"name": "blackroad-operating-system", "name": "blackroad-operating-system",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"main": "server.mjs", "main": "server.mjs",
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
@@ -10,21 +9,19 @@
"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 tools/health-check.ts",
"deploy:service": "ts-node scripts/deployService.ts",
"deploy:all": "ts-node scripts/deployAll.ts",
"health:all": "ts-node scripts/checkHealth.ts"
}, },
"dependencies": { "dependencies": {
"express": "^4.21.0" "express": "^4.21.0",
"node-fetch": "^2.6.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/node-fetch": "^2.6.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.4.3" "typescript": "^5.6.0"
"type": "module",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"express": "^4.21.0"
} }
} }

41
scripts/checkHealth.ts Normal file
View File

@@ -0,0 +1,41 @@
import * as path from "path";
import * as fs from "fs";
import { ServiceConfig } from "./types";
import fetch from "node-fetch";
function loadServices(): ServiceConfig[] {
const filePath = path.join(__dirname, "..", "infra", "services.json");
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as ServiceConfig[];
}
async function checkService(service: ServiceConfig) {
const url = `https://${service.domain}${service.healthPath}`;
try {
const res = await fetch(url, { method: "GET" });
const statusText = res.statusText || "";
console.log(
`[OK] ${service.id.padEnd(8)} ${res.status} ${statusText} - ${url}`
);
} catch (err) {
console.log(
`[FAIL] ${service.id.padEnd(8)} - ${(err as Error).message} - ${url}`
);
}
}
async function main() {
const services = loadServices();
console.log("\n🌡 Checking health of all services:\n");
for (const svc of services) {
await checkService(svc);
}
console.log("\nDone.\n");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

69
scripts/deployAll.ts Normal file
View File

@@ -0,0 +1,69 @@
import { spawn } from "child_process";
import * as path from "path";
import * as fs from "fs";
import { ServiceConfig } from "./types";
function loadServices(): ServiceConfig[] {
const filePath = path.join(__dirname, "..", "infra", "services.json");
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as ServiceConfig[];
}
function runCommand(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: "inherit",
shell: process.platform === "win32"
});
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with code ${code}`));
}
});
});
}
async function deployAll() {
const services = loadServices();
// Fixed order so dependencies come up first
const order = ["core", "api", "operator", "agents", "console", "web", "docs"];
const ordered = order
.map((id) => services.find((s) => s.id === id))
.filter((s): s is ServiceConfig => Boolean(s));
for (const service of ordered) {
console.log(
`\n===============================\nDeploying ${service.id} (${service.name})\n===============================`
);
const args = [
"railway",
"up",
"--project",
service.railwayProject,
"--service",
service.railwayService
];
try {
await runCommand("npx", args);
console.log(`✅ Done: ${service.id}`);
} catch (err) {
console.error(`❌ Failed: ${service.id}`);
console.error(String(err));
// Keep going to try the rest
}
}
console.log("\n🏁 Deployment run finished.");
}
deployAll().catch((err) => {
console.error(err);
process.exit(1);
});

61
scripts/deployService.ts Normal file
View File

@@ -0,0 +1,61 @@
import { spawn } from "child_process";
import * as path from "path";
import * as fs from "fs";
import { ServiceConfig } from "./types";
function loadServices(): ServiceConfig[] {
const filePath = path.join(__dirname, "..", "infra", "services.json");
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as ServiceConfig[];
}
function deployService(serviceId: string) {
const services = loadServices();
const service = services.find((s) => s.id === serviceId);
if (!service) {
console.error(`Service "${serviceId}" not found.`);
console.error(
"Valid ids: " + services.map((s) => s.id).join(", ")
);
process.exit(1);
}
console.log(`\n🚀 Deploying service: ${service.name} (${service.id})`);
console.log(
` Railway project: ${service.railwayProject}, service: ${service.railwayService}`
);
const cmd = "npx";
const args = [
"railway",
"up",
"--project",
service.railwayProject,
"--service",
service.railwayService
];
const child = spawn(cmd, args, {
stdio: "inherit",
shell: process.platform === "win32"
});
child.on("close", (code) => {
if (code === 0) {
console.log(`✅ Deploy complete: ${service.id}`);
} else {
console.error(`❌ Deploy failed for ${service.id} (exit code ${code})`);
}
process.exit(code === null ? 1 : code);
});
}
const serviceId = process.argv[2];
if (!serviceId) {
console.error("Usage: npm run deploy:service -- <serviceId>");
process.exit(1);
}
deployService(serviceId);

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": "ES2019",
"module": "CommonJS", "module": "commonjs",
"moduleResolution": "Node", "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"], "resolveJsonModule": true,
"lib": ["ES2021", "DOM"] "outDir": "dist",
"rootDir": "."
}, },
"include": ["tools/**/*.ts", "os-spec/**/*.json"] "include": [
"scripts/**/*.ts",
"infra/**/*.json"
]
} }