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