mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-18 03:33:59 -05:00
Add deployment orchestration tooling
This commit is contained in:
38
.github/workflows/deploy-all.yml
vendored
Normal file
38
.github/workflows/deploy-all.yml
vendored
Normal 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
29
docs/DEPLOYMENT.md
Normal 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
72
infra/services.json
Normal 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
934
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -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
35
scripts/checkHealth.ts
Normal 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
32
scripts/deployAll.ts
Normal 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
86
scripts/deployService.ts
Normal 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
10
scripts/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user