Files
hermes-control-panel/test/compose-contract.test.cjs
T

124 lines
5.7 KiB
JavaScript

"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { execSync } = require("child_process")
const path = require("path")
const root = path.join(__dirname, "..")
function getCompose() {
try {
const output = execSync(
"docker compose --env-file .env.example --profile pre-gateway --profile post-gateway config --format json",
{ cwd: root, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
)
return JSON.parse(output)
} catch (err) {
return null
}
}
test("compose config is valid and has correct service structure", (t) => {
const config = getCompose()
if (!config) {
t.skip("docker compose not available or config invalid")
return
}
const services = config.services || {}
const serviceNames = Object.keys(services)
// Must have all 6 services
assert(serviceNames.includes("hermes-postgres"), "missing hermes-postgres")
assert(serviceNames.includes("hermes-control-plane"), "missing hermes-control-plane")
assert(serviceNames.includes("hermes-pre-upstream"), "missing hermes-pre-upstream")
assert(serviceNames.includes("hermes-post-upstream"), "missing hermes-post-upstream")
assert(serviceNames.includes("hermes-pre-api"), "missing hermes-pre-api")
assert(serviceNames.includes("hermes-post-api"), "missing hermes-post-api")
// Upstream services must NOT have host ports
const preUpstream = services["hermes-pre-upstream"]
const postUpstream = services["hermes-post-upstream"]
const preApi = services["hermes-pre-api"]
const postApi = services["hermes-post-api"]
assert.deepEqual(preUpstream.profiles, ["pre-gateway"], "hermes-pre-upstream should be opt-in")
assert.deepEqual(preApi.profiles, ["pre-gateway"], "hermes-pre-api should be opt-in")
assert.deepEqual(postUpstream.profiles, ["post-gateway"], "hermes-post-upstream should be opt-in")
assert.deepEqual(postApi.profiles, ["post-gateway"], "hermes-post-api should be opt-in")
assert(!preUpstream.ports || preUpstream.ports.length === 0, "hermes-pre-upstream should not publish host ports")
assert(!postUpstream.ports || postUpstream.ports.length === 0, "hermes-post-upstream should not publish host ports")
// Public gateway services must publish correct ports
assert(preApi.ports && preApi.ports.length > 0, "hermes-pre-api should publish ports")
assert(postApi.ports && postApi.ports.length > 0, "hermes-post-api should publish ports")
const controlPlane = services["hermes-control-plane"]
assert.equal(
controlPlane.image,
"10.0.3.6:4000/zachariahsharma/hermes-control-plane:latest",
"Hermes services should default to the internal HTTP registry image"
)
const controlPlanePort = controlPlane.ports?.find((port) => Number(port.target) === 7843)
const preApiPort = preApi.ports?.find((port) => Number(port.target) === 8645)
const postApiPort = postApi.ports?.find((port) => Number(port.target) === 8646)
assert.equal(controlPlanePort?.host_ip, "127.0.0.1", "control plane should bind to loopback by default")
assert.equal(preApiPort?.host_ip, "127.0.0.1", "pre API should bind to loopback by default")
assert.equal(postApiPort?.host_ip, "127.0.0.1", "post API should bind to loopback by default")
// Post upstream must have API_SERVER_ENABLED=true
const postUpstreamEnv = postUpstream.environment || {}
assert.equal(String(postUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-post-upstream API_SERVER_ENABLED must be true")
assert(postUpstreamEnv.API_SERVER_KEY, "hermes-post-upstream must have an internal API server key")
// Control plane and gateway services must receive DATABASE_URL
const controlPlaneEnv = services["hermes-control-plane"].environment || {}
assert("DATABASE_URL" in controlPlaneEnv, "hermes-control-plane must have DATABASE_URL")
assert.equal(
controlPlaneEnv.HERMES_EXE,
"/opt/hermes-agent/venv/bin/hermes",
"Hermes executable must be baked outside the mutable Hermes state mount"
)
const expectedStateTargets = [
"/home/hermes/.hermes",
"/home/hermes/.codex",
"/home/hermes/.claude",
"/home/hermes/.gemini",
]
for (const serviceName of ["hermes-control-plane", "hermes-pre-upstream", "hermes-post-upstream"]) {
const volumes = services[serviceName].volumes || []
for (const target of expectedStateTargets) {
const mount = volumes.find((volume) => volume.target === target)
assert.equal(mount?.type, "bind", `${serviceName} should keep ${target} as a host bind mount`)
}
}
const preApiEnv = preApi.environment || {}
assert("DATABASE_URL" in preApiEnv, "hermes-pre-api must have DATABASE_URL")
const postApiEnv = postApi.environment || {}
assert("DATABASE_URL" in postApiEnv, "hermes-post-api must have DATABASE_URL")
assert.equal(
postApiEnv.HERMES_UPSTREAM_API_KEY,
postUpstreamEnv.API_SERVER_KEY,
"post API must authenticate to the native Hermes API with the same internal key"
)
// Admin variables only go to control plane
assert("HERMES_ADMIN_USERNAME" in controlPlaneEnv, "hermes-control-plane must have HERMES_ADMIN_USERNAME")
assert(!("HERMES_ADMIN_USERNAME" in preApiEnv), "hermes-pre-api must NOT have HERMES_ADMIN_USERNAME")
assert(!("HERMES_ADMIN_USERNAME" in postApiEnv), "hermes-post-api must NOT have HERMES_ADMIN_USERNAME")
// Gateway services run api-gateway.cjs
const preApiCommand = preApi.command
const postApiCommand = postApi.command
const preApiCmd = Array.isArray(preApiCommand) ? preApiCommand.join(" ") : String(preApiCommand || "")
const postApiCmd = Array.isArray(postApiCommand) ? postApiCommand.join(" ") : String(postApiCommand || "")
assert.match(preApiCmd, /api-gateway\.cjs/, "hermes-pre-api must run api-gateway.cjs")
assert.match(postApiCmd, /api-gateway\.cjs/, "hermes-post-api must run api-gateway.cjs")
})