1use std::time::Duration;
5
6mod delete;
7pub(crate) mod manifest;
8
9pub(super) use delete::{delete_tools, empty_org};
10
11use serde::Deserialize;
12use serde_json::{Value, json};
13
14use super::{args, caller_name, obj};
15use crate::app::deploy::Trigger;
16use crate::app::{App, AppSpec, Apps, EnvValue};
17use crate::error::{Error, Result};
18use crate::org::OrgId;
19use crate::server::{Caller, Registry, Tool};
20
21pub fn webhook_path(org: &OrgId, app: &str) -> String {
23 format!("/api/v1/webhooks/{org}/{app}")
24}
25
26fn org_of(a: &Value) -> Result<OrgId> {
27 super::arg_org(a)
28}
29
30pub(super) fn trigger(c: &Caller) -> Trigger {
31 if c.is_local() {
33 Trigger::Manual
34 } else {
35 Trigger::Api
36 }
37}
38
39pub fn app_json(org: &OrgId, a: &App) -> Value {
42 let mut v = serde_json::to_value(&a.spec).unwrap_or_default();
43 let stack = a.spec.stack().unwrap_or_default();
44 let vars: serde_json::Map<String, Value> = a
45 .spec
46 .env
47 .vars()
48 .map(|(k, v)| {
49 (
50 k.to_string(),
51 match v {
52 EnvValue::Plain(s) => json!(s),
53 EnvValue::Secret { secret } => json!({"secret": secret}),
54 },
55 )
56 })
57 .collect();
58 let o = v.as_object_mut().expect("an app is an object");
59 o.insert("env_vars".into(), Value::Object(vars));
60 o.insert("stack".into(), json!(stack));
61 o.insert(
62 "service_name".into(),
63 json!(format!("{}.{stack}", a.spec.name)),
64 );
65 o.insert("current_deployment".into(), json!(a.current));
66 o.insert("created_at".into(), json!(a.created_at));
67 o.insert("updated_at".into(), json!(a.updated_at));
68 o.insert("webhook".into(), json!(webhook_path(org, &a.spec.name)));
69 o.insert(
70 "domains_served".into(),
71 json!(crate::app::compose_takes_domains()),
72 );
73 if let crate::app::Source::Database(db) = &a.spec.source {
74 o.insert(
75 "connection".into(),
76 crate::app::database::connection(&a.spec, db, org, None),
77 );
78 }
79 v
80}
81
82pub const NO_INGRESS_WARNING: &str = "This server has no ingress, so domains aren't served. A platform admin starts isb serve with --ingress-https (or a Cloudflare Tunnel for the org).";
85
86pub(super) fn note_ingress(app: &mut Value, ingress: bool) -> Option<&'static str> {
89 let has_domains = app
90 .get("domains")
91 .and_then(Value::as_array)
92 .is_some_and(|d| !d.is_empty());
93 if let Some(o) = app.as_object_mut() {
94 o.insert("ingress_enabled".into(), json!(ingress));
95 }
96 (!ingress && has_domains).then_some(NO_INGRESS_WARNING)
97}
98
99fn service_status(ap: &Apps, org: &OrgId, app: &App) -> Option<Value> {
102 let stack = app.spec.stack().ok()?;
103 let st = ap
104 .controller()
105 .status(&crate::stack::qualified(org, &stack))
106 .ok()?;
107 let s = st
108 .services
109 .into_iter()
110 .find(|s| s.service == app.spec.name)?;
111 let mut v = json!({"state": s.state, "replicas": s.replicas, "healthy": s.healthy});
112 if let Some(m) = s.message {
113 v["message"] = json!(m);
114 }
115 Some(v)
116}
117
118pub(super) fn warn(out: &mut Value, image: Option<String>, ingress: Option<&str>) {
120 let all: Vec<String> = image.into_iter().chain(ingress.map(String::from)).collect();
121 if !all.is_empty() {
122 out["warning"] = json!(all.join("\n"));
123 }
124}
125
126fn check_patched_image(
130 ap: &Apps,
131 org: &OrgId,
132 name: &str,
133 patch: &Value,
134) -> Result<Option<String>> {
135 let cur = ap.get(org, name)?;
136 let mut v = serde_json::to_value(&cur.spec)?;
137 crate::app::merge_patch(&mut v, patch);
138 match serde_json::from_value::<AppSpec>(v) {
139 Ok(next) => ap.check_image(Some(&cur.spec), &next),
140 Err(_) => Ok(None),
141 }
142}
143
144const APP_PROPS: &str = r#"{
145 "source": {"type": "object", "description": "Exactly one of {\"image\": IMAGE} or {\"git\": {\"url\", \"ref\" (branch, tag or SHA; default main), \"subdir\", \"auth\": {\"token_secret\": NAME, \"username\"} | {\"ssh_key_secret\": NAME}, \"submodules\": false}}. IMAGE always carries its registry's prefix: Docker Hub is docker:NAME[:TAG] or docker:OWNER/NAME[:TAG] (docker:nginx:1.27, docker:traefik/whoami; never docker:traefik:whoami, which is the tag whoami of the image traefik), then ghcr:OWNER/NAME[:TAG], quay:OWNER/NAME[:TAG], oci:HOST/PATH[:TAG], registry:APP[:TAG] for the org's own builds; a name with no prefix is an image already on the host. A registry image is looked up when saved: one the registry does not have is refused, one that cannot be checked (offline, private) is saved with a warning."},
146 "build": {"type": "object", "description": "Git sources only: {\"builder\": {\"type\": \"railpack\" | \"nixpacks\" | \"dockerfile\" (path, target) | \"buildpacks\" (builder)}, \"args\": {K: V}, \"untrusted\": true (build in a VM)}."},
147 "env": {"description": ".env text, or a map {KEY: \"value\" | {\"secret\": NAME}}. A secret is an org secret, delivered as the variable."},
148 "domains": {"type": "array", "items": {"type": "object"}, "description": "[{host, path?, port?, https?, redirect?}] for the ingress; port defaults to the app's port."},
149 "volumes": {"type": "array", "items": {"type": "string"}, "description": "Named volumes, NAME:/path[:ro]. No host paths."},
150 "ports": {"type": "array", "items": {"type": "string"}, "description": "Published host ports, compose syntax (127.0.0.1:8080:80), load-balanced over healthy replicas."},
151 "replicas": {"type": "integer", "minimum": 0, "maximum": 100},
152 "port": {"type": "integer", "minimum": 1, "maximum": 65535, "description": "The port the app listens on."},
153 "healthcheck": {"type": "object", "description": "A compose healthcheck: {test, interval, timeout, retries, start_period}."},
154 "command": {"description": "argv (a list) or a command line."},
155 "previews": {"type": "object", "description": "Preview deployments per pull request (git sources): {enabled, branches (base branches; default the app's ref), max (default 3), env (.env text or {KEY: value | {secret: NAME}}), inherit_env (default false), domain (auto | *.suffix), port, replicas (default 1), resources, ttl (e.g. 7d), forks (default false; fork PRs build in a VM and get only fork_secrets), fork_secrets [NAME], status {token_secret, kind: github | gitea, api_url}}. See preview_list."},
156 "files": {"type": "array", "items": {"type": "object"}, "description": "[{path, secret, mode?}]: an org secret's value as a file at an absolute path (config files, certificates)."},
157 "user": {"type": "string", "description": "The user the app runs as; numeric (uid[:gid]) on an OCI image."},
158 "working_dir": {"type": "string"},
159 "secret_on_change": {"type": "string", "enum": ["roll", "restart", "none"], "description": "What a new version of a secret the app uses (env or files) does to its replicas: roll (default; a rolling update), restart (each replica's app restarted in place with the new value, one at a time, waiting until healthy) or none (files updated, replicas reported stale until they next start)."}
160}"#;
161
162fn app_props() -> Value {
163 let mut p: Value = serde_json::from_str(APP_PROPS).expect("APP_PROPS is JSON");
164 p["resources"] = crate::app::resources_schema();
165 p
166}
167
168fn create_props() -> Value {
170 let mut create_props = app_props();
171 create_props["name"] = json!({"type": "string", "description": "[a-z0-9-], unique in the org; the service name in its stack."});
172 create_props["project"] = json!({"type": "string"});
173 create_props["environment"] = json!({"type": "string", "description": "Default production."});
174 create_props["deploy"] =
175 json!({"type": "boolean", "description": "Queue a deploy right away."});
176 create_props
177}
178
179fn update_props() -> Value {
181 let mut update_props = app_props();
182 update_props["name"] = json!({"type": "string"});
183 update_props["deploy"] =
184 json!({"type": "boolean", "description": "Queue a deploy after the change."});
185 update_props
186}
187
188fn wait_props() -> Value {
190 json!({
191 "name": {"type": "string"},
192 "wait": {"type": "boolean", "description": "Wait until the deployment finishes (default false)."},
193 "timeout": {"type": "string", "description": "How long wait may take, e.g. 10m (default 15m)."}
194 })
195}
196
197fn rb_props() -> Value {
199 let mut rb_props = wait_props();
200 rb_props["deployment"] = json!({"type": "integer", "minimum": 1, "description": "The deployment to go back to (default: the last successful one before the current)."});
201 rb_props
202}
203
204struct Ann {
206 ro: Value,
207 destructive: Value,
208 write: Value,
209}
210
211#[derive(Deserialize)]
212#[serde(deny_unknown_fields)]
213struct Named {
214 name: String,
215 #[serde(default)]
216 #[allow(dead_code)]
217 org: Option<String>,
218}
219
220#[derive(Deserialize)]
221#[serde(deny_unknown_fields)]
222struct EnvArgs {
223 project: String,
224 #[serde(default)]
225 name: Option<String>,
226 #[serde(default)]
227 #[allow(dead_code)]
228 org: Option<String>,
229}
230
231#[derive(Deserialize)]
232#[serde(deny_unknown_fields)]
233struct DeployArgs {
234 name: String,
235 #[serde(default)]
236 deployment: Option<u64>,
237 #[serde(default)]
238 wait: bool,
239 #[serde(default)]
240 timeout: Option<String>,
241 #[serde(default)]
242 #[allow(dead_code)]
243 org: Option<String>,
244}
245
246fn finish(ap: &Apps, org: &OrgId, a: &DeployArgs, id: u64) -> Result<Value> {
247 let d = if a.wait {
248 let t = match &a.timeout {
249 Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
250 None => Duration::from_secs(900),
251 };
252 ap.wait(org, &a.name, id, t)?
253 } else {
254 ap.deployment(org, &a.name, id)?
255 };
256 Ok(json!({"deployment": d.summary()}))
257}
258
259pub fn register(r: &mut Registry, apps: Apps, ingress: bool) -> Result<()> {
260 let ann = Ann {
261 ro: json!({"readOnlyHint": true, "openWorldHint": false}),
262 destructive: json!({"destructiveHint": true, "openWorldHint": false}),
263 write: json!({"destructiveHint": false, "openWorldHint": false}),
264 };
265
266 project_create_tool(r, &apps, &ann)?;
267 project_list_tool(r, &apps, &ann)?;
268
269 environment_create_tool(r, &apps, &ann)?;
270 environment_list_tool(r, &apps, &ann)?;
271
272 app_create_tool(r, &apps, &ann, ingress)?;
273 app_get_tool(r, &apps, &ann, ingress)?;
274 app_list_tool(r, &apps, &ann, ingress)?;
275 app_update_tool(r, &apps, &ann, ingress)?;
276 app_delete_tool(r, &apps, &ann)?;
277
278 app_deploy_tool(r, &apps, &ann)?;
279 app_rollback_tool(r, &apps, &ann)?;
280 app_deployments_tool(r, &apps, &ann)?;
281 app_deployment_log_tool(r, &apps, &ann)?;
282 app_env_get_tool(r, &apps, &ann)?;
283 app_env_set_tool(r, &apps, &ann)?;
284 app_webhook_tool(r, &apps, &ann)?;
285 app_deploy_key_tool(r, &apps, &ann)?;
286 manifest::register(r, apps, ingress)
287}
288
289fn project_create_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
290 tool!(
291 r,
292 apps,
293 "project_create",
294 "Create a project",
295 "Create a project in an org: a group of environments (default: production), each of which runs its apps as one stack named <project>-<env>.",
296 obj(
297 json!({
298 "name": {"type": "string"},
299 "description": {"type": "string"},
300 "environments": {"type": "array", "items": {"type": "string"}, "description": "Default [production]."}
301 }),
302 &["name"]
303 ),
304 ann.write,
305 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
306 #[derive(Deserialize)]
307 #[serde(deny_unknown_fields)]
308 struct A {
309 name: String,
310 #[serde(default)]
311 description: String,
312 #[serde(default)]
313 environments: Vec<String>,
314 #[serde(default)]
315 #[allow(dead_code)]
316 org: Option<String>,
317 }
318 let org = org_of(&a)?;
319 let a: A = args(a)?;
320 Ok(json!(ap.project_create(
321 &org,
322 &a.name,
323 &a.description,
324 &a.environments
325 )?))
326 }
327 );
328 Ok(())
329}
330
331fn project_list_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
332 tool!(
333 r,
334 apps,
335 "project_list",
336 "List projects",
337 "An org's projects, each with its environments and, in each, its apps and its compose stacks (`compose`: name, services and `deployed_at`, unix seconds) and `conflicts`: a compose service that does not get the environment's name `<service>.<project>-<env>` because `winner` (a stack; `<project>-<env>` for an app) holds it.",
338 obj(json!({}), &[]),
339 ann.ro,
340 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
341 let org = org_of(&a)?;
342 let apps = ap.list(&org)?;
343 let projects: Vec<Value> = ap
344 .project_list(&org)?
345 .into_iter()
346 .map(|p| {
347 let envs: Vec<Value> = p
348 .environments
349 .iter()
350 .map(|e| {
351 let names: Vec<&str> = apps
352 .iter()
353 .filter(|x| x.spec.project == p.name && &x.spec.environment == e)
354 .map(|x| x.spec.name.as_str())
355 .collect();
356 let ec = ap.environment_compose(&org, &p.name, e);
357 let compose: Vec<Value> = ec
358 .stacks
359 .iter()
360 .map(|(s, svcs)| {
361 let at = ap
362 .controller()
363 .definition(&crate::stack::qualified(&org, s))
364 .ok()
365 .map(|d| d.deployed_at);
366 json!({"name": s, "services": svcs, "deployed_at": at})
367 })
368 .collect();
369 json!({
370 "name": e,
371 "stack": format!("{}-{e}", p.name),
372 "apps": names,
373 "compose": compose,
374 "conflicts": ec.conflicts,
375 })
376 })
377 .collect();
378 json!({"name": p.name, "description": p.description, "created_at": p.created_at, "environments": envs})
379 })
380 .collect();
381 Ok(json!({"projects": projects}))
382 }
383 );
384 Ok(())
385}
386
387fn environment_create_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
388 tool!(
389 r,
390 apps,
391 "environment_create",
392 "Create an environment",
393 "Add an environment (staging, preview, ...) to a project. Its apps run as the stack <project>-<name>.",
394 obj(
395 json!({"project": {"type": "string"}, "name": {"type": "string"}}),
396 &["project", "name"]
397 ),
398 ann.write,
399 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
400 let org = org_of(&a)?;
401 let a: EnvArgs = args(a)?;
402 let name = a.name.ok_or_else(|| Error::invalid("name is required"))?;
403 Ok(json!(ap.environment_create(&org, &a.project, &name)?))
404 }
405 );
406 Ok(())
407}
408
409fn environment_list_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
410 tool!(
411 r,
412 apps,
413 "environment_list",
414 "List environments",
415 "A project's environments.",
416 obj(json!({"project": {"type": "string"}}), &["project"]),
417 ann.ro,
418 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
419 let org = org_of(&a)?;
420 let a: EnvArgs = args(a)?;
421 let p = ap.project_get(&org, &a.project)?;
422 Ok(json!({"environments": p.environments}))
423 }
424 );
425 Ok(())
426}
427
428fn app_create_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
429 tool!(
430 r,
431 apps,
432 "app_create",
433 "Create an app",
434 "Create an application in a project's environment: an image or a git source (built by a builder), plus its env, domains, volumes, ports, replicas, port, health check, resources and command. Returns the app and its webhook secret (POST <webhook> with it to deploy). Nothing runs until app_deploy (or deploy=true).",
435 obj(create_props(), &["name", "project", "source"]),
436 ann.write,
437 move |ap: &Apps, mut a: Value, c: &Caller| -> Result<Value> {
438 let org = org_of(&a)?;
439 let deploy = a.get("deploy").and_then(Value::as_bool).unwrap_or(false);
440 if let Some(o) = a.as_object_mut() {
441 o.remove("org");
442 o.remove("deploy");
443 }
444 let spec: AppSpec = args(a)?;
445 let image = ap.check_image(None, &spec)?;
446 let (app, secret) = ap.create(&org, spec)?;
447 let mut aj = app_json(&org, &app);
448 let ingress = note_ingress(&mut aj, ingress);
449 let mut out = json!({"app": aj, "webhook_secret": secret});
450 warn(&mut out, image, ingress);
451 if deploy {
452 let d = ap.deploy(&org, &app.spec.name, trigger(c), &caller_name(c), None)?;
453 out["deployment"] = d.summary();
454 }
455 Ok(out)
456 }
457 );
458 Ok(())
459}
460
461fn app_get_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
462 tool!(
463 r,
464 apps,
465 "app_get",
466 "Get an app",
467 "An app's settings, stack, service name, current deployment and webhook path, and what its service is doing now: status {state (converged, updating, failing, ...), replicas, healthy, message (why it is failing, e.g. image ... not found)}. Secrets in its env show as {secret: NAME}, never values.",
468 obj(json!({"name": {"type": "string"}}), &["name"]),
469 ann.ro,
470 move |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
471 let org = org_of(&a)?;
472 let a: Named = args(a)?;
473 let app = ap.get(&org, &a.name)?;
474 let mut v = app_json(&org, &app);
475 note_ingress(&mut v, ingress);
476 if let Some(st) = service_status(ap, &org, &app) {
477 v["status"] = st;
478 }
479 Ok(v)
480 }
481 );
482 Ok(())
483}
484
485fn app_list_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
486 tool!(
487 r,
488 apps,
489 "app_list",
490 "List apps",
491 "An org's apps, optionally only one project's (and environment's).",
492 obj(
493 json!({"project": {"type": "string"}, "environment": {"type": "string"}}),
494 &[]
495 ),
496 ann.ro,
497 move |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
498 #[derive(Deserialize)]
499 #[serde(deny_unknown_fields)]
500 struct A {
501 project: Option<String>,
502 environment: Option<String>,
503 #[serde(default)]
504 #[allow(dead_code)]
505 org: Option<String>,
506 }
507 let org = org_of(&a)?;
508 let a: A = args(a)?;
509 let apps: Vec<Value> = ap
510 .list(&org)?
511 .into_iter()
512 .filter(|x| a.project.as_ref().is_none_or(|p| *p == x.spec.project))
513 .filter(|x| {
514 a.environment
515 .as_ref()
516 .is_none_or(|e| *e == x.spec.environment)
517 })
518 .map(|x| {
519 let mut v = app_json(&org, &x);
520 note_ingress(&mut v, ingress);
521 v
522 })
523 .collect();
524 Ok(json!({"apps": apps}))
525 }
526 );
527 Ok(())
528}
529
530fn app_update_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
531 tool!(
532 r,
533 apps,
534 "app_update",
535 "Update an app",
536 "Change an app's settings: the fields given replace the current ones (a JSON merge patch: null clears a setting; objects merge). Name, project and environment are fixed. Takes effect at the next deploy (deploy=true queues one).",
537 obj(update_props(), &["name"]),
538 ann.write,
539 move |ap: &Apps, mut a: Value, c: &Caller| -> Result<Value> {
540 let org = org_of(&a)?;
541 let deploy = a.get("deploy").and_then(Value::as_bool).unwrap_or(false);
542 let name = a
543 .get("name")
544 .and_then(Value::as_str)
545 .ok_or_else(|| Error::invalid("name is required"))?
546 .to_string();
547 if let Some(o) = a.as_object_mut() {
548 o.remove("org");
549 o.remove("deploy");
550 o.remove("name");
551 }
552 let image = match a.get("source") {
553 Some(_) => check_patched_image(ap, &org, &name, &a)?,
554 None => None,
555 };
556 let app = ap.update(&org, &name, &a)?;
557 let mut aj = app_json(&org, &app);
558 let ingress = note_ingress(&mut aj, ingress);
559 let mut out = json!({"app": aj});
560 warn(&mut out, image, ingress);
561 if deploy {
562 let d = ap.deploy(&org, &name, trigger(c), &caller_name(c), None)?;
563 out["deployment"] = d.summary();
564 }
565 Ok(out)
566 }
567 );
568 Ok(())
569}
570
571fn app_delete_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
572 tool!(
573 r,
574 apps,
575 "app_delete",
576 "Delete an app",
577 "Delete an app: its service leaves the stack (the stack is removed with its last app), its deployments, checkout, webhook secret and deploy key go. Named volumes are kept.",
578 obj(json!({"name": {"type": "string"}}), &["name"]),
579 ann.destructive,
580 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
581 let org = org_of(&a)?;
582 let a: Named = args(a)?;
583 ap.delete(&org, &a.name)?;
584 Ok(json!({"ok": true}))
585 }
586 );
587 Ok(())
588}
589
590fn app_deploy_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
591 tool!(
592 r,
593 apps,
594 "app_deploy",
595 "Deploy an app",
596 "Deploy an app's current settings: pull (an image source, pinned to its digest) or fetch and build (a git source), then roll its service in its environment's stack; other apps there are untouched. One deploy runs at a time per app; a new one waits behind it and replaces any other waiting one. Returns the deployment record; follow it with app_deployment_log or the events feed.",
597 obj(wait_props(), &["name"]),
598 ann.write,
599 |ap: &Apps, a: Value, c: &Caller| -> Result<Value> {
600 let org = org_of(&a)?;
601 let a: DeployArgs = args(a)?;
602 let d = ap.deploy(&org, &a.name, trigger(c), &caller_name(c), None)?;
603 finish(ap, &org, &a, d.id)
604 }
605 );
606 Ok(())
607}
608
609fn app_rollback_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
610 tool!(
611 r,
612 apps,
613 "app_rollback",
614 "Roll back an app",
615 "Redeploy a previous successful deployment's image (by digest when known) and settings, without building. The app's saved settings are not changed, so the next deploy applies them again.",
616 obj(rb_props(), &["name"]),
617 ann.write,
618 |ap: &Apps, a: Value, c: &Caller| -> Result<Value> {
619 let org = org_of(&a)?;
620 let a: DeployArgs = args(a)?;
621 let d = ap.rollback(&org, &a.name, a.deployment, trigger(c), &caller_name(c))?;
622 finish(ap, &org, &a, d.id)
623 }
624 );
625 Ok(())
626}
627
628fn app_deployments_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
629 tool!(
630 r,
631 apps,
632 "app_deployments",
633 "List an app's deployments",
634 "An app's deployments, newest first: trigger, status (queued, building, deploying, done, failed, superseded), commit, image and digest, timestamps.",
635 obj(
636 json!({"name": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 100}}),
637 &["name"]
638 ),
639 ann.ro,
640 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
641 #[derive(Deserialize)]
642 #[serde(deny_unknown_fields)]
643 struct A {
644 name: String,
645 limit: Option<usize>,
646 #[serde(default)]
647 #[allow(dead_code)]
648 org: Option<String>,
649 }
650 let org = org_of(&a)?;
651 let a: A = args(a)?;
652 let app = ap.get(&org, &a.name)?;
653 let ds: Vec<Value> = ap
654 .deployments(&org, &a.name)?
655 .into_iter()
656 .take(a.limit.unwrap_or(20))
657 .map(|d| d.summary())
658 .collect();
659 Ok(json!({"current": app.current, "deployments": ds}))
660 }
661 );
662 Ok(())
663}
664
665fn app_deployment_log_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
666 tool!(
667 r,
668 apps,
669 "app_deployment_log",
670 "A deployment's log",
671 "A deployment's log (git, build and rollout lines) from byte `offset`. Returns the text, the offset to ask from next, whether the deployment has finished, and its record (status, image, commit, timings; read before the text, so a finished record means the text is complete): poll until done. The same lines stream on the events feed as level `log`.",
672 obj(
673 json!({
674 "name": {"type": "string"},
675 "deployment": {"type": "integer", "minimum": 1},
676 "offset": {"type": "integer", "minimum": 0}
677 }),
678 &["name", "deployment"]
679 ),
680 ann.ro,
681 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
682 #[derive(Deserialize)]
683 #[serde(deny_unknown_fields)]
684 struct A {
685 name: String,
686 deployment: u64,
687 #[serde(default)]
688 offset: u64,
689 #[serde(default)]
690 #[allow(dead_code)]
691 org: Option<String>,
692 }
693 let org = org_of(&a)?;
694 let a: A = args(a)?;
695 let (log, next, d) = ap.log_and_record(&org, &a.name, a.deployment, a.offset)?;
696 Ok(json!({
697 "log": log,
698 "offset": next,
699 "finished": d.status.finished(),
700 "status": d.status,
701 "deployment": d.summary(),
702 }))
703 }
704 );
705 Ok(())
706}
707
708fn app_env_get_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
709 tool!(
710 r,
711 apps,
712 "app_env_get",
713 "Get an app's environment",
714 "An app's environment as .env text (KEY=value lines, comments kept). Secret references read KEY=${{secret.NAME}}; their values are never shown.",
715 obj(json!({"name": {"type": "string"}}), &["name"]),
716 ann.ro,
717 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
718 let org = org_of(&a)?;
719 let a: Named = args(a)?;
720 Ok(json!({"env": ap.env_get(&org, &a.name)?}))
721 }
722 );
723 Ok(())
724}
725
726fn app_env_set_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
727 tool!(
728 r,
729 apps,
730 "app_env_set",
731 "Set an app's environment",
732 "Replace an app's environment with .env text: KEY=value lines (quotes and # comments as in docker compose; comments are kept), KEY=${{secret.NAME}} for an org secret. Takes effect at the next deploy (deploy=true queues one).",
733 obj(
734 json!({
735 "name": {"type": "string"},
736 "env": {"type": "string", "description": "The .env text."},
737 "deploy": {"type": "boolean"}
738 }),
739 &["name", "env"]
740 ),
741 ann.write,
742 |ap: &Apps, a: Value, c: &Caller| -> Result<Value> {
743 #[derive(Deserialize)]
744 #[serde(deny_unknown_fields)]
745 struct A {
746 name: String,
747 env: String,
748 #[serde(default)]
749 deploy: bool,
750 #[serde(default)]
751 #[allow(dead_code)]
752 org: Option<String>,
753 }
754 let org = org_of(&a)?;
755 let a: A = args(a)?;
756 let app = ap.env_set(&org, &a.name, &a.env)?;
757 let mut out = json!({"env": app.spec.env.render()});
758 if a.deploy {
759 let d = ap.deploy(&org, &a.name, trigger(c), &caller_name(c), None)?;
760 out["deployment"] = d.summary();
761 }
762 Ok(out)
763 }
764 );
765 Ok(())
766}
767
768fn app_webhook_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
769 tool!(
770 r,
771 apps,
772 "app_webhook",
773 "An app's webhook",
774 "The app's webhook path and secret, to configure in GitHub (application/json, the secret), Gitea/Forgejo (the secret), GitLab (secret token) or any caller (?token=<secret>). rotate=true makes a new secret first.",
775 obj(
776 json!({"name": {"type": "string"}, "rotate": {"type": "boolean"}}),
777 &["name"]
778 ),
779 ann.write,
780 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
781 #[derive(Deserialize)]
782 #[serde(deny_unknown_fields)]
783 struct A {
784 name: String,
785 #[serde(default)]
786 rotate: bool,
787 #[serde(default)]
788 #[allow(dead_code)]
789 org: Option<String>,
790 }
791 let org = org_of(&a)?;
792 let a: A = args(a)?;
793 let secret = ap.webhook_secret(&org, &a.name, a.rotate)?;
794 Ok(json!({"path": webhook_path(&org, &a.name), "secret": secret}))
795 }
796 );
797 Ok(())
798}
799
800fn app_deploy_key_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
801 tool!(
802 r,
803 apps,
804 "app_deploy_key",
805 "Make a deploy key",
806 "Generate an ed25519 deploy key for a git app with an SSH URL: the private key is stored as the org secret app.<name>.deploy-key and becomes the app's credential; the public key is returned to add to the repository's deploy keys (read-only).",
807 obj(json!({"name": {"type": "string"}}), &["name"]),
808 ann.write,
809 |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
810 let org = org_of(&a)?;
811 let a: Named = args(a)?;
812 Ok(json!({"public_key": ap.deploy_key(&org, &a.name)?}))
813 }
814 );
815 Ok(())
816}
817
818pub fn webhook_routes(apps: Apps) -> crate::server::Routes {
821 use crate::server::http::Response;
822 std::sync::Arc::new(move |req: &crate::server::http::Request| {
823 let rest = req.path.strip_prefix("/api/v1/webhooks/")?;
824 let (org, app) = rest.split_once('/')?;
825 if app.contains('/') {
826 return Some(Response::text(404, "not found"));
827 }
828 if req.method != "POST" {
829 return Some(Response::text(405, "method not allowed").header("Allow", "POST"));
830 }
831 let token = req.query.as_deref().and_then(|q| {
832 q.split('&')
833 .find_map(|kv| kv.strip_prefix("token="))
834 .map(String::from)
835 });
836 let header = |n: &str| req.header(n).map(String::from);
837 let (status, body) = apps.webhook(org, app, &header, token.as_deref(), &req.body);
838 if status == 401 {
839 eprintln!(
840 "isb serve: webhook {org}/{app}: refused a request from {:?}",
841 req.peer
842 );
843 }
844 Some(Response::json(status, &body).header("Cache-Control", "no-store"))
845 })
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 #[test]
853 fn warns_about_domains_only_without_an_ingress() {
854 let with = json!({"domains": [{"host": "auto"}]});
855 let mut a = with.clone();
856 assert_eq!(note_ingress(&mut a, false), Some(NO_INGRESS_WARNING));
857 assert_eq!(a["ingress_enabled"], json!(false));
858 let mut a = with;
859 assert_eq!(note_ingress(&mut a, true), None);
860 assert_eq!(a["ingress_enabled"], json!(true));
861 let mut none = json!({"domains": []});
863 assert_eq!(note_ingress(&mut none, false), None);
864 let mut absent = json!({});
865 assert_eq!(note_ingress(&mut absent, false), None);
866 }
867}