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