worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // `<binary> setup` — turn a bare Linux host into a running service. |
| 2 | // |
| 3 | // This exists because the gap between "the binary works" and "the service is |
| 4 | // running behind TLS with maintenance scheduled" is a page of instructions that |
| 5 | // everyone follows slightly differently. It writes the units, the Caddyfile and the |
| 6 | // data directory, and prints the one credential you need. |
| 7 | // |
| 8 | // Two shapes: |
| 9 | // |
| 10 | // --standalone one process, repositories on local disk. What a self-hoster wants. |
| 11 | // --isolated a control plane plus one Incus container per account. Every |
| 12 | // account's code and CI runs in its own container, and the control |
| 13 | // plane holds no repositories. |
| 14 | // |
| 15 | // Everything it writes is printed first with `--dry-run`, because a setup command |
| 16 | // that silently rewrites files on a host is not something to run twice. |
| 17 | |
| 18 | use crate::brand; |
| 19 | use anyhow::{bail, Context, Result}; |
| 20 | use std::path::{Path, PathBuf}; |
| 21 | |
| 22 | /// Which shape to install. |
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 24 | pub enum Shape { |
| 25 | Standalone, |
| 26 | Isolated, |
| 27 | } |
| 28 | |
| 29 | impl Shape { |
| 30 | fn as_str(self) -> &'static str { |
| 31 | match self { |
| 32 | Shape::Standalone => "standalone", |
| 33 | Shape::Isolated => "isolated", |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | pub struct Options { |
| 39 | pub shape: Shape, |
| 40 | /// Names the unit, the environment file and the default data directory. |
| 41 | /// |
| 42 | /// Defaults to the product name. A second instance on the same host needs a |
| 43 | /// different one — which is not a hypothetical: self-hosting requires a build |
| 44 | /// instance separate from the control plane, because a deploy restarts the |
| 45 | /// control plane and a build running inside it would kill itself half way. |
| 46 | pub instance: String, |
| 47 | /// Public hostname. TLS is only requested when this is a real domain. |
| 48 | pub domain: Option<String>, |
| 49 | /// Unix user the service runs as. |
| 50 | pub user: String, |
| 51 | pub data_dir: PathBuf, |
| 52 | pub bind: String, |
| 53 | pub ssh_bind: String, |
| 54 | pub ci_enabled: bool, |
| 55 | pub dry_run: bool, |
| 56 | } |
| 57 | |
| 58 | impl Options { |
| 59 | pub fn from_args(args: &[String]) -> Result<Self> { |
| 60 | let shape = if args.iter().any(|a| a == "--isolated") { |
| 61 | Shape::Isolated |
| 62 | } else if args.iter().any(|a| a == "--standalone") { |
| 63 | Shape::Standalone |
| 64 | } else { |
| 65 | bail!( |
| 66 | "choose a shape: --standalone (one host, local repositories) or \ |
| 67 | --isolated (a container per account)" |
| 68 | ); |
| 69 | }; |
| 70 | |
| 71 | let flag = |name: &str| -> Option<String> { |
| 72 | args.iter() |
| 73 | .position(|a| a == name) |
| 74 | .and_then(|i| args.get(i + 1)) |
| 75 | .cloned() |
| 76 | }; |
| 77 | |
| 78 | let instance = flag("--instance").unwrap_or_else(|| brand::NAME.to_string()); |
| 79 | // This becomes a path under /etc and a unit name. Anything outside this set |
| 80 | // would either escape the directory or produce a unit systemd will not load. |
| 81 | if instance.is_empty() |
| 82 | || !instance |
| 83 | .chars() |
| 84 | .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') |
| 85 | { |
| 86 | bail!("--instance must be lowercase letters, digits and dashes"); |
| 87 | } |
| 88 | |
| 89 | let data_dir = flag("--data-dir") |
| 90 | .map(PathBuf::from) |
| 91 | .unwrap_or_else(|| PathBuf::from(format!("/var/lib/{instance}"))); |
| 92 | if data_dir.is_relative() { |
| 93 | bail!("--data-dir must be absolute"); |
| 94 | } |
| 95 | |
| 96 | Ok(Options { |
| 97 | shape, |
| 98 | domain: flag("--domain"), |
| 99 | user: flag("--user").unwrap_or_else(|| instance.clone()), |
| 100 | instance, |
| 101 | data_dir, |
| 102 | bind: flag("--bind").unwrap_or_else(|| "127.0.0.1:8790".into()), |
| 103 | ssh_bind: flag("--ssh-bind").unwrap_or_else(|| "0.0.0.0:2222".into()), |
| 104 | ci_enabled: args.iter().any(|a| a == "--ci"), |
| 105 | dry_run: args.iter().any(|a| a == "--dry-run"), |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// A file the setup will write. |
| 111 | pub struct Artifact { |
| 112 | pub path: PathBuf, |
| 113 | pub contents: String, |
| 114 | /// `0600` for anything holding a credential. |
| 115 | pub mode: u32, |
| 116 | } |
| 117 | |
| 118 | /// Everything `setup` would write, without writing it. |
| 119 | /// |
| 120 | /// Separated from execution so the plan can be printed, diffed and unit-tested — |
| 121 | /// the interesting failures here are "wrong unit file", not "write failed". |
| 122 | pub fn plan(options: &Options) -> Vec<Artifact> { |
| 123 | let name = options.instance.as_str(); |
| 124 | let binary = std::env::current_exe() |
| 125 | .map(|p| p.display().to_string()) |
| 126 | .unwrap_or_else(|_| format!("/usr/local/bin/{name}")); |
| 127 | |
| 128 | let mut artifacts = vec![ |
| 129 | Artifact { |
| 130 | path: PathBuf::from(format!("/etc/{name}.env")), |
| 131 | contents: environment(options), |
| 132 | // Holds no secret today, but it is the file an operator adds one to. |
| 133 | mode: 0o600, |
| 134 | }, |
| 135 | Artifact { |
| 136 | path: PathBuf::from(format!("/etc/systemd/system/{name}.service")), |
| 137 | contents: service_unit(options, &binary), |
| 138 | mode: 0o644, |
| 139 | }, |
| 140 | Artifact { |
| 141 | path: PathBuf::from(format!("/etc/systemd/system/{name}-jobs.service")), |
| 142 | contents: jobs_unit(options, &binary), |
| 143 | mode: 0o644, |
| 144 | }, |
| 145 | ]; |
| 146 | |
| 147 | if let Some(domain) = &options.domain { |
| 148 | artifacts.push(Artifact { |
| 149 | path: PathBuf::from("/etc/caddy/Caddyfile"), |
| 150 | contents: caddyfile(domain, &options.bind, &options.instance), |
| 151 | mode: 0o644, |
| 152 | }); |
| 153 | } |
| 154 | |
| 155 | artifacts |
| 156 | } |
| 157 | |
| 158 | /// The environment file both units read. |
| 159 | fn environment(options: &Options) -> String { |
| 160 | let p = brand::env_prefix(); |
| 161 | let mut lines = vec![ |
| 162 | format!( |
| 163 | "# Written by `{} setup --{}`.", |
| 164 | brand::NAME, |
| 165 | options.shape.as_str() |
| 166 | ), |
| 167 | format!("{p}DATA_DIR={}", options.data_dir.display()), |
| 168 | format!("{p}BIND={}", options.bind), |
| 169 | format!("{p}SSH_BIND={}", options.ssh_bind), |
| 170 | ]; |
| 171 | |
| 172 | if let Some(domain) = &options.domain { |
| 173 | lines.push(format!("{p}PUBLIC_URL=https://{domain}")); |
| 174 | // The SSH port is not proxied by Caddy: git over SSH is not HTTP. |
| 175 | let port = options.ssh_bind.rsplit(':').next().unwrap_or("2222"); |
| 176 | lines.push(format!("{p}PUBLIC_SSH=ssh://git@{domain}:{port}")); |
| 177 | } |
| 178 | |
| 179 | match options.shape { |
| 180 | Shape::Standalone => { |
| 181 | lines.push(format!("{p}MODE=standalone")); |
| 182 | lines.push(format!( |
| 183 | "{p}CI_ENABLED={}", |
| 184 | if options.ci_enabled { "1" } else { "0" } |
| 185 | )); |
| 186 | if options.ci_enabled { |
| 187 | lines.push("# CI runs as this unit's user with rlimits but no container.".into()); |
| 188 | lines.push("# Anyone who can push can run code as that user.".into()); |
| 189 | } |
| 190 | } |
| 191 | Shape::Isolated => { |
| 192 | lines.push(format!("{p}MODE=control")); |
| 193 | lines.push("# The control plane holds no repositories; each account gets".into()); |
| 194 | lines.push("# its own Incus container, and CI runs inside it.".into()); |
| 195 | lines.push(format!("{p}INCUS_IMAGE=images:debian/12")); |
| 196 | lines.push(format!("{p}INCUS_NETWORK=incusbr0")); |
| 197 | } |
| 198 | } |
| 199 | lines.join("\n") + "\n" |
| 200 | } |
| 201 | |
| 202 | fn service_unit(options: &Options, binary: &str) -> String { |
| 203 | let name = options.instance.as_str(); |
| 204 | // The control plane drives Incus, which needs a socket only root or the |
| 205 | // incus-admin group can reach. |
| 206 | let extra = match options.shape { |
| 207 | Shape::Isolated => "SupplementaryGroups=incus-admin\n", |
| 208 | Shape::Standalone => "", |
| 209 | }; |
| 210 | |
| 211 | format!( |
| 212 | "[Unit]\n\ |
| 213 | Description={product} git hosting ({shape}, instance {name})\n\ |
| 214 | After=network-online.target\n\ |
| 215 | Wants=network-online.target\n\ |
| 216 | \n\ |
| 217 | [Service]\n\ |
| 218 | ExecStart={binary}\n\ |
| 219 | EnvironmentFile=/etc/{name}.env\n\ |
| 220 | User={user}\n\ |
| 221 | Restart=always\n\ |
| 222 | RestartSec=2\n\ |
| 223 | # Repositories and credentials live under the data directory and nowhere else.\n\ |
| 224 | ReadWritePaths={data}\n\ |
| 225 | ProtectSystem=strict\n\ |
| 226 | ProtectHome=yes\n\ |
| 227 | PrivateTmp=yes\n\ |
| 228 | NoNewPrivileges=yes\n\ |
| 229 | {extra}\ |
| 230 | \n\ |
| 231 | [Install]\n\ |
| 232 | WantedBy=multi-user.target\n", |
| 233 | name = name, |
| 234 | product = brand::NAME, |
| 235 | shape = options.shape.as_str(), |
| 236 | binary = binary, |
| 237 | user = options.user, |
| 238 | data = options.data_dir.display(), |
| 239 | extra = extra, |
| 240 | ) |
| 241 | } |
| 242 | |
| 243 | fn jobs_unit(options: &Options, binary: &str) -> String { |
| 244 | let name = options.instance.as_str(); |
| 245 | format!( |
| 246 | "[Unit]\n\ |
| 247 | Description={name} maintenance\n\ |
| 248 | After={name}.service\n\ |
| 249 | \n\ |
| 250 | [Service]\n\ |
| 251 | # Separate from the web unit: a long repack must not stall request handling.\n\ |
| 252 | ExecStart={binary} jobs\n\ |
| 253 | EnvironmentFile=/etc/{name}.env\n\ |
| 254 | User={user}\n\ |
| 255 | Restart=always\n\ |
| 256 | RestartSec=10\n\ |
| 257 | ReadWritePaths={data}\n\ |
| 258 | ProtectSystem=strict\n\ |
| 259 | ProtectHome=yes\n\ |
| 260 | NoNewPrivileges=yes\n\ |
| 261 | \n\ |
| 262 | [Install]\n\ |
| 263 | WantedBy=multi-user.target\n", |
| 264 | name = name, |
| 265 | binary = binary, |
| 266 | user = options.user, |
| 267 | data = options.data_dir.display(), |
| 268 | ) |
| 269 | } |
| 270 | |
| 271 | /// Caddy in front, for TLS. |
| 272 | /// |
| 273 | /// Caddy rather than nginx because it obtains and renews certificates without a |
| 274 | /// second tool and a cron entry, which is the part self-hosters get wrong. |
| 275 | fn caddyfile(domain: &str, upstream: &str, instance: &str) -> String { |
| 276 | format!( |
| 277 | "# Written by `{product} setup`. Caddy obtains and renews TLS automatically.\n\ |
| 278 | {domain} {{\n\ |
| 279 | \treverse_proxy {upstream} {{\n\ |
| 280 | \t\t# Git streams: a clone can run for minutes and a push is one long\n\ |
| 281 | \t\t# request. Buffering either would undo the streaming the service does.\n\ |
| 282 | \t\tflush_interval -1\n\ |
| 283 | \t}}\n\ |
| 284 | \n\ |
| 285 | \t# A push is a large body. Caddy does not cap it by default; this is the\n\ |
| 286 | \t# ceiling the service itself enforces, stated here so the proxy agrees.\n\ |
| 287 | \trequest_body {{\n\ |
| 288 | \t\tmax_size 512MB\n\ |
| 289 | \t}}\n\ |
| 290 | \n\ |
| 291 | \tencode zstd gzip\n\ |
| 292 | \n\ |
| 293 | \tlog {{\n\ |
| 294 | \t\toutput file /var/log/caddy/{name}.log\n\ |
| 295 | \t}}\n\ |
| 296 | }}\n", |
| 297 | product = brand::NAME, |
| 298 | name = instance, |
| 299 | domain = domain, |
| 300 | upstream = upstream, |
| 301 | ) |
| 302 | } |
| 303 | |
| 304 | /// Shell commands the operator still has to run, in order. |
| 305 | /// |
| 306 | /// Printed rather than executed: creating users, enabling units and initialising |
| 307 | /// Incus are the steps someone may reasonably want to do differently, and a setup |
| 308 | /// command that does them silently is hard to undo. |
| 309 | pub fn next_steps(options: &Options) -> Vec<String> { |
| 310 | let name = options.instance.as_str(); |
| 311 | let mut steps = vec![ |
| 312 | format!( |
| 313 | "sudo useradd --system --home {} --shell /usr/sbin/nologin {} 2>/dev/null || true", |
| 314 | options.data_dir.display(), |
| 315 | options.user |
| 316 | ), |
| 317 | format!( |
| 318 | "sudo mkdir -p {} && sudo chown -R {}:{} {}", |
| 319 | options.data_dir.display(), |
| 320 | options.user, |
| 321 | options.user, |
| 322 | options.data_dir.display() |
| 323 | ), |
| 324 | ]; |
| 325 | |
| 326 | if options.shape == Shape::Isolated { |
| 327 | steps.push("sudo apt-get install -y incus".into()); |
| 328 | steps.push("sudo incus admin init --auto".into()); |
| 329 | steps.push(format!("sudo usermod -aG incus-admin {}", options.user)); |
| 330 | } |
| 331 | |
| 332 | steps.push("sudo systemctl daemon-reload".into()); |
| 333 | steps.push(format!("sudo systemctl enable --now {name} {name}-jobs")); |
| 334 | |
| 335 | if options.domain.is_some() { |
| 336 | steps.push("sudo apt-get install -y caddy".into()); |
| 337 | steps.push("sudo systemctl reload caddy".into()); |
| 338 | } |
| 339 | |
| 340 | steps.push(format!( |
| 341 | "sudo -u {} {} token <account> --scopes admin", |
| 342 | options.user, |
| 343 | std::env::current_exe() |
| 344 | .map(|p| p.display().to_string()) |
| 345 | .unwrap_or_else(|_| format!("/usr/local/bin/{name}")) |
| 346 | )); |
| 347 | steps |
| 348 | } |
| 349 | |
| 350 | /// Write the plan to disk. |
| 351 | pub fn apply(options: &Options) -> Result<()> { |
| 352 | let artifacts = plan(options); |
| 353 | |
| 354 | println!( |
| 355 | "# {} setup — {} (instance {})", |
| 356 | brand::NAME, |
| 357 | options.shape.as_str(), |
| 358 | options.instance |
| 359 | ); |
| 360 | println!(); |
| 361 | |
| 362 | for artifact in &artifacts { |
| 363 | if options.dry_run { |
| 364 | println!("--- {} (mode {:o})", artifact.path.display(), artifact.mode); |
| 365 | println!("{}", artifact.contents); |
| 366 | continue; |
| 367 | } |
| 368 | |
| 369 | write(&artifact.path, &artifact.contents, artifact.mode) |
| 370 | .with_context(|| format!("write {}", artifact.path.display()))?; |
| 371 | println!("wrote {}", artifact.path.display()); |
| 372 | } |
| 373 | |
| 374 | println!(); |
| 375 | println!("# then:"); |
| 376 | for step in next_steps(options) { |
| 377 | println!(" {step}"); |
| 378 | } |
| 379 | |
| 380 | if options.shape == Shape::Isolated { |
| 381 | println!(); |
| 382 | println!("# the control plane prints the public key each tenant needs:"); |
| 383 | println!("# {}CONTROL_PUBLIC_KEYS=<key>", brand::env_prefix()); |
| 384 | } |
| 385 | if options.dry_run { |
| 386 | println!(); |
| 387 | println!("# --dry-run: nothing was written."); |
| 388 | } |
| 389 | Ok(()) |
| 390 | } |
| 391 | |
| 392 | fn write(path: &Path, contents: &str, mode: u32) -> Result<()> { |
| 393 | if let Some(dir) = path.parent() { |
| 394 | std::fs::create_dir_all(dir)?; |
| 395 | } |
| 396 | |
| 397 | // Never clobber an existing file silently: a second `setup` on a live host must |
| 398 | // not discard hand-edited configuration. |
| 399 | if path.exists() { |
| 400 | let backup = path.with_extension("bak"); |
| 401 | std::fs::copy(path, &backup)?; |
| 402 | println!("backed up {} -> {}", path.display(), backup.display()); |
| 403 | } |
| 404 | |
| 405 | std::fs::write(path, contents)?; |
| 406 | |
| 407 | #[cfg(unix)] |
| 408 | { |
| 409 | use std::os::unix::fs::PermissionsExt; |
| 410 | std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?; |
| 411 | } |
| 412 | Ok(()) |
| 413 | } |
| 414 | |
| 415 | /// Sanity-check a host before installing. |
| 416 | pub fn preflight(shape: Shape) -> Vec<(String, bool, String)> { |
| 417 | let mut checks = vec![]; |
| 418 | |
| 419 | let git = std::process::Command::new("git").arg("--version").output(); |
| 420 | checks.push(( |
| 421 | "git".into(), |
| 422 | git.as_ref().map(|o| o.status.success()).unwrap_or(false), |
| 423 | git.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) |
| 424 | .unwrap_or_else(|_| "not found — the wire protocol needs it".into()), |
| 425 | )); |
| 426 | |
| 427 | if shape == Shape::Isolated { |
| 428 | let incus = std::process::Command::new("incus") |
| 429 | .arg("--version") |
| 430 | .output(); |
| 431 | checks.push(( |
| 432 | "incus".into(), |
| 433 | incus.as_ref().map(|o| o.status.success()).unwrap_or(false), |
| 434 | incus |
| 435 | .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) |
| 436 | .unwrap_or_else(|_| "not found — --isolated needs it".into()), |
| 437 | )); |
| 438 | } |
| 439 | |
| 440 | checks.push(( |
| 441 | "systemd".into(), |
| 442 | Path::new("/run/systemd/system").exists(), |
| 443 | "required for the service units".into(), |
| 444 | )); |
| 445 | checks |
| 446 | } |
| 447 | |
| 448 | #[cfg(test)] |
| 449 | mod tests { |
| 450 | use super::*; |
| 451 | |
| 452 | fn options(shape: Shape) -> Options { |
| 453 | Options { |
| 454 | shape, |
| 455 | instance: brand::NAME.to_string(), |
| 456 | domain: Some("git.example.com".into()), |
| 457 | user: brand::NAME.to_string(), |
| 458 | data_dir: PathBuf::from("/var/lib/test"), |
| 459 | bind: "127.0.0.1:8790".into(), |
| 460 | ssh_bind: "0.0.0.0:2222".into(), |
| 461 | ci_enabled: false, |
| 462 | dry_run: true, |
| 463 | } |
| 464 | } |
| 465 | |
| 466 | #[test] |
| 467 | fn a_shape_must_be_chosen_explicitly() { |
| 468 | // Defaulting would pick an isolation posture on the operator's behalf. |
| 469 | assert!(Options::from_args(&[]).is_err()); |
| 470 | assert!(Options::from_args(&["--standalone".into()]).is_ok()); |
| 471 | assert!(Options::from_args(&["--isolated".into()]).is_ok()); |
| 472 | } |
| 473 | |
| 474 | #[test] |
| 475 | fn a_relative_data_dir_is_refused() { |
| 476 | let args = vec![ |
| 477 | "--standalone".into(), |
| 478 | "--data-dir".into(), |
| 479 | "relative".into(), |
| 480 | ]; |
| 481 | assert!(Options::from_args(&args).is_err()); |
| 482 | } |
| 483 | |
| 484 | #[test] |
| 485 | fn a_second_instance_shares_no_path_with_the_first() { |
| 486 | // Self-hosting runs a build instance beside the control plane. If they |
| 487 | // collided on a unit name, an env file or a data directory, installing the |
| 488 | // second would silently take over the first. |
| 489 | let instance = format!("{}-build", brand::NAME); |
| 490 | let mut build = options(Shape::Standalone); |
| 491 | build.data_dir = PathBuf::from(format!("/var/lib/{instance}")); |
| 492 | build.user = instance.clone(); |
| 493 | build.instance = instance; |
| 494 | |
| 495 | let first: Vec<String> = plan(&options(Shape::Isolated)) |
| 496 | .iter() |
| 497 | .map(|a| a.path.display().to_string()) |
| 498 | .collect(); |
| 499 | let second: Vec<String> = plan(&build) |
| 500 | .iter() |
| 501 | .map(|a| a.path.display().to_string()) |
| 502 | .collect(); |
| 503 | |
| 504 | for path in &second { |
| 505 | // The Caddyfile is genuinely shared — one proxy serves the host — so it |
| 506 | // is the single permitted overlap. |
| 507 | if path.contains("Caddyfile") { |
| 508 | continue; |
| 509 | } |
| 510 | assert!(!first.contains(path), "{path} would be written by both"); |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | #[test] |
| 515 | fn an_instance_name_that_could_escape_its_directory_is_refused() { |
| 516 | // The name becomes /etc/<name>.env and a unit filename. |
| 517 | for bad in ["../../etc/passwd", "with space", "Upper", ""] { |
| 518 | assert!( |
| 519 | Options::from_args(&["--standalone".into(), "--instance".into(), bad.to_string()]) |
| 520 | .is_err(), |
| 521 | "{bad:?} must be refused" |
| 522 | ); |
| 523 | } |
| 524 | } |
| 525 | |
| 526 | #[test] |
| 527 | fn the_user_and_data_directory_follow_the_instance_name() { |
| 528 | let instance = format!("{}-build", brand::NAME); |
| 529 | let o = Options::from_args(&["--standalone".into(), "--instance".into(), instance.clone()]) |
| 530 | .unwrap(); |
| 531 | assert_eq!(o.user, instance); |
| 532 | assert_eq!(o.data_dir, PathBuf::from(format!("/var/lib/{instance}"))); |
| 533 | } |
| 534 | |
| 535 | #[test] |
| 536 | fn standalone_writes_units_and_an_environment_file() { |
| 537 | let files: Vec<String> = plan(&options(Shape::Standalone)) |
| 538 | .iter() |
| 539 | .map(|a| a.path.display().to_string()) |
| 540 | .collect(); |
| 541 | |
| 542 | assert!(files.iter().any(|f| f.ends_with(".env"))); |
| 543 | assert!(files |
| 544 | .iter() |
| 545 | .any(|f| f.contains("systemd") && !f.contains("jobs"))); |
| 546 | assert!(files.iter().any(|f| f.contains("jobs.service"))); |
| 547 | assert!(files.iter().any(|f| f.contains("Caddyfile"))); |
| 548 | } |
| 549 | |
| 550 | #[test] |
| 551 | fn the_environment_file_is_not_world_readable() { |
| 552 | // It is the file an operator will put a secret in. |
| 553 | let env = plan(&options(Shape::Standalone)) |
| 554 | .into_iter() |
| 555 | .find(|a| a.path.display().to_string().ends_with(".env")) |
| 556 | .expect("an env file is written"); |
| 557 | assert_eq!( |
| 558 | env.mode & 0o077, |
| 559 | 0, |
| 560 | "the env file must not be readable by others" |
| 561 | ); |
| 562 | } |
| 563 | |
| 564 | #[test] |
| 565 | fn no_caddyfile_without_a_domain() { |
| 566 | // Caddy would otherwise try to obtain a certificate for nothing. |
| 567 | let mut opts = options(Shape::Standalone); |
| 568 | opts.domain = None; |
| 569 | |
| 570 | let files: Vec<String> = plan(&opts) |
| 571 | .iter() |
| 572 | .map(|a| a.path.display().to_string()) |
| 573 | .collect(); |
| 574 | assert!(!files.iter().any(|f| f.contains("Caddyfile"))); |
| 575 | } |
| 576 | |
| 577 | #[test] |
| 578 | fn the_caddyfile_does_not_buffer_git_streams() { |
| 579 | let caddy = caddyfile("git.example.com", "127.0.0.1:8790", brand::NAME); |
| 580 | assert!( |
| 581 | caddy.contains("flush_interval -1"), |
| 582 | "buffering would undo the streaming the service does: {caddy}" |
| 583 | ); |
| 584 | assert!( |
| 585 | caddy.contains("max_size"), |
| 586 | "a push needs a stated body ceiling" |
| 587 | ); |
| 588 | } |
| 589 | |
| 590 | #[test] |
| 591 | fn standalone_and_isolated_choose_different_modes() { |
| 592 | let standalone = environment(&options(Shape::Standalone)); |
| 593 | assert!(standalone.contains("MODE=standalone")); |
| 594 | assert!(!standalone.contains("INCUS")); |
| 595 | |
| 596 | let isolated = environment(&options(Shape::Isolated)); |
| 597 | assert!(isolated.contains("MODE=control")); |
| 598 | assert!(isolated.contains("INCUS_IMAGE")); |
| 599 | } |
| 600 | |
| 601 | #[test] |
| 602 | fn only_the_isolated_service_gets_the_incus_group() { |
| 603 | // The control plane drives Incus; a standalone service has no business |
| 604 | // holding a socket that can create containers. |
| 605 | assert!(service_unit(&options(Shape::Isolated), "/usr/local/bin/x") |
| 606 | .contains("SupplementaryGroups=incus-admin")); |
| 607 | assert!( |
| 608 | !service_unit(&options(Shape::Standalone), "/usr/local/bin/x").contains("incus-admin") |
| 609 | ); |
| 610 | } |
| 611 | |
| 612 | #[test] |
| 613 | fn the_units_confine_the_service_to_its_data_directory() { |
| 614 | let unit = service_unit(&options(Shape::Standalone), "/usr/local/bin/x"); |
| 615 | for directive in [ |
| 616 | "ProtectSystem=strict", |
| 617 | "ProtectHome=yes", |
| 618 | "NoNewPrivileges=yes", |
| 619 | "ReadWritePaths=/var/lib/test", |
| 620 | ] { |
| 621 | assert!(unit.contains(directive), "missing {directive}:\n{unit}"); |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | #[test] |
| 626 | fn maintenance_is_a_separate_unit() { |
| 627 | let jobs = jobs_unit(&options(Shape::Standalone), "/usr/local/bin/x"); |
| 628 | assert!( |
| 629 | jobs.contains(" jobs\n"), |
| 630 | "the jobs unit must run the jobs command" |
| 631 | ); |
| 632 | assert!(!service_unit(&options(Shape::Standalone), "/usr/local/bin/x").contains(" jobs\n")); |
| 633 | } |
| 634 | |
| 635 | #[test] |
| 636 | fn enabling_ci_says_what_it_costs() { |
| 637 | let mut opts = options(Shape::Standalone); |
| 638 | opts.ci_enabled = true; |
| 639 | let env = environment(&opts); |
| 640 | |
| 641 | assert!(env.contains("CI_ENABLED=1")); |
| 642 | assert!( |
| 643 | env.contains("no container"), |
| 644 | "standalone CI is not a security boundary and the file should say so" |
| 645 | ); |
| 646 | } |
| 647 | |
| 648 | #[test] |
| 649 | fn isolated_setup_tells_the_operator_to_install_incus() { |
| 650 | let steps = next_steps(&options(Shape::Isolated)).join("\n"); |
| 651 | assert!(steps.contains("incus admin init")); |
| 652 | assert!(steps.contains("usermod -aG incus-admin")); |
| 653 | |
| 654 | let standalone = next_steps(&options(Shape::Standalone)).join("\n"); |
| 655 | assert!(!standalone.contains("incus")); |
| 656 | } |
| 657 | |
| 658 | #[test] |
| 659 | fn nothing_in_the_plan_hardcodes_the_product_name() { |
| 660 | // A rename must carry the setup with it. |
| 661 | for artifact in plan(&options(Shape::Standalone)) { |
| 662 | let path = artifact.path.display().to_string(); |
| 663 | if path.contains("Caddyfile") { |
| 664 | continue; |
| 665 | } |
| 666 | assert!( |
| 667 | path.contains(brand::NAME), |
| 668 | "{path} does not track the product name" |
| 669 | ); |
| 670 | } |
| 671 | } |
| 672 | } |