1use 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
18pub 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 if c.is_local() {
30 Trigger::Manual
31 } else {
32 Trigger::Api
33 }
34}
35
36pub 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
79pub 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
83pub(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
118fn 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
129fn 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
138fn 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
147fn 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
154struct 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
787pub 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 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}