Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,10 @@ skills/*/references/*.md
CHANGELOG.md
docs/i18n/*/CHANGELOG.md

# Helm templates are Go text/template, not YAML: `{{- if ... }}` at the start of
# a line is a parse error for Prettier's YAML parser. Helm itself is the
# formatter of record here — `helm lint` and `helm template` validate them.
deploy/helm/*/templates/

# packages/ui-kit/ — vendored snapshot of the CI UI Kit; synced verbatim by scripts/dev/sync-ui-kit.sh, formatted upstream.
packages/ui-kit/src/**
250 changes: 250 additions & 0 deletions bin/cli/commands/deploy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* `omniroute deploy` — Kubernetes manifests and cluster provisioning commands.
*
* Both subcommands only WRITE TO STDOUT. Nothing here touches a cluster, runs
* kubectl, or connects to a server: provisioning a cluster is a privileged
* host-level operation, and doing it from a long-running proxy would mean
* shipping an SSH client and holding a private key. You pipe the output where
* you want it:
*
* omniroute deploy k8s --target local | kubectl apply -f -
* omniroute deploy k8s provision --target vps --server 203.0.113.10
*
* The generation logic lives in src/lib/deploy/kubernetes/ (TypeScript, unit
* tested); this file is the argument surface. Dynamic .ts imports work because
* bin/omniroute.mjs registers tsx before dispatching.
*/

const VALID_TARGETS = new Set(["local", "vps"]);
const VALID_LIVENESS = new Set(["http", "tcp"]);
const VALID_PULL_POLICIES = new Set(["Always", "IfNotPresent", "Never"]);

function fail(message) {
process.stderr.write(`error: ${message}\n`);
process.exit(2);
}

function parseTarget(value) {
if (!VALID_TARGETS.has(value)) {
fail(`--target must be "local" or "vps" (got ${JSON.stringify(value)})`);
}
return value;
}

/** Apply CLI flags on top of the baseline spec for the chosen target. */
function applyOverrides(spec, opts) {
const next = {
...spec,
image: { ...spec.image },
ingress: { ...spec.ingress },
storage: { ...spec.storage },
resources: { ...spec.resources },
config: { ...spec.config },
};

if (opts.namespace) next.namespace = opts.namespace;
if (opts.release) next.releaseName = opts.release;
if (opts.image) next.image.repository = opts.image;
if (opts.tag) next.image.tag = opts.tag;
if (opts.pullPolicy) {
if (!VALID_PULL_POLICIES.has(opts.pullPolicy)) {
fail(`--pull-policy must be one of ${[...VALID_PULL_POLICIES].join(", ")}`);
}
next.image.pullPolicy = opts.pullPolicy;
}

if (opts.host) next.ingress.host = opts.host;
if (opts.ingressClass !== undefined) {
next.ingress.className = opts.ingressClass || undefined;
}
// commander maps --no-ingress to ingress:false and --no-tls to tls:false.
if (opts.ingress === false) {
next.ingress.enabled = false;
// TLS is terminated AT the Ingress, so it cannot outlive it. Without this,
// `--no-ingress` on the vps profile (which defaults TLS on) would render an
// invalid spec and fail with a confusing message about a flag not passed.
next.ingress.tlsEnabled = false;
}
if (opts.tls === false) next.ingress.tlsEnabled = false;
if (opts.tls === true && next.ingress.enabled) next.ingress.tlsEnabled = true;
if (opts.tlsSecret) next.ingress.tlsSecretName = opts.tlsSecret;

if (opts.storageClass !== undefined) {
next.storage.className = opts.storageClass || undefined;
}
if (opts.storageSize) next.storage.size = opts.storageSize;
if (opts.secretName) next.secretName = opts.secretName;

if (opts.requestsCpu) next.resources.requestsCpu = opts.requestsCpu;
if (opts.requestsMemory) next.resources.requestsMemory = opts.requestsMemory;
if (opts.limitsCpu) next.resources.limitsCpu = opts.limitsCpu;
if (opts.limitsMemory) next.resources.limitsMemory = opts.limitsMemory;
if (opts.maxOldSpace) {
const parsed = Number.parseInt(opts.maxOldSpace, 10);
if (!Number.isFinite(parsed)) fail("--max-old-space must be a number of MiB");
next.config.maxOldSpaceSizeMb = parsed;
}

if (opts.requireApiKey === true) next.config.requireApiKey = true;
if (opts.requireApiKey === false) next.config.requireApiKey = false;
if (opts.logLevel) next.config.logLevel = opts.logLevel;
if (opts.wsOrigins !== undefined) next.config.liveWsAllowedOrigins = opts.wsOrigins;

if (opts.liveness) {
if (!VALID_LIVENESS.has(opts.liveness)) fail('--liveness must be "http" or "tcp"');
next.livenessProbe = opts.liveness;
}

// A hostname with no explicit origins would otherwise leave the dashboard's
// live WebSocket rejecting the very host it is served from.
if (next.ingress.enabled && next.ingress.host && !next.config.liveWsAllowedOrigins) {
const scheme = next.ingress.tlsEnabled ? "https" : "http";
next.config.liveWsAllowedOrigins = `${scheme}://${next.ingress.host}`;
}

return next;
}

export async function runManifestsCommand(opts) {
const target = parseTarget(opts.target ?? "local");
const { defaultSpec, renderManifests } =
await import("../../../src/lib/deploy/kubernetes/manifestGenerator.ts");
const { deploySpecSchema } = await import("../../../src/lib/deploy/kubernetes/types.ts");

const spec = applyOverrides(defaultSpec(target), opts);
const parsed = deploySpecSchema.safeParse(spec);

if (!parsed.success) {
process.stderr.write("error: the deploy spec is incomplete or invalid:\n");
for (const issue of parsed.error.issues) {
process.stderr.write(` ${issue.path.join(".") || "spec"}: ${issue.message}\n`);
}
if (parsed.error.issues.some((i) => i.path.join(".") === "ingress.host")) {
process.stderr.write("\nhint: pass --host <hostname>, or --no-ingress to skip it.\n");
}
process.exit(2);
}

if (opts.json) {
process.stdout.write(`${JSON.stringify(parsed.data, null, 2)}\n`);
return 0;
}

process.stdout.write(renderManifests(parsed.data));
return 0;
}

export async function runProvisionCommand(opts) {
const target = parseTarget(opts.target ?? "local");
const {
buildProvisionPlan,
isSafeServerAddress,
isSafeVersion,
isSafeContextName,
isSafeSshUser,
} = await import("../../../src/lib/deploy/kubernetes/provisionPlan.ts");

// These values are rendered into commands a human then runs. A shell
// metacharacter would make the printed command mean something other than
// what it looks like, so reject rather than escape.
if (opts.server && !isSafeServerAddress(opts.server)) {
fail("--server must be a plain hostname or IP address");
}
if (opts.k3sVersion && !isSafeVersion(opts.k3sVersion)) {
fail("--k3s-version must look like v1.31.2+k3s1");
}
if (opts.sshUser && !isSafeSshUser(opts.sshUser)) {
fail("--ssh-user must be a plain username");
}
if (opts.context && !isSafeContextName(opts.context)) {
fail("--context must be a plain kubeconfig context name");
}

const plan = buildProvisionPlan({
target,
serverAddress: opts.server,
k3sVersion: opts.k3sVersion,
sshUser: opts.sshUser,
contextName: opts.context,
});

if (opts.json) {
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return 0;
}

const out = [];
out.push(`# Provision a k3s cluster — target: ${plan.target}`, "");
for (const warning of plan.warnings) out.push(`# ! ${warning}`);
out.push("");

plan.steps.forEach((step, index) => {
const where = step.location === "server" ? "on the server" : "on your machine";
const tags = [where];
if (step.privileged) tags.push("needs root");
if (step.optional) tags.push("optional");
out.push(`# ${index + 1}. ${step.title} [${tags.join(", ")}]`);
out.push(`# ${step.rationale}`);
out.push(step.command, "");
});

process.stdout.write(`${out.join("\n")}\n`);
return 0;
}

export function registerDeploy(program) {
const deploy = program.command("deploy").description("Generate deployment artifacts");

const k8s = deploy
.command("k8s")
.alias("kubernetes")
.description("Print Kubernetes manifests for this instance on stdout")
.option("--target <target>", "local or vps", "local")
.option("--namespace <name>", "Kubernetes namespace")
.option("--release <name>", "release name used for every object")
.option("--image <repository>", "container image repository")
.option("--tag <tag>", "container image tag")
.option("--pull-policy <policy>", "Always, IfNotPresent or Never")
.option("--host <hostname>", "Ingress hostname")
.option("--ingress-class <name>", "Ingress class (traefik, nginx, …)")
.option("--no-ingress", "do not generate an Ingress")
.option("--tls", "terminate TLS at the Ingress")
.option("--no-tls", "do not terminate TLS at the Ingress")
.option("--tls-secret <name>", "existing TLS Secret name")
.option("--storage-class <name>", "StorageClass (empty for the cluster default)")
.option("--storage-size <size>", "volume size, e.g. 20Gi")
.option("--secret-name <name>", "existing Secret holding JWT_SECRET and friends")
.option("--requests-cpu <cpu>", "CPU request")
.option("--requests-memory <memory>", "memory request")
.option("--limits-cpu <cpu>", "CPU limit")
.option("--limits-memory <memory>", "memory limit")
.option("--max-old-space <mib>", "V8 heap ceiling in MiB (must be below the memory limit)")
.option("--require-api-key", "require an API key on /v1/*")
.option("--no-require-api-key", "do not require an API key on /v1/*")
.option("--log-level <level>", "trace, debug, info, warn, error or fatal")
.option("--ws-origins <origins>", "comma-separated allowed WebSocket origins")
.option("--liveness <kind>", "liveness probe: tcp (default) or http")
.option("--json", "print the resolved spec as JSON instead of YAML")
.action(async (opts) => {
const code = await runManifestsCommand(opts);
if (code !== 0) process.exit(code);
});

k8s
.command("provision")
.description("Print the commands for standing up a k3s cluster (never runs them)")
// No --target here on purpose: the parent `k8s` command already declares it,
// and commander binds the flag to the parent, leaving a duplicate on the
// subcommand permanently stuck at its own default. optsWithGlobals() reads
// the inherited value instead, and accepts the flag on either side of
// `provision`.
.option("--server <address>", "public address of the VPS (with --target vps)")
.option("--k3s-version <version>", "pin a k3s version, e.g. v1.31.2+k3s1")
.option("--ssh-user <user>", "SSH user rendered into the commands", "root")
.option("--context <name>", "kubeconfig context name for the cluster")
.option("--json", "print the plan as JSON")
.action(async (opts, cmd) => {
const code = await runProvisionCommand({ ...cmd.optsWithGlobals(), ...opts });
if (code !== 0) process.exit(code);
});
}
2 changes: 2 additions & 0 deletions bin/cli/commands/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { registerStop } from "./stop.mjs";
import { registerRestart } from "./restart.mjs";
import { registerDashboard } from "./dashboard.mjs";
import { registerDoctor } from "./doctor.mjs";
import { registerDeploy } from "./deploy.mjs";
import { registerSetup } from "./setup.mjs";
import { registerProviders } from "./providers.mjs";
import { registerProvider } from "./provider-cmd.mjs";
Expand Down Expand Up @@ -122,6 +123,7 @@ export function registerCommands(program) {
registerRestart(program);
registerDashboard(program);
registerDoctor(program);
registerDeploy(program);
registerSetup(program);
registerProviders(program);
registerProvider(program);
Expand Down
10 changes: 9 additions & 1 deletion bin/omniroute.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,15 @@ if (shouldProvisionStorageKey(process.argv)) {
// init) can themselves log during evaluation. Redirecting after those imports let
// early output leak straight into the JSON-RPC stream and corrupt it client-side
// (e.g. Claude Desktop: "Unexpected token 'D', \"[DB] Changi\"... is not valid JSON").
if (process.argv.includes("--mcp")) {
//
// `deploy` needs the same treatment for the same reason: it prints Kubernetes
// YAML (or JSON) on stdout and is meant to be piped —
// `omniroute deploy k8s | kubectl apply -f -`. Env-loading notices and DB
// warnings on stdout make that YAML unparseable ("control characters are not
// allowed"), so they belong on stderr where a human still sees them.
const STDOUT_IS_DATA = process.argv.includes("--mcp") || process.argv[2] === "deploy";

if (STDOUT_IS_DATA) {
const { Console } = await import("node:console");
const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr });
console.log = stderrConsole.log.bind(stderrConsole);
Expand Down
11 changes: 11 additions & 0 deletions changelog.d/features/kubernetes-deploy-manifests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- **feat(deploy):** first-class Kubernetes support — Kustomize base with k3s (Traefik +
`local-path`) and generic k8s (ingress-nginx) overlays under `deploy/kubernetes/`, plus a Helm
chart at `deploy/helm/omniroute/`. Both pin the single-SQLite-writer topology (`replicas: 1`,
`Recreate`, ReadWriteOnce) and the chart refuses to render a configuration that would corrupt
the database. Ingress defaults keep SSE unbuffered, and the probe defaults account for the
synchronous SQLite driver: liveness is `tcpSocket` (an HTTP probe reads a checkpoint or VACUUM
as a dead process and restarts the pod mid-write), with a 10-minute startup budget and a
300s termination grace period for the unbounded WAL checkpoint on shutdown. New `omniroute deploy k8s` prints the same
manifests built from a running instance's settings, and `omniroute deploy k8s provision` prints
the commands for standing up a k3s cluster locally or on a VPS — OmniRoute never runs them and
never connects to the server. Guide: `docs/ops/KUBERNETES_DEPLOYMENT_GUIDE.md`.
5 changes: 5 additions & 0 deletions deploy/helm/omniroute/.helmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.DS_Store
.git/
.gitignore
*.tmp
*.bak
20 changes: 20 additions & 0 deletions deploy/helm/omniroute/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
apiVersion: v2
name: omniroute
description: OmniRoute — unified AI proxy/router. One endpoint, many LLM providers, auto-fallback.
type: application
# Chart version — bump on every chart change.
version: 0.1.0
# Default image tag. Overridable with image.tag.
appVersion: "latest"
home: https://github.com/diegosouzapw/OmniRoute
sources:
- https://github.com/diegosouzapw/OmniRoute
keywords:
- llm
- proxy
- router
- openai
- ai-gateway
maintainers:
- name: OmniRoute contributors
url: https://github.com/diegosouzapw/OmniRoute
33 changes: 33 additions & 0 deletions deploy/helm/omniroute/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
OmniRoute {{ .Chart.AppVersion }} installed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".

Watch it come up (cold start runs SQLite migrations and can take a minute):

kubectl -n {{ .Release.Namespace }} rollout status deploy/{{ include "omniroute.fullname" . }}

{{- if .Values.ingress.enabled }}

Reachable at:
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else }}

No Ingress is enabled. Reach the dashboard over a port-forward:

kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "omniroute.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}

Then open http://localhost:{{ .Values.service.port }}
{{- end }}

Before putting this in front of anyone else:

1. Log in and change the initial admin password immediately.
{{- if not .Values.config.requireApiKey }}
2. config.requireApiKey is false — /v1/* accepts unauthenticated requests.
Set it to true before the Service is reachable outside the cluster.
{{- end }}
3. Set config.liveWsAllowedOrigins to your real host, or the dashboard's
live WebSocket will be rejected by the origin check.

Upgrades restart the only pod: every in-flight SSE stream and dashboard session
drops. That is inherent to the single-SQLite-writer topology, not a bug.
Loading
Loading