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