This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: a ${NAME} in this file is what the
|
||||
// compose document holds verbatim -- compose interpolates it from the deployment's env file -- so
|
||||
// the assertions compare against that text and not against a value.
|
||||
|
||||
// compose.yaml: the ordering the platform does not give (decision AT-D14), and the keys the
|
||||
// platform's own compose model accepts.
|
||||
//
|
||||
// The model is crates/conusai-deployer/src/compose_model.rs: a service key it has no field for
|
||||
// fails the deployment's parse, and so does a host binding in `ports`. This suite is that list,
|
||||
// read from the document, so a key added here in a hurry fails in review and not on stage.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parse } from "yaml"
|
||||
|
||||
interface Service {
|
||||
build?: { context: string; dockerfile: string; target?: string }
|
||||
image?: string
|
||||
command?: string[]
|
||||
environment?: Record<string, string>
|
||||
expose?: (string | number)[]
|
||||
ports?: unknown[]
|
||||
depends_on?: Record<string, { condition: string }>
|
||||
restart?: string
|
||||
healthcheck?: { test: string[]; interval?: string }
|
||||
deploy?: { resources?: { limits?: { cpus?: string; memory?: string } } }
|
||||
init?: boolean
|
||||
working_dir?: string
|
||||
stop_grace_period?: string
|
||||
}
|
||||
|
||||
const document = parse(readFileSync(new URL("../compose.yaml", import.meta.url), "utf8")) as {
|
||||
services: Record<string, Service>
|
||||
}
|
||||
const services = document.services
|
||||
|
||||
const optional = (name: string): string => `\${${name}:-}`
|
||||
|
||||
/**
|
||||
* The passthroughs every profile owes each process (BUG-APP-02): a variable the code reads but
|
||||
* compose never names never reaches the container, so the deployment's value silently loses to
|
||||
* the default. `${VAR}` where boot refuses to run without it, `${VAR:-}` where a default stands.
|
||||
*/
|
||||
function expectPassthroughs(named: Record<string, Service>): void {
|
||||
for (const name of [
|
||||
"OIDC_WEB_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE",
|
||||
"OIDC_INTROSPECTION_CLIENT_ID",
|
||||
"OIDC_INTROSPECTION_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE_STATUS_URL",
|
||||
"OIDC_SCOPES",
|
||||
"AUTH_PROVIDER_GENERATION",
|
||||
"AUTH_COOKIE_NAME",
|
||||
"AUTH_SESSION_IDLE_SECONDS",
|
||||
"AUTH_SESSION_ABSOLUTE_SECONDS",
|
||||
]) {
|
||||
expect(named.web?.environment?.[name], `web ${name}`).toBe(optional(name))
|
||||
}
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
if (named[name]) expect(named[name]?.environment?.CONUSAI_ORG_SLUG).toBe(optional("CONUSAI_ORG_SLUG"))
|
||||
}
|
||||
if (named.worker) {
|
||||
expect(named.worker.environment?.CONUSAI_PUBLIC_URL).toBe("${CONUSAI_PUBLIC_URL}")
|
||||
expect(named.worker.environment?.DOCUMENTS_EXCLUDED_PAGE_CLASSES).toBe(optional("DOCUMENTS_EXCLUDED_PAGE_CLASSES"))
|
||||
}
|
||||
if (named.migrate) expect(named.migrate.environment?.CONUSAI_RELEASE).toBe("${CONUSAI_RELEASE}")
|
||||
for (const name of [
|
||||
"CONUSAI_ENVIRONMENT",
|
||||
"CONUSAI_DSN",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
]) {
|
||||
if (named.migrate) expect(named.migrate.environment?.[name], `migrate ${name}`).toBe(optional(name))
|
||||
}
|
||||
}
|
||||
|
||||
/** Every key TenantService models (compose_model.rs). Anything else fails the platform's parse. */
|
||||
const MODELLED = new Set([
|
||||
"image",
|
||||
"build",
|
||||
"command",
|
||||
"environment",
|
||||
"ports",
|
||||
"expose",
|
||||
"volumes",
|
||||
"depends_on",
|
||||
"restart",
|
||||
"healthcheck",
|
||||
"deploy",
|
||||
"init",
|
||||
"stop_grace_period",
|
||||
"stop_signal",
|
||||
"working_dir",
|
||||
"user",
|
||||
])
|
||||
|
||||
describe("compose.yaml", () => {
|
||||
it("has the three services of conusai.json and nothing else", () => {
|
||||
expect(Object.keys(services).sort()).toEqual(["migrate", "web", "worker"])
|
||||
})
|
||||
|
||||
it("uses only the keys the platform's compose model has a field for", () => {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
for (const key of Object.keys(service)) {
|
||||
expect(MODELLED.has(key), `${name}.${key} is not in the platform's compose model`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("builds every service from one Dockerfile, so one image serves all three", () => {
|
||||
const builds = Object.values(services).map((service) => JSON.stringify(service.build))
|
||||
expect(new Set(builds).size).toBe(1)
|
||||
expect(Object.values(services).every((service) => service.image === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it("starts each process with its own argv, never a shell string", () => {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
expect(Array.isArray(service.command), `${name} has no argv command`).toBe(true)
|
||||
}
|
||||
expect(services.migrate?.command?.at(-1)).toContain("migrate.js")
|
||||
})
|
||||
|
||||
it("runs the migrate job to completion before web and worker start", () => {
|
||||
// `kind: job` in conusai.json is inert at deploy time: one `docker compose up -d --wait`
|
||||
// starts everything at once, and `pre_deploy_command` has no executor at all. This is the
|
||||
// ordering (decision AT-D14).
|
||||
expect(services.migrate?.restart).toBe("no")
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
expect(services[name]?.depends_on?.migrate).toEqual({ condition: "service_completed_successfully" })
|
||||
}
|
||||
})
|
||||
|
||||
it("publishes no host port", () => {
|
||||
for (const service of Object.values(services)) expect(service.ports).toBeUndefined()
|
||||
expect(services.web?.expose).toEqual(["3000"])
|
||||
})
|
||||
|
||||
it("gives every service a limit, and the two long-running ones a healthcheck", () => {
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
expect(service.deploy?.resources?.limits?.memory, `${name} has no memory limit`).toBeTruthy()
|
||||
expect(service.deploy?.resources?.limits?.cpus, `${name} has no cpu limit`).toBeTruthy()
|
||||
expect(service.init, `${name} does not reap its children`).toBe(true)
|
||||
}
|
||||
expect(services.web?.healthcheck?.test).toBeTruthy()
|
||||
expect(services.worker?.healthcheck?.test).toBeTruthy()
|
||||
// A job is not healthy or unhealthy; it succeeds or it fails.
|
||||
expect(services.migrate?.healthcheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it("hands the owner's URL to the migrate job alone", () => {
|
||||
expect(services.migrate?.environment?.POSTGRES_MIGRATE_URL).toBe("${POSTGRES_MIGRATE_URL}")
|
||||
expect(services.web?.environment?.POSTGRES_MIGRATE_URL).toBeUndefined()
|
||||
expect(services.worker?.environment?.POSTGRES_MIGRATE_URL).toBeUndefined()
|
||||
for (const name of ["web", "worker", "migrate"] as const) {
|
||||
expect(services[name]?.environment?.POSTGRES_URL).toBe("${POSTGRES_URL}")
|
||||
expect(services[name]?.environment?.POSTGRES_READER_URL).toBe("${POSTGRES_READER_URL}")
|
||||
}
|
||||
})
|
||||
|
||||
it("passes every variable each process reads through to its container", () => {
|
||||
expectPassthroughs(services)
|
||||
})
|
||||
})
|
||||
|
||||
describe("profiles/mariadb/compose.yaml", () => {
|
||||
const profile = parse(readFileSync(new URL("../profiles/mariadb/compose.yaml", import.meta.url), "utf8")) as {
|
||||
services: Record<string, Service>
|
||||
}
|
||||
|
||||
it("hands the one MariaDB login to every process and the Valkey queue to web and worker", () => {
|
||||
for (const name of ["web", "worker", "migrate"] as const) {
|
||||
expect(profile.services[name]?.environment?.MARIADB_URL).toBe("${MARIADB_URL}")
|
||||
}
|
||||
for (const name of ["web", "worker"] as const) {
|
||||
expect(profile.services[name]?.environment?.REDIS_URL).toBe("${REDIS_URL}")
|
||||
}
|
||||
// MariaDB has no row-level security: there is no owner/runtime/reader split to hand out.
|
||||
expect(profile.services.migrate?.environment?.POSTGRES_MIGRATE_URL).toBeUndefined()
|
||||
})
|
||||
|
||||
it("passes every variable each process reads through to its container", () => {
|
||||
expectPassthroughs(profile.services)
|
||||
})
|
||||
})
|
||||
|
||||
for (const name of ["mongodb", "convex"] as const) {
|
||||
describe(`profiles/${name}/compose.yaml`, () => {
|
||||
const profile = parse(readFileSync(new URL(`../profiles/${name}/compose.yaml`, import.meta.url), "utf8")) as {
|
||||
services: Record<string, Service>
|
||||
}
|
||||
|
||||
it("passes the platform billing slug and web auth settings through to the containers", () => {
|
||||
for (const service of ["web", "worker"] as const) {
|
||||
if (profile.services[service]) {
|
||||
expect(profile.services[service]?.environment?.CONUSAI_ORG_SLUG).toBe(
|
||||
name === "convex" ? `\${CONUSAI_ORG_SLUG}` : optional("CONUSAI_ORG_SLUG"),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const variable of [
|
||||
"OIDC_WEB_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE",
|
||||
"OIDC_INTROSPECTION_CLIENT_ID",
|
||||
"OIDC_INTROSPECTION_CLIENT_SECRET",
|
||||
"OIDC_RESOURCE_STATUS_URL",
|
||||
"OIDC_SCOPES",
|
||||
"AUTH_PROVIDER_GENERATION",
|
||||
"AUTH_COOKIE_NAME",
|
||||
"AUTH_SESSION_IDLE_SECONDS",
|
||||
"AUTH_SESSION_ABSOLUTE_SECONDS",
|
||||
]) {
|
||||
expect(profile.services.web?.environment?.[variable], `web ${variable}`).toBe(optional(variable))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("the local stack", () => {
|
||||
const dev = readFileSync(new URL("../compose.dev.yaml", import.meta.url), "utf8")
|
||||
const roles = readFileSync(new URL("../dev/postgres-roles.sql", import.meta.url), "utf8")
|
||||
const example = readFileSync(new URL("../.env.example", import.meta.url), "utf8")
|
||||
|
||||
it("creates the three roles the platform creates, with the same powers", () => {
|
||||
for (const role of ["appdev_owner", "appdev_app", "appdev_reader"]) {
|
||||
expect(roles, `${role} is not created`).toMatch(new RegExp(`CREATE ROLE ${role}\\s+LOGIN`))
|
||||
}
|
||||
// The three are LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT NOREPLICATION,
|
||||
// as data-services DS21 creates them.
|
||||
expect(roles.match(/NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT NOREPLICATION/g)).toHaveLength(3)
|
||||
// The owner owns schema public; the runtime role gets rows, never ownership.
|
||||
expect(roles).toContain('ALTER SCHEMA public OWNER TO "appdev_owner"')
|
||||
expect(roles).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "appdev_app"')
|
||||
// The reader gets no table privilege here: the app's own migrations grant it what it reads.
|
||||
expect(roles).not.toMatch(/GRANT SELECT ON (ALL )?TABLES? [^;]*"appdev_reader"/)
|
||||
})
|
||||
|
||||
it("hands each local process the same URL the platform would", () => {
|
||||
expect(example).toContain("POSTGRES_URL=postgresql://appdev_app:")
|
||||
expect(example).toContain("POSTGRES_READER_URL=postgresql://appdev_reader:")
|
||||
expect(example).toContain("POSTGRES_MIGRATE_URL=postgresql://appdev_owner:")
|
||||
})
|
||||
|
||||
it("keeps the development stack out of what is deployed", () => {
|
||||
// conusai.json names compose.yaml alone, so nothing in compose.dev.yaml is ever deployed.
|
||||
expect(dev).toContain("image: pgvector/pgvector:pg18")
|
||||
expect(readFileSync(new URL("../conusai.json", import.meta.url), "utf8")).not.toContain("compose.dev.yaml")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user