1use std::fmt::Write as _;
19use std::path::Path;
20
21#[derive(Debug, Clone)]
23pub struct InitOptions {
24 pub org_label: String,
25 pub seats: Option<u32>,
26 pub reference_model: Option<String>,
27 pub persons: Vec<String>,
29 pub proxy_port: u16,
30 pub admin_port: u16,
31}
32
33impl Default for InitOptions {
34 fn default() -> Self {
35 Self {
36 org_label: String::new(),
37 seats: None,
38 reference_model: None,
39 persons: Vec::new(),
40 proxy_port: 8484,
41 admin_port: 8485,
42 }
43 }
44}
45
46#[derive(Debug)]
48pub struct InitOutcome {
49 pub files: Vec<String>,
50 pub person_keys: Vec<(String, String)>,
52}
53
54pub fn run(dir: &Path, opts: &InitOptions) -> anyhow::Result<InitOutcome> {
59 std::fs::create_dir_all(dir)?;
60 for name in [
61 "config.toml",
62 ".env",
63 "docker-compose.yml",
64 "README.md",
65 "gateway-keys.toml",
66 ] {
67 anyhow::ensure!(
68 !dir.join(name).exists(),
69 "{} already exists — `gateway init` never overwrites an instance \
70 (delete the file or choose another directory)",
71 dir.join(name).display()
72 );
73 }
74
75 let proxy_token = random_token()?;
76 let admin_token = random_token()?;
77 let pg_password = random_token()?;
78
79 let mut files = Vec::new();
80 write_file(dir, &mut files, "config.toml", &render_config(opts), false)?;
81 write_file(
82 dir,
83 &mut files,
84 ".env",
85 &render_env(&proxy_token, &admin_token, &pg_password),
86 true,
87 )?;
88 write_file(
89 dir,
90 &mut files,
91 "docker-compose.yml",
92 &render_compose(opts),
93 false,
94 )?;
95 write_file(dir, &mut files, ".gitignore", ".env\n", false)?;
96
97 let mut person_keys = Vec::new();
99 let keys_path = dir.join("gateway-keys.toml");
100 for person in &opts.persons {
101 let key = super::keys_cli::add_key(&keys_path, person, None, None, false)?;
102 person_keys.push((person.clone(), key));
103 }
104 if person_keys.is_empty() {
105 super::keys_cli::write_empty(&keys_path)?;
107 }
108 files.push("gateway-keys.toml".to_string());
109
110 write_file(dir, &mut files, "README.md", &render_readme(opts), false)?;
111
112 Ok(InitOutcome { files, person_keys })
113}
114
115fn write_file(
116 dir: &Path,
117 files: &mut Vec<String>,
118 name: &str,
119 contents: &str,
120 secret: bool,
121) -> anyhow::Result<()> {
122 let path = dir.join(name);
123 std::fs::write(&path, contents)?;
124 #[cfg(unix)]
125 if secret {
126 use std::os::unix::fs::PermissionsExt;
127 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
128 }
129 let _ = secret; files.push(name.to_string());
131 Ok(())
132}
133
134fn random_token() -> anyhow::Result<String> {
136 let mut buf = [0u8; 32];
137 getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("CSPRNG unavailable: {e}"))?;
138 Ok(buf.iter().fold(String::new(), |mut acc, b| {
139 let _ = write!(acc, "{b:02x}");
140 acc
141 }))
142}
143
144fn render_config(opts: &InitOptions) -> String {
145 let mut out = String::from(
146 "# lean-ctx gateway configuration — generated by `lean-ctx gateway init`.\n\
147 # Secrets never live here: tokens and DATABASE_URL come from .env.\n\n\
148 # Bind beyond loopback (the container/K8s case) and require Bearer auth.\n\
149 proxy_bind_host = \"0.0.0.0\"\n\
150 proxy_require_token = true\n",
151 );
152 let _ = writeln!(out, "\n[gateway_server]");
153 let _ = writeln!(out, "admin_bind_host = \"0.0.0.0\"");
157 if let Some(seats) = opts.seats {
158 let _ = writeln!(out, "seats = {seats}");
159 }
160 if !opts.org_label.is_empty() {
161 let _ = writeln!(
162 out,
163 "org_label = \"{}\"",
164 opts.org_label.replace('"', "\\\"")
165 );
166 }
167 if let Some(reference) = opts
168 .reference_model
169 .as_deref()
170 .map(str::trim)
171 .filter(|m| !m.is_empty())
172 {
173 let _ = writeln!(
174 out,
175 "\n# Counterfactual baseline: what the org would have paid without lean-ctx.\n\
176 [proxy.baseline]\nreference_model = \"{reference}\""
177 );
178 }
179 out.push_str(
180 "\n# Registry providers (optional). Built-in routes work without any entry:\n\
181 # /anthropic/… /openai/… /gemini/…\n\
182 # Add self-hosted or Foundry endpoints like this:\n\
183 # [[proxy.providers]]\n\
184 # id = \"local\"\n\
185 # shape = \"openai\"\n\
186 # base_url = \"http://host.docker.internal:11434\"\n\
187 # local = true # billed at the shadow rate, not cloud list prices\n\
188 # # plain-HTTP non-loopback upstream additionally needs:\n\
189 # # [proxy] allow_insecure_http_upstream = true\n\
190 #\n\
191 # [[proxy.providers]]\n\
192 # id = \"foundry\"\n\
193 # shape = \"openai\"\n\
194 # base_url = \"https://<resource>.services.ai.azure.com/models\"\n\
195 # api_key_env = \"FOUNDRY_API_KEY\"\n\
196 \n\
197 # Active routing (optional): aliases + tier targets, see docs/reference/05-advanced.md.\n\
198 # Aliases are your org's model namespace — clients discover them via\n\
199 # GET /v1/models on the proxy port and select them by name in the IDE:\n\
200 # [proxy.routing]\n\
201 # enabled = true\n\
202 # [proxy.routing.aliases]\n\
203 # \"acme/fast\" = \"foundry:gpt-4o-mini\" # org name -> provider:model\n\
204 # \"acme/local\" = \"local:llama3.3\" # local target (shadow rate)\n",
205 );
206 out
207}
208
209fn render_env(proxy_token: &str, admin_token: &str, pg_password: &str) -> String {
210 format!(
211 "# Generated secrets — keep out of git (the generated .gitignore covers this file).\n\
212 # Rotate by editing here and `docker compose up -d` (containers restart with new values).\n\
213 LEAN_CTX_PROXY_TOKEN={proxy_token}\n\
214 LEAN_CTX_GATEWAY_ADMIN_TOKEN={admin_token}\n\
215 POSTGRES_PASSWORD={pg_password}\n\
216 DATABASE_URL=postgres://leanctx:{pg_password}@postgres:5432/leanctx\n"
217 )
218}
219
220fn render_compose(opts: &InitOptions) -> String {
221 format!(
222 r#"# lean-ctx gateway — pilot deployment (single host, docker compose).
223# Production path: the lean-ctx-gateway Helm chart (see deploy template repo).
224services:
225 postgres:
226 image: postgres:17-alpine
227 environment:
228 POSTGRES_USER: leanctx
229 POSTGRES_PASSWORD: ${{POSTGRES_PASSWORD}}
230 POSTGRES_DB: leanctx
231 volumes:
232 - pgdata:/var/lib/postgresql/data
233 healthcheck:
234 test: ["CMD-SHELL", "pg_isready -U leanctx -d leanctx"]
235 interval: 5s
236 timeout: 3s
237 retries: 10
238 restart: unless-stopped
239
240 gateway:
241 image: ${{LEANCTX_IMAGE:-lean-ctx-gateway:latest}}
242 depends_on:
243 postgres:
244 condition: service_healthy
245 environment:
246 LEAN_CTX_PROXY_TOKEN: ${{LEAN_CTX_PROXY_TOKEN}}
247 LEAN_CTX_GATEWAY_ADMIN_TOKEN: ${{LEAN_CTX_GATEWAY_ADMIN_TOKEN}}
248 DATABASE_URL: ${{DATABASE_URL}}
249 volumes:
250 - ./config.toml:/etc/lean-ctx/config.toml:ro
251 - ./gateway-keys.toml:/etc/lean-ctx/gateway-keys.toml:ro
252 ports:
253 - "{proxy_port}:8484" # proxy — the surface clients use
254 - "127.0.0.1:{admin_port}:8485" # admin console — host-local only
255 restart: unless-stopped
256
257volumes:
258 pgdata:
259"#,
260 proxy_port = opts.proxy_port,
261 admin_port = opts.admin_port,
262 )
263}
264
265fn render_readme(opts: &InitOptions) -> String {
266 let org = if opts.org_label.is_empty() {
267 "your org"
268 } else {
269 &opts.org_label
270 };
271 format!(
272 r"# lean-ctx gateway — {org}
273
274Generated by `lean-ctx gateway init`. Three steps to a running gateway:
275
276## 1. Start
277
278```bash
279docker compose up -d
280```
281
282(The image comes from `LEANCTX_IMAGE`, default `lean-ctx-gateway:latest` — build it
283with `docker build -f docker/Dockerfile.gateway -t lean-ctx-gateway:latest .` from
284the engine repo, or point `LEANCTX_IMAGE` at your registry.)
285
286## 2. Verify
287
288```bash
289lean-ctx gateway doctor --dir . # preflight: config, secrets, DB, ports
290curl -s http://127.0.0.1:{proxy_port}/health # proxy liveness
291open http://127.0.0.1:{admin_port}/ # admin console (token: LEAN_CTX_GATEWAY_ADMIN_TOKEN in .env)
292```
293
294## 3. Hand out keys
295
296```bash
297lean-ctx gateway keys add --person alice@example.com --team platform --project checkout --file gateway-keys.toml
298lean-ctx gateway keys rotate --person alice@example.com --file gateway-keys.toml # compromised/expiring key: one atomic step
299docker compose restart gateway # reload the key set
300```
301
302Point clients at the proxy:
303
304```bash
305export ANTHROPIC_BASE_URL=http://<host>:{proxy_port}/anthropic
306export ANTHROPIC_AUTH_TOKEN=<the person's gk-… key>
307```
308
309Model catalog and personal view (each person uses their own key):
310
311```bash
312curl -s -H 'Authorization: Bearer <gk-… key>' http://<host>:{proxy_port}/v1/models # org model aliases
313open http://<host>:{proxy_port}/me # personal usage dashboard
314```
315
316## Files
317
318| File | Purpose | Committable |
319|---|---|---|
320| `config.toml` | engine config (org, baseline, providers, routing) | yes |
321| `docker-compose.yml` | pilot deployment | yes |
322| `gateway-keys.toml` | SHA-256 key hashes (no plaintext) | yes |
323| `.env` | generated secrets | **no** (gitignored) |
324",
325 proxy_port = opts.proxy_port,
326 admin_port = opts.admin_port,
327 )
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn opts() -> InitOptions {
335 InitOptions {
336 org_label: "Zühlke Engineering AG".into(),
337 seats: Some(800),
338 reference_model: Some("claude-opus-4.5".into()),
339 persons: vec!["alice@zuehlke.com".into(), "bob@zuehlke.com".into()],
340 proxy_port: 8484,
341 admin_port: 8485,
342 }
343 }
344
345 #[test]
346 fn init_creates_complete_runnable_instance() {
347 let tmp = tempfile::tempdir().unwrap();
348 let dir = tmp.path().join("gw");
349 let outcome = run(&dir, &opts()).unwrap();
350
351 for f in [
352 "config.toml",
353 ".env",
354 "docker-compose.yml",
355 "README.md",
356 "gateway-keys.toml",
357 ".gitignore",
358 ] {
359 assert!(dir.join(f).exists(), "missing {f}");
360 }
361
362 let cfg = std::fs::read_to_string(dir.join("config.toml")).unwrap();
364 let parsed: toml::Value = toml::from_str(&cfg).expect("generated config must parse");
365 assert_eq!(
366 parsed["gateway_server"]["org_label"].as_str(),
367 Some("Zühlke Engineering AG")
368 );
369 assert_eq!(parsed["gateway_server"]["seats"].as_integer(), Some(800));
370 assert_eq!(
371 parsed["proxy"]["baseline"]["reference_model"].as_str(),
372 Some("claude-opus-4.5")
373 );
374 assert_eq!(parsed["proxy_bind_host"].as_str(), Some("0.0.0.0"));
375 assert_eq!(parsed["proxy_require_token"].as_bool(), Some(true));
376
377 let env = std::fs::read_to_string(dir.join(".env")).unwrap();
379 let token_of = |name: &str| {
380 env.lines()
381 .find_map(|l| l.strip_prefix(&format!("{name}=")))
382 .map(str::to_string)
383 .unwrap_or_default()
384 };
385 let proxy_token = token_of("LEAN_CTX_PROXY_TOKEN");
386 assert_eq!(proxy_token.len(), 64);
387 assert!(env.contains(&format!(
388 "DATABASE_URL=postgres://leanctx:{}@postgres:5432/leanctx",
389 token_of("POSTGRES_PASSWORD")
390 )));
391 assert!(!cfg.contains(&proxy_token));
392 let compose = std::fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
393 assert!(!compose.contains(&proxy_token));
394 assert!(compose.contains("service_healthy"));
395
396 assert_eq!(outcome.person_keys.len(), 2);
398 let keys =
399 crate::proxy::gateway_identity::GatewayKeys::load(&dir.join("gateway-keys.toml"))
400 .unwrap();
401 for (person, key) in &outcome.person_keys {
402 let tags = keys.lookup(key).expect("generated key resolves");
403 assert_eq!(tags.person.as_deref(), Some(person.as_str()));
404 }
405
406 #[cfg(unix)]
408 {
409 use std::os::unix::fs::PermissionsExt;
410 let mode = std::fs::metadata(dir.join(".env"))
411 .unwrap()
412 .permissions()
413 .mode();
414 assert_eq!(mode & 0o777, 0o600);
415 }
416 }
417
418 #[test]
419 fn init_refuses_to_overwrite_existing_instance() {
420 let tmp = tempfile::tempdir().unwrap();
421 let dir = tmp.path().join("gw");
422 run(&dir, &InitOptions::default()).unwrap();
423 let err = run(&dir, &InitOptions::default()).unwrap_err();
424 assert!(
425 err.to_string().contains("never overwrites"),
426 "second init must refuse: {err}"
427 );
428 }
429
430 #[test]
431 fn tokens_are_distinct_per_init() {
432 let tmp = tempfile::tempdir().unwrap();
433 run(&tmp.path().join("a"), &InitOptions::default()).unwrap();
434 run(&tmp.path().join("b"), &InitOptions::default()).unwrap();
435 let env_a = std::fs::read_to_string(tmp.path().join("a/.env")).unwrap();
436 let env_b = std::fs::read_to_string(tmp.path().join("b/.env")).unwrap();
437 let first_line = |s: &str| {
438 s.lines()
439 .find(|l| l.starts_with("LEAN_CTX_PROXY_TOKEN"))
440 .unwrap()
441 .to_string()
442 };
443 assert_ne!(first_line(&env_a), first_line(&env_b));
444 }
445}