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