Skip to main content

isb_daemon/daemon/
apps.rs

1//! The app tools (`project_*`, `environment_*`, `app_*`) and the public
2//! webhook route, over [`crate::app::Apps`].
3
4use std::time::Duration;
5
6mod delete;
7pub(crate) mod manifest;
8
9pub(super) use delete::{KEEP_MONITORS, delete_tools, empty_org, stack_targets};
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
21/// Where an app's webhook is served.
22pub 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    // The CLI on the host; a superadmin over HTTP is an API call.
32    if c.is_local() {
33        Trigger::Manual
34    } else {
35        Trigger::Api
36    }
37}
38
39/// An app as the tools show it: its settings, where it runs, and its
40/// environment as a map with secret references (never values).
41pub 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
82/// What `app_create` and `app_update` answer with when the app has domains
83/// and the server runs without an ingress.
84pub 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
86/// Add `ingress_enabled` to an app as the tools show it, and return the
87/// warning when its domains will not be served.
88pub(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
99/// What the app's service is doing now (`state`, and `message` when it is
100/// failing: "image ... not found", say), or `None` before its first deploy.
101fn 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
118/// Put the image check's warning and the ingress one in a tool's answer.
119pub(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
126/// Check the image an update's patch would leave the app with, when it
127/// changes. A patch that does not make valid settings is left to `update`
128/// to refuse.
129fn 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
168/// app_create's properties: the app's, plus where it goes.
169fn 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
179/// app_update's properties.
180fn 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
188/// The properties of the tools that can wait for a deployment.
189fn 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
197/// app_rollback's properties.
198fn 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
204/// The MCP annotations the tools below share.
205struct Ann {
206    ro: Value,
207    write: Value,
208}
209
210#[derive(Deserialize)]
211#[serde(deny_unknown_fields)]
212struct Named {
213    name: String,
214    #[serde(default)]
215    #[allow(dead_code)]
216    org: Option<String>,
217}
218
219#[derive(Deserialize)]
220#[serde(deny_unknown_fields)]
221struct EnvArgs {
222    project: String,
223    #[serde(default)]
224    name: Option<String>,
225    #[serde(default)]
226    #[allow(dead_code)]
227    org: Option<String>,
228}
229
230#[derive(Deserialize)]
231#[serde(deny_unknown_fields)]
232struct DeployArgs {
233    name: String,
234    #[serde(default)]
235    deployment: Option<u64>,
236    #[serde(default)]
237    wait: bool,
238    #[serde(default)]
239    timeout: Option<String>,
240    #[serde(default)]
241    #[allow(dead_code)]
242    org: Option<String>,
243}
244
245fn finish(ap: &Apps, org: &OrgId, a: &DeployArgs, id: u64) -> Result<Value> {
246    let d = if a.wait {
247        let t = match &a.timeout {
248            Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
249            None => Duration::from_secs(900),
250        };
251        ap.wait(org, &a.name, id, t)?
252    } else {
253        ap.deployment(org, &a.name, id)?
254    };
255    Ok(json!({"deployment": d.summary()}))
256}
257
258pub fn register(r: &mut Registry, apps: Apps, ingress: bool) -> Result<()> {
259    let ann = Ann {
260        ro: json!({"readOnlyHint": true, "openWorldHint": false}),
261        write: json!({"destructiveHint": false, "openWorldHint": false}),
262    };
263
264    project_create_tool(r, &apps, &ann)?;
265    project_list_tool(r, &apps, &ann)?;
266
267    environment_create_tool(r, &apps, &ann)?;
268    environment_list_tool(r, &apps, &ann)?;
269
270    app_create_tool(r, &apps, &ann, ingress)?;
271    app_get_tool(r, &apps, &ann, ingress)?;
272    app_list_tool(r, &apps, &ann, ingress)?;
273    app_update_tool(r, &apps, &ann, ingress)?;
274
275    app_deploy_tool(r, &apps, &ann)?;
276    app_rollback_tool(r, &apps, &ann)?;
277    app_deployments_tool(r, &apps, &ann)?;
278    app_deployment_log_tool(r, &apps, &ann)?;
279    app_env_get_tool(r, &apps, &ann)?;
280    app_env_set_tool(r, &apps, &ann)?;
281    app_webhook_tool(r, &apps, &ann)?;
282    app_deploy_key_tool(r, &apps, &ann)?;
283    manifest::register(r, apps, ingress)
284}
285
286fn project_create_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
287    tool!(
288        r,
289        apps,
290        "project_create",
291        "Create a project",
292        "Create a project in an org: a group of environments (default: production), each of which runs its apps as one stack named <project>-<env>.",
293        obj(
294            json!({
295                "name": {"type": "string"},
296                "description": {"type": "string"},
297                "environments": {"type": "array", "items": {"type": "string"}, "description": "Default [production]."}
298            }),
299            &["name"]
300        ),
301        ann.write,
302        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
303            #[derive(Deserialize)]
304            #[serde(deny_unknown_fields)]
305            struct A {
306                name: String,
307                #[serde(default)]
308                description: String,
309                #[serde(default)]
310                environments: Vec<String>,
311                #[serde(default)]
312                #[allow(dead_code)]
313                org: Option<String>,
314            }
315            let org = org_of(&a)?;
316            let a: A = args(a)?;
317            Ok(json!(ap.project_create(
318                &org,
319                &a.name,
320                &a.description,
321                &a.environments
322            )?))
323        }
324    );
325    Ok(())
326}
327
328fn project_list_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
329    tool!(
330        r,
331        apps,
332        "project_list",
333        "List projects",
334        "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.",
335        obj(json!({}), &[]),
336        ann.ro,
337        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
338            let org = org_of(&a)?;
339            let apps = ap.list(&org)?;
340            let projects: Vec<Value> = ap
341                .project_list(&org)?
342                .into_iter()
343                .map(|p| {
344                    let envs: Vec<Value> = p
345                        .environments
346                        .iter()
347                        .map(|e| {
348                            let names: Vec<&str> = apps
349                                .iter()
350                                .filter(|x| x.spec.project == p.name && &x.spec.environment == e)
351                                .map(|x| x.spec.name.as_str())
352                                .collect();
353                            let ec = ap.environment_compose(&org, &p.name, e);
354                            let compose: Vec<Value> = ec
355                                .stacks
356                                .iter()
357                                .map(|(s, svcs)| {
358                                    let at = ap
359                                        .controller()
360                                        .definition(&crate::stack::qualified(&org, s))
361                                        .ok()
362                                        .map(|d| d.deployed_at);
363                                    json!({"name": s, "services": svcs, "deployed_at": at})
364                                })
365                                .collect();
366                            json!({
367                                "name": e,
368                                "stack": format!("{}-{e}", p.name),
369                                "apps": names,
370                                "compose": compose,
371                                "conflicts": ec.conflicts,
372                            })
373                        })
374                        .collect();
375                    json!({"name": p.name, "description": p.description, "created_at": p.created_at, "environments": envs})
376                })
377                .collect();
378            Ok(json!({"projects": projects}))
379        }
380    );
381    Ok(())
382}
383
384fn environment_create_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
385    tool!(
386        r,
387        apps,
388        "environment_create",
389        "Create an environment",
390        "Add an environment (staging, preview, ...) to a project. Its apps run as the stack <project>-<name>.",
391        obj(
392            json!({"project": {"type": "string"}, "name": {"type": "string"}}),
393            &["project", "name"]
394        ),
395        ann.write,
396        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
397            let org = org_of(&a)?;
398            let a: EnvArgs = args(a)?;
399            let name = a.name.ok_or_else(|| Error::invalid("name is required"))?;
400            Ok(json!(ap.environment_create(&org, &a.project, &name)?))
401        }
402    );
403    Ok(())
404}
405
406fn environment_list_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
407    tool!(
408        r,
409        apps,
410        "environment_list",
411        "List environments",
412        "A project's environments.",
413        obj(json!({"project": {"type": "string"}}), &["project"]),
414        ann.ro,
415        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
416            let org = org_of(&a)?;
417            let a: EnvArgs = args(a)?;
418            let p = ap.project_get(&org, &a.project)?;
419            Ok(json!({"environments": p.environments}))
420        }
421    );
422    Ok(())
423}
424
425fn app_create_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
426    tool!(
427        r,
428        apps,
429        "app_create",
430        "Create an app",
431        "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).",
432        obj(create_props(), &["name", "project", "source"]),
433        ann.write,
434        move |ap: &Apps, mut a: Value, c: &Caller| -> Result<Value> {
435            let org = org_of(&a)?;
436            let deploy = a.get("deploy").and_then(Value::as_bool).unwrap_or(false);
437            if let Some(o) = a.as_object_mut() {
438                o.remove("org");
439                o.remove("deploy");
440            }
441            let spec: AppSpec = args(a)?;
442            let image = ap.check_image(None, &spec)?;
443            let (app, secret) = ap.create(&org, spec)?;
444            let mut aj = app_json(&org, &app);
445            let ingress = note_ingress(&mut aj, ingress);
446            let mut out = json!({"app": aj, "webhook_secret": secret});
447            warn(&mut out, image, ingress);
448            if deploy {
449                let d = ap.deploy(&org, &app.spec.name, trigger(c), &caller_name(c), None)?;
450                out["deployment"] = d.summary();
451            }
452            Ok(out)
453        }
454    );
455    Ok(())
456}
457
458fn app_get_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
459    tool!(
460        r,
461        apps,
462        "app_get",
463        "Get an app",
464        "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.",
465        obj(json!({"name": {"type": "string"}}), &["name"]),
466        ann.ro,
467        move |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
468            let org = org_of(&a)?;
469            let a: Named = args(a)?;
470            let app = ap.get(&org, &a.name)?;
471            let mut v = app_json(&org, &app);
472            note_ingress(&mut v, ingress);
473            if let Some(st) = service_status(ap, &org, &app) {
474                v["status"] = st;
475            }
476            Ok(v)
477        }
478    );
479    Ok(())
480}
481
482fn app_list_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
483    tool!(
484        r,
485        apps,
486        "app_list",
487        "List apps",
488        "An org's apps, optionally only one project's (and environment's).",
489        obj(
490            json!({"project": {"type": "string"}, "environment": {"type": "string"}}),
491            &[]
492        ),
493        ann.ro,
494        move |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
495            #[derive(Deserialize)]
496            #[serde(deny_unknown_fields)]
497            struct A {
498                project: Option<String>,
499                environment: Option<String>,
500                #[serde(default)]
501                #[allow(dead_code)]
502                org: Option<String>,
503            }
504            let org = org_of(&a)?;
505            let a: A = args(a)?;
506            let apps: Vec<Value> = ap
507                .list(&org)?
508                .into_iter()
509                .filter(|x| a.project.as_ref().is_none_or(|p| *p == x.spec.project))
510                .filter(|x| {
511                    a.environment
512                        .as_ref()
513                        .is_none_or(|e| *e == x.spec.environment)
514                })
515                .map(|x| {
516                    let mut v = app_json(&org, &x);
517                    note_ingress(&mut v, ingress);
518                    v
519                })
520                .collect();
521            Ok(json!({"apps": apps}))
522        }
523    );
524    Ok(())
525}
526
527fn app_update_tool(r: &mut Registry, apps: &Apps, ann: &Ann, ingress: bool) -> Result<()> {
528    tool!(
529        r,
530        apps,
531        "app_update",
532        "Update an app",
533        "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).",
534        obj(update_props(), &["name"]),
535        ann.write,
536        move |ap: &Apps, mut a: Value, c: &Caller| -> Result<Value> {
537            let org = org_of(&a)?;
538            let deploy = a.get("deploy").and_then(Value::as_bool).unwrap_or(false);
539            let name = a
540                .get("name")
541                .and_then(Value::as_str)
542                .ok_or_else(|| Error::invalid("name is required"))?
543                .to_string();
544            if let Some(o) = a.as_object_mut() {
545                o.remove("org");
546                o.remove("deploy");
547                o.remove("name");
548            }
549            let image = match a.get("source") {
550                Some(_) => check_patched_image(ap, &org, &name, &a)?,
551                None => None,
552            };
553            let app = ap.update(&org, &name, &a)?;
554            let mut aj = app_json(&org, &app);
555            let ingress = note_ingress(&mut aj, ingress);
556            let mut out = json!({"app": aj});
557            warn(&mut out, image, ingress);
558            if deploy {
559                let d = ap.deploy(&org, &name, trigger(c), &caller_name(c), None)?;
560                out["deployment"] = d.summary();
561            }
562            Ok(out)
563        }
564    );
565    Ok(())
566}
567
568fn app_deploy_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
569    tool!(
570        r,
571        apps,
572        "app_deploy",
573        "Deploy an app",
574        "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.",
575        obj(wait_props(), &["name"]),
576        ann.write,
577        |ap: &Apps, a: Value, c: &Caller| -> Result<Value> {
578            let org = org_of(&a)?;
579            let a: DeployArgs = args(a)?;
580            let d = ap.deploy(&org, &a.name, trigger(c), &caller_name(c), None)?;
581            finish(ap, &org, &a, d.id)
582        }
583    );
584    Ok(())
585}
586
587fn app_rollback_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
588    tool!(
589        r,
590        apps,
591        "app_rollback",
592        "Roll back an app",
593        "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.",
594        obj(rb_props(), &["name"]),
595        ann.write,
596        |ap: &Apps, a: Value, c: &Caller| -> Result<Value> {
597            let org = org_of(&a)?;
598            let a: DeployArgs = args(a)?;
599            let d = ap.rollback(&org, &a.name, a.deployment, trigger(c), &caller_name(c))?;
600            finish(ap, &org, &a, d.id)
601        }
602    );
603    Ok(())
604}
605
606fn app_deployments_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
607    tool!(
608        r,
609        apps,
610        "app_deployments",
611        "List an app's deployments",
612        "An app's deployments, newest first: trigger, status (queued, building, deploying, done, failed, superseded), commit, image and digest, timestamps.",
613        obj(
614            json!({"name": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 100}}),
615            &["name"]
616        ),
617        ann.ro,
618        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
619            #[derive(Deserialize)]
620            #[serde(deny_unknown_fields)]
621            struct A {
622                name: String,
623                limit: Option<usize>,
624                #[serde(default)]
625                #[allow(dead_code)]
626                org: Option<String>,
627            }
628            let org = org_of(&a)?;
629            let a: A = args(a)?;
630            let app = ap.get(&org, &a.name)?;
631            let ds: Vec<Value> = ap
632                .deployments(&org, &a.name)?
633                .into_iter()
634                .take(a.limit.unwrap_or(20))
635                .map(|d| d.summary())
636                .collect();
637            Ok(json!({"current": app.current, "deployments": ds}))
638        }
639    );
640    Ok(())
641}
642
643fn app_deployment_log_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
644    tool!(
645        r,
646        apps,
647        "app_deployment_log",
648        "A deployment's log",
649        "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`.",
650        obj(
651            json!({
652                "name": {"type": "string"},
653                "deployment": {"type": "integer", "minimum": 1},
654                "offset": {"type": "integer", "minimum": 0}
655            }),
656            &["name", "deployment"]
657        ),
658        ann.ro,
659        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
660            #[derive(Deserialize)]
661            #[serde(deny_unknown_fields)]
662            struct A {
663                name: String,
664                deployment: u64,
665                #[serde(default)]
666                offset: u64,
667                #[serde(default)]
668                #[allow(dead_code)]
669                org: Option<String>,
670            }
671            let org = org_of(&a)?;
672            let a: A = args(a)?;
673            let (log, next, d) = ap.log_and_record(&org, &a.name, a.deployment, a.offset)?;
674            Ok(json!({
675                "log": log,
676                "offset": next,
677                "finished": d.status.finished(),
678                "status": d.status,
679                "deployment": d.summary(),
680            }))
681        }
682    );
683    Ok(())
684}
685
686fn app_env_get_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
687    tool!(
688        r,
689        apps,
690        "app_env_get",
691        "Get an app's environment",
692        "An app's environment as .env text (KEY=value lines, comments kept). Secret references read KEY=${{secret.NAME}}; their values are never shown.",
693        obj(json!({"name": {"type": "string"}}), &["name"]),
694        ann.ro,
695        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
696            let org = org_of(&a)?;
697            let a: Named = args(a)?;
698            Ok(json!({"env": ap.env_get(&org, &a.name)?}))
699        }
700    );
701    Ok(())
702}
703
704fn app_env_set_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
705    tool!(
706        r,
707        apps,
708        "app_env_set",
709        "Set an app's environment",
710        "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).",
711        obj(
712            json!({
713                "name": {"type": "string"},
714                "env": {"type": "string", "description": "The .env text."},
715                "deploy": {"type": "boolean"}
716            }),
717            &["name", "env"]
718        ),
719        ann.write,
720        |ap: &Apps, a: Value, c: &Caller| -> Result<Value> {
721            #[derive(Deserialize)]
722            #[serde(deny_unknown_fields)]
723            struct A {
724                name: String,
725                env: String,
726                #[serde(default)]
727                deploy: bool,
728                #[serde(default)]
729                #[allow(dead_code)]
730                org: Option<String>,
731            }
732            let org = org_of(&a)?;
733            let a: A = args(a)?;
734            let app = ap.env_set(&org, &a.name, &a.env)?;
735            let mut out = json!({"env": app.spec.env.render()});
736            if a.deploy {
737                let d = ap.deploy(&org, &a.name, trigger(c), &caller_name(c), None)?;
738                out["deployment"] = d.summary();
739            }
740            Ok(out)
741        }
742    );
743    Ok(())
744}
745
746fn app_webhook_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
747    tool!(
748        r,
749        apps,
750        "app_webhook",
751        "An app's webhook",
752        "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.",
753        obj(
754            json!({"name": {"type": "string"}, "rotate": {"type": "boolean"}}),
755            &["name"]
756        ),
757        ann.write,
758        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
759            #[derive(Deserialize)]
760            #[serde(deny_unknown_fields)]
761            struct A {
762                name: String,
763                #[serde(default)]
764                rotate: bool,
765                #[serde(default)]
766                #[allow(dead_code)]
767                org: Option<String>,
768            }
769            let org = org_of(&a)?;
770            let a: A = args(a)?;
771            let secret = ap.webhook_secret(&org, &a.name, a.rotate)?;
772            Ok(json!({"path": webhook_path(&org, &a.name), "secret": secret}))
773        }
774    );
775    Ok(())
776}
777
778fn app_deploy_key_tool(r: &mut Registry, apps: &Apps, ann: &Ann) -> Result<()> {
779    tool!(
780        r,
781        apps,
782        "app_deploy_key",
783        "Make a deploy key",
784        "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).",
785        obj(json!({"name": {"type": "string"}}), &["name"]),
786        ann.write,
787        |ap: &Apps, a: Value, _c: &Caller| -> Result<Value> {
788            let org = org_of(&a)?;
789            let a: Named = args(a)?;
790            Ok(json!({"public_key": ap.deploy_key(&org, &a.name)?}))
791        }
792    );
793    Ok(())
794}
795
796/// `POST /api/v1/webhooks/<org>/<app>`: served without a session; the
797/// request's signature or token is its only credential.
798pub fn webhook_routes(apps: Apps) -> crate::server::Routes {
799    use crate::server::http::Response;
800    std::sync::Arc::new(move |req: &crate::server::http::Request| {
801        let rest = req.path.strip_prefix("/api/v1/webhooks/")?;
802        let (org, app) = rest.split_once('/')?;
803        if app.contains('/') {
804            return Some(Response::text(404, "not found"));
805        }
806        if req.method != "POST" {
807            return Some(Response::text(405, "method not allowed").header("Allow", "POST"));
808        }
809        let token = req.query.as_deref().and_then(|q| {
810            q.split('&')
811                .find_map(|kv| kv.strip_prefix("token="))
812                .map(String::from)
813        });
814        let header = |n: &str| req.header(n).map(String::from);
815        let (status, body) = apps.webhook(org, app, &header, token.as_deref(), &req.body);
816        if status == 401 {
817            eprintln!(
818                "isb serve: webhook {org}/{app}: refused a request from {:?}",
819                req.peer
820            );
821        }
822        Some(Response::json(status, &body).header("Cache-Control", "no-store"))
823    })
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[test]
831    fn warns_about_domains_only_without_an_ingress() {
832        let with = json!({"domains": [{"host": "auto"}]});
833        let mut a = with.clone();
834        assert_eq!(note_ingress(&mut a, false), Some(NO_INGRESS_WARNING));
835        assert_eq!(a["ingress_enabled"], json!(false));
836        let mut a = with;
837        assert_eq!(note_ingress(&mut a, true), None);
838        assert_eq!(a["ingress_enabled"], json!(true));
839        // No domains, nothing to warn about.
840        let mut none = json!({"domains": []});
841        assert_eq!(note_ingress(&mut none, false), None);
842        let mut absent = json!({});
843        assert_eq!(note_ingress(&mut absent, false), None);
844    }
845}