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