diff --git a/AGENTS.md b/AGENTS.md index 8cc025b..028b520 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ UDS source code is maintained for people first, including developers who are new - Document every function and method with `///`, regardless of visibility. This includes test functions, test helpers, and standard trait implementations. Explain the UDS-specific purpose, behavior, or guarantee instead of merely repeating the function name. - Document public and private modules, constants, fields, and enum variants as well. The Rust `missing_docs` lint covers the public API and Clippy's `missing_docs_in_private_items` covers internal code; both are required at deny level and must pass for every target. - Treat a documented or annotated struct field or enum variant as one visual block: keep its documentation, attributes, and declaration together, and separate it from the next block with a blank line. Undocumented and unannotated fields may remain compact. +- `build.rs` enforces this visual block rule for Rust files below `src/` and `tests/` during Cargo builds. - Start every non-trivial module with a `//!` comment describing its responsibility and its place in UDS. - Prefer named intermediate values over deeply nested expressions when the names make the data flow easier to follow. - Keep each module focused on one responsibility. Treat roughly 500 lines of production code as a prompt to look for a useful responsibility boundary, not as a mechanical limit. diff --git a/CHANGELOG.md b/CHANGELOG.md index 003b440..ff163c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,5 +2,6 @@ ## Initial version +- Added a manual update feature for configuration-assistant-installed, systemd-managed Linux single nodes, with explicit regular or prerelease selection, signed staging, persistent operations, restart observation, and rollback. - Provides the update delivery server and interactive administration client for publishing, withdrawing, copying, and managing MindWork AI Studio releases. - Supports clustered and single-node operation, node discovery, replication, TLS, structured logging, download statistics, graceful shutdown, and an interactive configuration assistant. diff --git a/Cargo.lock b/Cargo.lock index 08a4819..3433913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -235,6 +241,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" @@ -413,6 +425,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -449,6 +470,44 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -544,6 +603,31 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -581,12 +665,38 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flexi_logger" version = "0.31.9" @@ -1213,6 +1323,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1313,6 +1433,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1325,6 +1454,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1518,6 +1657,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1846,6 +1986,18 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -1890,6 +2042,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1939,6 +2101,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2307,6 +2480,8 @@ dependencies = [ "clap", "crossterm", "directories", + "ed25519-dalek", + "flate2", "flexi_logger", "futures-util", "getrandom 0.4.3", @@ -2318,6 +2493,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tar", "tempfile", "thiserror", "time", @@ -2687,6 +2863,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 198d26c..efe02ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ axum = { version = "0.8", features = ["macros", "multipart"] } axum-server = { version = "0.8", features = ["tls-rustls"] } bytes = "1" base64 = "0.22" +ed25519-dalek = { version = "3", features = ["pkcs8", "pem"] } +flate2 = "1" crossterm = "0.29" clap = { version = "4.5", features = ["derive"] } directories = "6" @@ -34,11 +36,12 @@ hex = "0.4" getrandom = "0.4" inquire = "0.9" libc = "0.2" -reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "rustls", "stream"] } +reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "multipart", "query", "rustls", "stream"] } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" +tar = "0.4" tempfile = "3" thiserror = "2" time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde"] } diff --git a/README.md b/README.md index d243200..5f93ae8 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ This repository contains the initial Rust implementation. The single-node update ## Supported Platforms and Production Use -For production deployments, we strongly recommend running UDS on a maintained Linux server. The macOS and Windows builds are intended for development, testing, and evaluation. Automatic UDS updates are available only for supported Linux installations. +For production deployments, we strongly recommend running UDS on a maintained Linux server. The macOS and Windows builds are intended for development, testing, and evaluation. The manual UDS Update Feature is available only for supported Linux installations. Release binaries are provided for Linux on x86_64 and ARM64, macOS on Apple Silicon, and Windows on x86_64 and ARM64. Linux releases use the standard GNU target and require glibc; Alpine Linux and other systems that provide only musl are not supported. Each release documents the minimum glibc symbol version detected during its build. -Automatic updates are planned for systemd-managed Linux installations. Manually launched processes must be updated and restarted manually. Containerized deployments must use their image and orchestrator rollout instead of replacing the executable inside a running container. +The UDS Update Feature supports only single nodes installed by the configuration assistant as `/usr/local/bin/uds` and managed by systemd. It never updates unattended or on a schedule: an administrator must select and confirm one exact version with `uds client updates`. Manually launched processes must be replaced and restarted by their administrator. Containerized deployments must use their image and orchestrator rollout instead of replacing the executable inside a running container. ## UDS Release Process @@ -254,21 +254,29 @@ uds client tokens disable The first client run offers to create a local profile. The profile stores the UDS base URL, one personal admin token, and the default channel in a user-local config file. Owner tokens are never stored in client profiles or any separate client configuration. UDS hardens this file so only the current user can read or write it on Linux and macOS, and uses `icacls` for equivalent best-effort ACL hardening on Windows. -### Planned UDS Self-Updates +### UDS Update Feature -Self-updating UDS nodes are a planned Linux-only capability. The release pipeline already provides a signed `latest.json` manifest for this future workflow, but the client commands and server endpoints described here are not implemented yet. +Run `uds client updates` to update a supported single node manually. The client initially lists only newer regular releases. The administrator may switch to a separate prerelease list, switch back, choose one exact version, review its version, build, and release notes, and then explicitly confirm or cancel. UDS never chooses a version, updates unattended, or runs updates on a schedule. -The planned `uds client updates` workflow keeps every administrator request on the Admin API behind the load balancer: +The authenticated Admin API implements: -1. The client requests the fleet view through the load balancer. The receiving node becomes the coordinator and returns stable node IDs, versions, builds, update capability, health, and last-seen timestamps. -2. The administrator selects one node, several nodes, or the entire fleet. Prereleases are excluded unless the administrator explicitly enables the Canary option. -3. The client submits the update operation and selected node IDs through the load balancer. It never needs private node addresses. -4. The receiving coordinator resolves each node ID to the private Fleet API URL learned through discovery. It executes a local target itself and forwards other targets directly through the Fleet API. Broadcast is used for discovery, not for update commands or release archives. -5. Each target verifies the Ed25519 manifest signature, platform, architecture, and SHA-256 digest before staging the new executable. A systemd-managed Linux node atomically swaps the executable, retains the previous version for rollback, and asks systemd to restart the service. +- `GET /admin/v1/updates/releases?kind=regular|prerelease` for local node identity, current version/build, capability, and newer releases in exactly one category. +- `POST /admin/v1/updates` with a client-generated operation UUID, the local node ID, exact version, and explicit prerelease permission. +- `GET /admin/v1/updates/{operation_id}` for durable status (`queued`, `downloading`, `staged`, `applying`, `boot_confirmed`, `succeeded`, `rolled_back`, or `failed`). -Update operations will have stable operation IDs, be idempotent, and expose fleet-wide status so polling through the load balancer does not require a sticky session. Fleet-wide rollouts will use configurable batches with health checks and a pause between batches. Automatic updates will not be offered on macOS or Windows; those builds remain intended for development, testing, and evaluation. +Repeated identical POSTs are idempotent; reusing an operation UUID for different input returns `409 Conflict`, and a node accepts only one active operation. Polling tolerates the expected connection interruption while systemd restarts UDS. -This workflow depends on completing peer discovery. Nodes must receive presence broadcasts, expire stale peers, and retain the mapping from each stable node ID to its advertised private `fleet_base_url` before targeted updates can be implemented safely. +UDS retrieves official GitHub releases page by page, excludes drafts and invalid or non-newer SemVer tags, and keeps regular releases separate from prereleases. Before staging, it verifies the Ed25519 signature over `latest.json` with the embedded public key and checks schema, tag, Linux platform, architecture, update support, archive size, and SHA-256. The unprivileged service atomically stores only the verified manifest, signature, archive, and operation data below `data_dir/self-update/operations`. + +The configuration assistant installs a hardened readiness-aware `uds.service`, a root `uds-update.service` oneshot, and a bounded `uds-update.path` trigger. The helper verifies the signed inputs again, reads only the signed regular executable entry from the archive, rejects unsafe paths and links, retains the old executable as `/usr/local/bin/uds.previous`, and restarts UDS. If the new service does not remain active for 30 seconds, the helper restores the previous executable. A successful update retains `uds.previous` until the next successful update; a successful rollback restores the regular `uds` path and leaves no `uds.previous`. + +Other executable locations, manually started services, containers, macOS, Windows, and fleet mode do not support this feature. + +#### Planned Fleet Updates + +Fleet updates remain planned. Administrators will select nodes through the load balancer, submit stable operations, configure batches, require health checks, and pause between batches. The coordinator will resolve stable node IDs to private Fleet API addresses and direct work to each target; clients will not need private addresses. + +This requires complete peer discovery, stale-peer expiry, and real replication first. Until those prerequisites exist, fleet mode explicitly reports the UDS Update Feature as unavailable. The signed release manifest and signing process are the shared trust foundation for today's single-node workflow and this later fleet workflow. ### Configure the Client diff --git a/build.rs b/build.rs index 6942d11..e49be00 100644 --- a/build.rs +++ b/build.rs @@ -1,13 +1,33 @@ //! Build-time integration that exposes the UDS build number to the executable. use std::{env, fs, path::Path}; +#[path = "build_support/source_layout.rs"] +mod source_layout; + fn main() { println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=tests"); + + let manifest_directory = env::var("CARGO_MANIFEST_DIR").unwrap(); + let manifest_directory = Path::new(&manifest_directory); + + // Report every source-layout problem in one deterministic batch so a + // developer can fix the complete set before rebuilding. + let violations = source_layout::check_roots(manifest_directory, &["src", "tests"]) + .expect("failed to inspect Rust sources for UDS layout rules"); + if !violations.is_empty() { + eprint!("{}", source_layout::format_report(&violations)); + + panic!( + "source layout check failed with {} violation(s)", + violations.len() + ); + } // Read the manifest explicitly because the UDS build number lives in // package metadata instead of Cargo's standard package fields. - let manifest_directory = env::var("CARGO_MANIFEST_DIR").unwrap(); - let manifest_path = Path::new(&manifest_directory).join("Cargo.toml"); + let manifest_path = manifest_directory.join("Cargo.toml"); let manifest = fs::read_to_string(manifest_path).expect("failed to read Cargo.toml"); let metadata = manifest diff --git a/build_support/source_layout.rs b/build_support/source_layout.rs new file mode 100644 index 0000000..76db3ee --- /dev/null +++ b/build_support/source_layout.rs @@ -0,0 +1,239 @@ +//! Checks the UDS-specific spacing of documented and annotated data members. + +use std::{fs, io, path::Path}; + +/// One source-layout problem found while scanning Rust sources. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Violation { + /// Repository-relative path containing the problem. + pub path: String, + + /// One-based line on which the affected member block starts. + pub line: usize, + + /// Human-readable explanation of the required layout. + pub description: String, +} + +/// Formats a deterministic batch report, including its total violation count. +pub fn format_report(violations: &[Violation]) -> String { + let mut report = String::new(); + + for violation in violations { + report.push_str(&format!( + "{}:{}: {}\n", + violation.path, violation.line, violation.description + )); + } + + report.push_str(&format!( + "source layout check failed with {} violation(s)\n", + violations.len() + )); + + report +} + +/// Recursively checks Rust files below each supplied repository-relative root. +pub fn check_roots(manifest_dir: &Path, roots: &[&str]) -> io::Result> { + let mut files = Vec::new(); + + for root in roots { + collect_rust_files(&manifest_dir.join(root), &mut files)?; + } + + files.sort(); + + let mut violations = Vec::new(); + for file in files { + let source = fs::read_to_string(&file)?; + let relative = file + .strip_prefix(manifest_dir) + .unwrap_or(&file) + .to_string_lossy() + .replace('\\', "/"); + violations.extend(check_source(&relative, &source)); + } + violations.sort(); + + Ok(violations) +} + +/// Checks one Rust source string for spacing violations. +pub fn check_source(path: &str, source: &str) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + let mut violations = Vec::new(); + let mut container_depth = None; + let mut pending_container = false; + let mut brace_depth = 0_i32; + let mut block_start = None; + let mut block_decorated = false; + let mut attribute_nesting = 0_i32; + let mut member_started = false; + let mut member_nesting = 0_i32; + let mut previous_member: Option<(usize, bool)> = None; + + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + let code = code_before_line_comment(line); + let trimmed = code.trim(); + let source_trimmed = line.trim(); + let depth_at_start = brace_depth; + + if container_depth.is_none() && (pending_container || starts_braced_data_type(trimmed)) { + pending_container = !code.contains('{'); + if code.contains('{') { + container_depth = Some(depth_at_start + 1); + pending_container = false; + block_start = None; + block_decorated = false; + attribute_nesting = 0; + member_started = false; + previous_member = None; + } + } + + if let Some(member_depth) = container_depth + && depth_at_start == member_depth + && !source_trimmed.is_empty() + && !source_trimmed.starts_with('}') + { + let decoration = source_trimmed.starts_with("///") || source_trimmed.starts_with("#["); + + if !member_started && (decoration || block_start.is_some()) { + block_start.get_or_insert(line_number); + block_decorated |= decoration; + } + + let attribute_line = !member_started && (attribute_nesting > 0 || source_trimmed.starts_with("#[")); + if attribute_line { + attribute_nesting += delimiter_delta(code); + } + + if !member_started && !is_block_prefix(source_trimmed) && !attribute_line { + let current_start = block_start.unwrap_or(line_number); + if block_start.is_some() + && lines[current_start - 1..line_number - 1] + .iter() + .any(|line| line.trim().is_empty()) + { + violations.push(Violation { + path: path.to_owned(), + line: current_start, + description: "documentation, attributes, and the member declaration must stay together without blank lines" + .to_owned(), + }); + } + + if let Some((previous_end, previous_decorated)) = previous_member + && (previous_decorated || block_decorated) + { + let separating_lines = current_start.saturating_sub(previous_end + 1); + let exactly_one_blank = separating_lines == 1 && lines[previous_end].trim().is_empty(); + + if !exactly_one_blank { + violations.push(Violation { + path: path.to_owned(), + line: current_start, + description: format!( + "expected exactly one blank line before this documented or annotated struct field or enum variant; found {separating_lines} separating line(s)" + ), + }); + } + } + + member_started = true; + member_nesting = delimiter_delta(code); + } else if member_started { + member_nesting += delimiter_delta(code); + } + + if member_started && member_nesting <= 0 && code.contains(',') { + previous_member = Some((line_number, block_decorated)); + block_start = None; + block_decorated = false; + attribute_nesting = 0; + member_started = false; + member_nesting = 0; + } + } + + brace_depth += brace_delta(code); + + if let Some(member_depth) = container_depth + && brace_depth < member_depth + { + container_depth = None; + block_start = None; + block_decorated = false; + attribute_nesting = 0; + member_started = false; + previous_member = None; + } + } + + violations +} + +/// Adds all Rust files below one source root to the shared work list. +fn collect_rust_files(directory: &Path, files: &mut Vec) -> io::Result<()> { + if !directory.exists() { + return Ok(()); + } + + let mut entries = fs::read_dir(directory)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_rust_files(&path, files)?; + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } + + Ok(()) +} + +/// Recognizes a braced struct or enum declaration without matching type aliases. +fn starts_braced_data_type(line: &str) -> bool { + let words: Vec<&str> = line + .split(|character: char| !character.is_alphanumeric() && character != '_') + .collect(); + + words.contains(&"struct") || words.contains(&"enum") +} + +/// Returns whether a top-level line still belongs to documentation or attributes. +fn is_block_prefix(line: &str) -> bool { + line.starts_with("///") + || line.starts_with("#[") + || line.starts_with("//") + || line.starts_with("/*") + || line.starts_with('*') + || line.starts_with("*/") +} + +/// Removes a line comment so braces in prose cannot affect nesting. +fn code_before_line_comment(line: &str) -> &str { + line.split_once("//").map_or(line, |(code, _)| code) +} + +/// Counts curly braces on a source line after line comments were removed. +fn brace_delta(line: &str) -> i32 { + line.chars().fold(0, |depth, character| match character { + '{' => depth + 1, + '}' => depth - 1, + _ => depth, + }) +} + +/// Counts all delimiters used by a potentially multiline member declaration. +fn delimiter_delta(line: &str) -> i32 { + line.chars().fold(0, |depth, character| match character { + '(' | '[' | '{' | '<' => depth + 1, + ')' | ']' | '}' | '>' => depth - 1, + _ => depth, + }) +} diff --git a/src/application/mod.rs b/src/application/mod.rs index be82cae..62b6d0c 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -31,6 +31,10 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { update_delivery_system::server_configure::run(configure_args).await?; Ok(()) } + Some(ServerCommand::ApplyUpdates(apply_args)) => { + update_delivery_system::self_update::apply_pending(&apply_args.data_dir, &apply_args.binary)?; + Ok(()) + } None => run_server(args).await, }, Some(CliCommand::Client { command }) => { @@ -65,6 +69,8 @@ async fn run_server(args: ServerArgs) -> anyhow::Result<()> { update_delivery_system::stats::StatsRecorder::new(config.data_dir.clone(), config.stats.clone()).await?; let cluster = ClusterState::new(&config).await?; let auth = update_delivery_system::auth::AdminTokenStore::open(&config.data_dir).await?; + let update_node_id = uuid::Uuid::parse_str(cluster.node_id())?; + let updates = update_delivery_system::self_update::UpdateManager::open(&config, update_node_id).await?; tracing::info!( mode = ?config.mode, public_base_url = %config.public_base_url, @@ -91,6 +97,7 @@ async fn run_server(args: ServerArgs) -> anyhow::Result<()> { logging: logging.clone(), shutdown: shutdown.clone(), auth: Arc::new(auth), + updates: Arc::new(updates), }; let stats = state.stats.clone(); warn_insecure_listener("public", config.public_api.bind, &config.public_api.tls); @@ -98,7 +105,6 @@ async fn run_server(args: ServerArgs) -> anyhow::Result<()> { if let Some(fleet) = &config.fleet_api { warn_insecure_listener("fleet", fleet.bind, &fleet.tls); } - // // Start each configured listener with an independent shutdown handle. A // listener failure triggers the same controlled drain as an OS signal. @@ -136,6 +142,8 @@ async fn run_server(args: ServerArgs) -> anyhow::Result<()> { handle, )); } + tokio::task::yield_now().await; + notify_ready(); let grace_period = Duration::from_secs(config.shutdown.grace_period_seconds); let first = tokio::select! { result = listeners.join_next() => Err(anyhow::anyhow!("listener ended unexpectedly: {:?}", result)), @@ -205,6 +213,23 @@ async fn run_server(args: ServerArgs) -> anyhow::Result<()> { Ok(()) } +/// Signals systemd only after initialization and all listener tasks were started. +#[cfg(unix)] +fn notify_ready() { + use std::os::unix::net::UnixDatagram; + let Some(socket) = std::env::var_os("NOTIFY_SOCKET") else { + return; + }; + let path = std::path::PathBuf::from(socket); + if let Ok(sender) = UnixDatagram::unbound() { + let _ = sender.send_to(b"READY=1", path); + } +} + +/// Does nothing on platforms without systemd notification sockets. +#[cfg(not(unix))] +fn notify_ready() {} + /// Performs the warn insecure listener operation required by UDS. fn warn_insecure_listener(name: &str, bind: std::net::SocketAddr, tls: &update_delivery_system::config::TlsConfig) { if tls.mode == update_delivery_system::config::TlsMode::Off && !bind.ip().is_loopback() { diff --git a/src/auth.rs b/src/auth.rs index d6c95f6..fd162df 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -138,7 +138,6 @@ pub struct AdminTokenMetadata { skip_serializing_if = "Option::is_none", with = "time::serde::rfc3339::option" )] - /// The disabled at carried by this UDS data contract. pub disabled_at: Option, diff --git a/src/client/api.rs b/src/client/api.rs index 78718c8..7999372 100644 --- a/src/client/api.rs +++ b/src/client/api.rs @@ -13,6 +13,7 @@ use crate::client::import::PreparedUpload; use crate::errors::{Result, UdsError}; use crate::logging::LogEventLine; use crate::models::{ChangelogPatchRequest, CopyReleaseRequest, MutationResponse, ReleaseListResponse, UploadPolicy}; +use crate::self_update::{ReleaseKind, ReleaseResponse, StartUpdateRequest, UpdateOperation}; use crate::stats::ChannelStats; use zeroize::Zeroize; @@ -99,6 +100,27 @@ impl AdminClient { self.get_json("/admin/v1/upload-policy").await } + /// Retrieves the explicitly selected regular or prerelease update list. + pub async fn update_releases(&self, kind: ReleaseKind) -> Result { + let kind = match kind { + ReleaseKind::Regular => "regular", + ReleaseKind::Prerelease => "prerelease", + }; + self.get_json(&format!("/admin/v1/updates/releases?kind={kind}")) + .await + } + + /// Submits one client-generated idempotent update request. + pub async fn start_update(&self, request: &StartUpdateRequest) -> Result { + self.post_json("/admin/v1/updates", request).await + } + + /// Polls one durable update operation after staging or a restart. + pub async fn update_status(&self, operation_id: uuid::Uuid) -> Result { + self.get_json(&format!("/admin/v1/updates/{operation_id}")) + .await + } + /// Streams release metadata and artifacts to the selected channel. pub async fn upload_release(&self, channel: &str, upload: &PreparedUpload) -> Result { let metadata = serde_json::to_string(&upload.metadata)?; diff --git a/src/client/prompts/mod.rs b/src/client/prompts/mod.rs index beb30db..881039d 100644 --- a/src/client/prompts/mod.rs +++ b/src/client/prompts/mod.rs @@ -9,6 +9,7 @@ mod copy; mod logs; mod stats; mod tokens; +mod updates; mod upload; mod withdraw; @@ -35,6 +36,7 @@ pub async fn run(command: Option) -> Result<()> { Some(ClientCommand::Copy) => copy::run().await, Some(ClientCommand::Changelog) => changelog::run().await, Some(ClientCommand::Stats) => stats::run().await, + Some(ClientCommand::Updates) => updates::run().await, Some(ClientCommand::Tokens { command }) => tokens::run(command).await, Some(ClientCommand::Logs { follow, @@ -57,6 +59,7 @@ async fn main_menu() -> Result<()> { MenuAction::Changelog, MenuAction::Stats, MenuAction::Logs, + MenuAction::Updates, MenuAction::Configure, ], ) @@ -71,6 +74,7 @@ async fn main_menu() -> Result<()> { MenuAction::Changelog => changelog::run().await, MenuAction::Stats => stats::run().await, MenuAction::Logs => logs::run(false, 200, None, false).await, + MenuAction::Updates => updates::run().await, } } @@ -203,6 +207,9 @@ enum MenuAction { /// Represents the item concept used by UDS. Logs, + + /// Opens the explicit manual UDS update selection workflow. + Updates, } impl std::fmt::Display for MenuAction { @@ -215,6 +222,7 @@ impl std::fmt::Display for MenuAction { MenuAction::Changelog => write!(formatter, "Correct changelog"), MenuAction::Stats => write!(formatter, "Show statistics"), MenuAction::Logs => write!(formatter, "Show logs"), + MenuAction::Updates => write!(formatter, "Update UDS manually"), } } } diff --git a/src/client/prompts/updates.rs b/src/client/prompts/updates.rs new file mode 100644 index 0000000..47d7cf0 --- /dev/null +++ b/src/client/prompts/updates.rs @@ -0,0 +1,139 @@ +//! Explicit release selection, confirmation, and polling for manual UDS updates. + +use std::fmt; +use std::time::Duration; + +use inquire::{Confirm, Select}; +use uuid::Uuid; + +use crate::client::api::AdminClient; +use crate::errors::{Result, UdsError}; +use crate::self_update::{AvailableRelease, OperationStatus, ReleaseKind, StartUpdateRequest}; + +use super::{load_profile_or_configure, prompt_error}; + +/// Runs the interactive manual update workflow without unattended behavior. +pub async fn run() -> Result<()> { + let (_, _, profile) = load_profile_or_configure().await?; + let client = AdminClient::new(&profile)?; + let mut kind = ReleaseKind::Regular; + loop { + let response = client.update_releases(kind).await?; + if !response.update_supported { + return Err(UdsError::BadRequest( + response + .unavailable_reason + .unwrap_or_else(|| "updates are unavailable".into()), + )); + } + let mut choices = response + .releases + .iter() + .cloned() + .map(UpdateChoice::Release) + .collect::>(); + choices.push(match kind { + ReleaseKind::Regular => UpdateChoice::ShowPrereleases, + ReleaseKind::Prerelease => UpdateChoice::ShowRegular, + }); + choices.push(UpdateChoice::Cancel); + let selected = Select::new( + match kind { + ReleaseKind::Regular => "Newer regular releases:", + ReleaseKind::Prerelease => "Newer prereleases:", + }, + choices, + ) + .prompt() + .map_err(prompt_error)?; + match selected { + UpdateChoice::ShowPrereleases => kind = ReleaseKind::Prerelease, + UpdateChoice::ShowRegular => kind = ReleaseKind::Regular, + UpdateChoice::Cancel => { + println!("Update cancelled; no changes were made."); + return Ok(()); + } + UpdateChoice::Release(release) => { + println!( + "\nUDS update review\nVersion: {}\nBuild: {}\nRelease notes:\n{}\n", + release.version, release.build, release.notes + ); + if !Confirm::new(&format!( + "Install exactly UDS v{} (build {})?", + release.version, release.build + )) + .with_default(false) + .prompt() + .map_err(prompt_error)? + { + println!("Update cancelled; no changes were made."); + return Ok(()); + } + let request = StartUpdateRequest { + operation_id: Uuid::new_v4(), + node_id: response.node_id, + version: release.version, + allow_prerelease: kind == ReleaseKind::Prerelease, + }; + let operation = client.start_update(&request).await?; + return poll(&client, operation.request.operation_id).await; + } + } + } +} + +/// Polls durable state while tolerating expected connection loss during restart. +async fn poll(client: &AdminClient, id: Uuid) -> Result<()> { + let mut last = None; + loop { + match client.update_status(id).await { + Ok(operation) => { + if last != Some(operation.status) { + println!("Update status: {:?}", operation.status); + last = Some(operation.status); + } + if matches!(operation.status, OperationStatus::Succeeded) { + return Ok(()); + } + if matches!( + operation.status, + OperationStatus::RolledBack | OperationStatus::Failed + ) { + return Err(UdsError::Storage(operation.error.unwrap_or_else(|| { + format!("update ended as {:?}", operation.status) + }))); + } + } + Err(error) => tracing::debug!(%error, "update polling connection unavailable during restart"), + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +/// One selectable release or navigation action in the update chooser. +#[derive(Debug, Clone)] +enum UpdateChoice { + /// Exact release offered by the server. + Release(AvailableRelease), + + /// Switches from the regular list to the explicit prerelease list. + ShowPrereleases, + + /// Returns from prereleases to the regular list. + ShowRegular, + + /// Leaves without submitting an operation. + Cancel, +} + +/// Renders concise choices while the review supplies full notes. +impl fmt::Display for UpdateChoice { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Release(release) => write!(formatter, "v{} (build {})", release.version, release.build), + Self::ShowPrereleases => write!(formatter, "Show prereleases"), + Self::ShowRegular => write!(formatter, "Back to regular releases"), + Self::Cancel => write!(formatter, "Cancel"), + } + } +} diff --git a/src/config/cli.rs b/src/config/cli.rs index 4aae467..3108699 100644 --- a/src/config/cli.rs +++ b/src/config/cli.rs @@ -1,7 +1,7 @@ //! Command-line contract for the UDS server and administration client. //! //! Keeping Clap-specific types separate prevents command parsing concerns from -//! obscuring the persisted server configuration schema. +//! obscuring the persisting server configuration schema. use std::path::PathBuf; @@ -59,6 +59,22 @@ pub struct ServerArgs { pub enum ServerCommand { /// Interactively create or update a single-node server configuration. Configure(ConfigureServerArgs), + + /// Apply every signed staged update (invoked only by the systemd helper). + #[command(hide = true)] + ApplyUpdates(ApplyUpdatesArgs), +} + +/// Paths supplied by the root-owned update oneshot unit. +#[derive(Debug, Args)] +pub struct ApplyUpdatesArgs { + /// Data directory containing unprivileged staging operations. + #[arg(long)] + pub data_dir: PathBuf, + + /// Fixed the executable installed by the configuration assistant. + #[arg(long, default_value = "/usr/local/bin/uds")] + pub binary: PathBuf, } /// Input for the guided server configuration workflow. @@ -90,6 +106,9 @@ pub enum ClientCommand { /// Show channel statistics. Stats, + /// Select and confirm one manual UDS update. + Updates, + /// Manage personal admin tokens using the break-glass owner token. Tokens { /// Token operation to execute. diff --git a/src/config/mod.rs b/src/config/mod.rs index 02a8ffd..1db7b2a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -14,7 +14,9 @@ use serde::{Deserialize, Serialize}; use crate::errors::{Result, UdsError}; -pub use cli::{Cli, CliCommand, ClientCommand, ConfigureServerArgs, ServerArgs, ServerCommand, TokenCommand}; +pub use cli::{ + ApplyUpdatesArgs, Cli, CliCommand, ClientCommand, ConfigureServerArgs, ServerArgs, ServerCommand, TokenCommand, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "lowercase")] diff --git a/src/lib.rs b/src/lib.rs index d32d90a..988dbf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod logging; pub mod models; pub mod routes; pub mod security; +pub mod self_update; pub mod server_configure; pub mod shutdown; pub mod stats; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 74ca247..b0d496b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -37,6 +37,7 @@ use crate::models::{ ReplicationEvent, ReplicationEventType, UploadPolicy, }; use crate::security::{AdminAuth, ClusterAuth, OwnerAuth}; +use crate::self_update::{ReleaseKind, ReleaseResponse, StartUpdateRequest, UpdateManager, UpdateOperation}; use crate::shutdown::{ShutdownState, TransferKind}; use crate::stats::{ChannelStats, StatsEvent, StatsEventKind, StatsRecorder}; use crate::storage::{StagedArtifact, Storage}; @@ -69,6 +70,43 @@ pub struct AppState { /// The auth carried by this UDS data contract. pub auth: Arc, + + /// Persistent coordinator for the local manual UDS Update Feature. + pub updates: Arc, +} + +/// Query selecting the regular or explicitly requested prerelease list. +#[derive(serde::Deserialize)] +struct UpdateReleaseQuery { + /// Release category to enumerate; prereleases never leak into regular results. + kind: ReleaseKind, +} + +/// Lists signed, newer releases for this node. +async fn update_releases( + State(state): State, + _auth: AdminAuth, + Query(query): Query, +) -> Result> { + Ok(Json(state.updates.releases(query.kind).await?)) +} + +/// Starts one exact, explicitly confirmed update operation. +async fn start_update( + State(state): State, + _auth: AdminAuth, + Json(request): Json, +) -> Result> { + Ok(Json(state.updates.start(request).await?)) +} + +/// Returns durable status so polling survives a service restart. +async fn update_status( + State(state): State, + _auth: AdminAuth, + Path(operation_id): Path, +) -> Result> { + Ok(Json(state.updates.get(operation_id).await?)) } #[derive(serde::Deserialize)] @@ -974,6 +1012,11 @@ mod tests { .unwrap(), ); let cluster = ClusterState::new(&config).await.unwrap(); + let updates = Arc::new( + UpdateManager::open(&config, Uuid::parse_str(cluster.node_id()).unwrap()) + .await + .unwrap(), + ); let shutdown = Arc::new(ShutdownState::default()); let auth = Arc::new(AdminTokenStore::open(&config.data_dir).await.unwrap()); let state = AppState { @@ -984,6 +1027,7 @@ mod tests { logging: Arc::new(LoggingRuntime::disabled()), shutdown: shutdown.clone(), auth, + updates, }; let public = build_public_router(state.clone()); let admin = build_admin_router(state.clone()); diff --git a/src/routes/routers.rs b/src/routes/routers.rs index d10f4a6..1b6070c 100644 --- a/src/routes/routers.rs +++ b/src/routes/routers.rs @@ -36,7 +36,10 @@ pub fn build_admin_router(state: AppState) -> Router { .route("/admin/v1/channels/{target_channel}/copy", post(copy_release)) .route("/admin/v1/channels/{channel}/stats", get(channel_stats)) .route("/admin/v1/admin-tokens", get(list_admin_tokens).post(create_admin_token)) - .route("/admin/v1/admin-tokens/{id}", patch(set_admin_token_status)); + .route("/admin/v1/admin-tokens/{id}", patch(set_admin_token_status)) + .route("/admin/v1/updates/releases", get(update_releases)) + .route("/admin/v1/updates", post(start_update)) + .route("/admin/v1/updates/{operation_id}", get(update_status)); if state.config.logging.admin_api.enabled && state.config.logging.file.enabled { router = router diff --git a/src/self_update.rs b/src/self_update.rs new file mode 100644 index 0000000..9a3cc18 --- /dev/null +++ b/src/self_update.rs @@ -0,0 +1,923 @@ +//! Manual, authenticated updates for wizard-installed Linux single nodes. +//! +//! The unprivileged server discovers and stages signed releases. A separately +//! installed systemd oneshot invokes the privileged helper for the final swap. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine; +use ed25519_dalek::pkcs8::DecodePublicKey; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::build_info; +use crate::config::{ServerConfig, ServerMode}; +use crate::errors::{Result, UdsError}; + +/// Official release API used for manual UDS updates. +const RELEASES_API: &str = "https://api.github.com/repos/MindWorkAI/UpdateDeliveryServer/releases"; + +/// Public signing key committed with the release workflow. +const UPDATE_PUBLIC_KEY: &str = include_str!("../release/uds-update-public-key.pem"); + +/// Releases shown by the explicit regular/prerelease chooser. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ReleaseKind { + /// Published releases intended for normal production selection. + Regular, + + /// Prereleases shown only after the administrator switches lists. + Prerelease, +} + +/// One verified, newer GitHub release offered to an administrator. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailableRelease { + /// Exact semantic version selected by the administrator. + pub version: String, + + /// Build number signed in to the release manifest. + pub build: u64, + + /// Release notes displayed before confirmation. + pub notes: String, + + /// Whether GitHub marks this release as a prerelease. + pub prerelease: bool, +} + +/// Node capability and releases returned by the update discovery endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseResponse { + /// Stable identity of the local node. + pub node_id: Uuid, + + /// The currently running UDS version. + pub version: String, + + /// Currently running UDS build number. + pub build: u64, + + /// Whether this exact installation supports the manual workflow. + pub update_supported: bool, + + /// Safe explanation when updates are unavailable. + pub unavailable_reason: Option, + + /// Verified releases newer than the running version. + pub releases: Vec, +} + +/// Client-generated, idempotent request for one exact version. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StartUpdateRequest { + /// Stable operation identifier generated before the first POST. + pub operation_id: Uuid, + + /// Local target node; fleet targeting is deliberately unavailable. + pub node_id: Uuid, + + /// Exact semantic version chosen from the discovery response. + pub version: String, + + /// Explicit opt-in required when the selected release is a prerelease. + pub allow_prerelease: bool, +} + +/// Durable lifecycle states shared by server, helper, and polling client. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OperationStatus { + /// Accepted and waiting for the staging task. + Queued, + + /// Release metadata and archive are being downloaded. + Downloading, + + /// Signed inputs are durable and ready for the privileged helper. + Staged, + + /// The helper is replacing the executable and restarting UDS. + Applying, + + /// The replacement reported full listener readiness. + BootConfirmed, + + /// The replacement remained active during the observation period. + Succeeded, + + /// The previous executable was restored automatically. + RolledBack, + + /// Staging, replacement, or restoration failed. + Failed, +} + +/// Persistent operation record returned by the status endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOperation { + /// Original idempotency input. + pub request: StartUpdateRequest, + + /// Current durable lifecycle state. + pub status: OperationStatus, + + /// RFC 3339 creation timestamp. + pub created_at: String, + + /// RFC 3339 timestamp of the most recent state change. + pub updated_at: String, + + /// Sanitized failure detail suitable for an administrator. + pub error: Option, +} + +/// Signed UDS release manifest generated by the release workflow. +#[derive(Debug, Clone, Deserialize)] +struct UpdateManifest { + /// Supported manifest schema revision. + schema_version: u64, + + /// Release semantic version. + version: String, + + /// Release build number. + build: u64, + + /// Git tag that owns the assets. + tag: String, + + /// Administrator-facing release notes. + notes: String, + + /// Signed artifacts indexed by platform and architecture. + artifacts: BTreeMap, +} + +/// Platform-specific archive metadata covered by the manifest signature. +#[derive(Debug, Clone, Deserialize)] +struct UpdateArtifact { + /// Exact archive download URL. + url: String, + + /// Lowercase SHA-256 archive digest. + sha256: String, + + /// Exact archive size in bytes. + size: u64, + + /// Signed archive container format. + archive_format: String, + + /// Only member the helper may extract. + executable_path: String, + + /// Release-pipeline permission for executable replacement. + self_update_supported: bool, +} + +/// Minimal GitHub release representation used for paginated discovery. +#[derive(Debug, Deserialize)] +struct GithubRelease { + /// Release tag used for exact selection. + tag_name: String, + + /// Drafts must never be offered. + draft: bool, + + /// Category used for strict list separation. + prerelease: bool, + + /// Downloadable release assets. + assets: Vec, +} + +/// Download URL for a named GitHub release asset. +#[derive(Debug, Deserialize)] +struct GithubAsset { + /// Stable asset file name. + name: String, + + /// GitHub-provided download URL. + browser_download_url: String, +} + +/// Coordinates persistent operations for one local node. +pub struct UpdateManager { + /// Durable operation directory below the configured data root. + root: PathBuf, + + /// Stable identity restricting requests to this node. + node_id: Uuid, + + /// Narrow installation capability determined at startup. + supported: bool, + + /// Serializes idempotency and active-operation checks. + lock: Mutex<()>, + + /// Reusable HTTPS client for official GitHub assets. + http: reqwest::Client, +} + +impl UpdateManager { + /// Opens the operation store and determines installation capability. + pub async fn open(config: &ServerConfig, node_id: Uuid) -> Result { + let root = config.data_dir.join("self-update/operations"); + tokio::fs::create_dir_all(&root).await?; + let supported = config.mode == ServerMode::SingleNode + && cfg!(target_os = "linux") + && std::env::current_exe().is_ok_and(|path| path == Path::new("/usr/local/bin/uds")) + && Path::new("/etc/systemd/system/uds.service").is_file() + && Path::new("/etc/systemd/system/uds-update.service").is_file() + && Path::new("/etc/systemd/system/uds-update.path").is_file(); + let http = reqwest::Client::builder() + .user_agent("uds-update-client") + .build() + .map_err(|error| UdsError::Storage(error.to_string()))?; + Ok(Self { + root, + node_id, + supported, + lock: Mutex::new(()), + http, + }) + } + + /// Discovers all newer signed releases in one explicitly selected category. + pub async fn releases(&self, kind: ReleaseKind) -> Result { + if !self.supported { + return Ok(self.unavailable_response()); + } + let mut releases = Vec::new(); + for page in 1.. { + let github: Vec = self + .http + .get(RELEASES_API) + .query(&[("per_page", "100"), ("page", &page.to_string())]) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)? + .json() + .await + .map_err(network_error)?; + if github.is_empty() { + break; + } + let count = github.len(); + for release in github { + if release.draft || release.prerelease != (kind == ReleaseKind::Prerelease) { + continue; + } + let Ok(version) = Version::parse(release.tag_name.trim_start_matches('v')) else { + continue; + }; + if version <= Version::parse(build_info::VERSION)? { + continue; + } + if let Ok((manifest, _, _)) = self.fetch_manifest(&release).await + && validate_manifest(&manifest, &release.tag_name, &version.to_string()).is_ok() + { + releases.push(AvailableRelease { + version: manifest.version, + build: manifest.build, + notes: manifest.notes, + prerelease: release.prerelease, + }); + } + } + if count < 100 { + break; + } + } + releases.sort_by(|a, b| { + Version::parse(&b.version) + .ok() + .cmp(&Version::parse(&a.version).ok()) + }); + Ok(ReleaseResponse { + node_id: self.node_id, + version: build_info::VERSION.into(), + build: build_info::BUILD.parse().unwrap_or(0), + update_supported: true, + unavailable_reason: None, + releases, + }) + } + + /// Persists an idempotent request and starts asynchronous staging. + pub async fn start(self: &Arc, request: StartUpdateRequest) -> Result { + let _guard = self.lock.lock().await; + if !self.supported { + return Err(UdsError::BadRequest(self.unavailable_reason())); + } + if request.node_id != self.node_id { + return Err(UdsError::BadRequest( + "the requested node ID is not this single node".into(), + )); + } + Version::parse(&request.version).map_err(|_| UdsError::BadRequest("version must be valid SemVer".into()))?; + if let Some(existing) = self.read(request.operation_id).await? { + return if existing.request == request { + Ok(existing) + } else { + Err(UdsError::Conflict( + "operation ID was already used with different input".into(), + )) + }; + } + if self.active_operation().await?.is_some() { + return Err(UdsError::Conflict( + "this node already has an active update operation".into(), + )); + } + let now = now(); + let operation = UpdateOperation { + request, + status: OperationStatus::Queued, + created_at: now.clone(), + updated_at: now, + error: None, + }; + self.write(&operation).await?; + let manager = self.clone(); + let id = operation.request.operation_id; + tokio::spawn(async move { + if let Err(error) = manager.stage(id).await { + let _ = manager.fail(id, &error.to_string()).await; + } + }); + Ok(operation) + } + + /// Loads one durable operation by identifier. + pub async fn get(&self, id: Uuid) -> Result { + self.read(id) + .await? + .ok_or_else(|| UdsError::NotFound("update operation does not exist".into())) + } + + /// Downloads, verifies, and atomically stages one exact release. + async fn stage(&self, id: Uuid) -> Result<()> { + let mut operation = self.get(id).await?; + self.set_status(&mut operation, OperationStatus::Downloading) + .await?; + let kind = if operation.request.allow_prerelease { + ReleaseKind::Prerelease + } else { + ReleaseKind::Regular + }; + let releases = self.github_releases(kind).await?; + let github = releases + .into_iter() + .find(|release| release.tag_name == format!("v{}", operation.request.version)) + .ok_or_else(|| { + UdsError::BadRequest("the exact selected release is unavailable in the chosen category".into()) + })?; + let (manifest, manifest_bytes, signature_bytes) = self.fetch_manifest(&github).await?; + let artifact = validate_manifest(&manifest, &github.tag_name, &operation.request.version)?; + let archive = self + .http + .get(&artifact.url) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)? + .bytes() + .await + .map_err(network_error)?; + validate_archive_bytes(&archive, artifact)?; + let directory = self.root.join(id.to_string()); + tokio::fs::create_dir_all(&directory).await?; + atomic_write(&directory.join("latest.json"), &manifest_bytes).await?; + atomic_write(&directory.join("latest.json.sig"), &signature_bytes).await?; + atomic_write(&directory.join("archive.tar.gz"), &archive).await?; + self.set_status(&mut operation, OperationStatus::Staged) + .await?; + atomic_write(&self.root.join(format!("{id}.apply")), b"staged\n").await + } + + /// Fetches and cryptographically verifies both manifest assets. + async fn fetch_manifest(&self, release: &GithubRelease) -> Result<(UpdateManifest, Vec, Vec)> { + let manifest_url = asset_url(release, "latest.json")?; + let signature_url = asset_url(release, "latest.json.sig")?; + let manifest = self + .http + .get(manifest_url) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)? + .bytes() + .await + .map_err(network_error)? + .to_vec(); + let signature_text = self + .http + .get(signature_url) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)? + .text() + .await + .map_err(network_error)?; + verify_signature(&manifest, signature_text.trim())?; + let parsed: UpdateManifest = serde_json::from_slice(&manifest)?; + Ok((parsed, manifest, signature_text.into_bytes())) + } + + /// Fetches all GitHub pages for an exact release-category lookup. + async fn github_releases(&self, kind: ReleaseKind) -> Result> { + let mut all = Vec::new(); + for page in 1.. { + let page_items: Vec = self + .http + .get(RELEASES_API) + .query(&[("per_page", "100"), ("page", &page.to_string())]) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)? + .json() + .await + .map_err(network_error)?; + let count = page_items.len(); + all.extend( + page_items + .into_iter() + .filter(|item| !item.draft && item.prerelease == (kind == ReleaseKind::Prerelease)), + ); + if count < 100 { + break; + } + } + Ok(all) + } + + /// Finds any nonterminal operation that prevents concurrent replacement. + async fn active_operation(&self) -> Result> { + let mut entries = tokio::fs::read_dir(&self.root).await?; + while let Some(entry) = entries.next_entry().await? { + let Ok(id) = Uuid::parse_str(&entry.file_name().to_string_lossy()) else { + continue; + }; + if let Some(operation) = self.read(id).await? + && !terminal(operation.status) + { + return Ok(Some(operation)); + } + } + Ok(None) + } + + /// Reads an operation JSON file if it exists. + async fn read(&self, id: Uuid) -> Result> { + let path = self.root.join(id.to_string()).join("operation.json"); + match tokio::fs::read(path).await { + Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + + /// Atomically replaces an operation JSON file. + async fn write(&self, operation: &UpdateOperation) -> Result<()> { + let directory = self.root.join(operation.request.operation_id.to_string()); + tokio::fs::create_dir_all(&directory).await?; + atomic_write( + &directory.join("operation.json"), + &serde_json::to_vec_pretty(operation)?, + ) + .await + } + + /// Changes and persists the lifecycle state. + async fn set_status(&self, operation: &mut UpdateOperation, status: OperationStatus) -> Result<()> { + operation.status = status; + operation.updated_at = now(); + self.write(operation).await + } + + /// Records a sanitized staging failure. + async fn fail(&self, id: Uuid, message: &str) -> Result<()> { + let mut operation = self.get(id).await?; + operation.status = OperationStatus::Failed; + operation.updated_at = now(); + operation.error = Some(sanitize_error(message)); + self.write(&operation).await + } + + /// Builds the explicit unsupported response for non-wizard/fleet installs. + fn unavailable_response(&self) -> ReleaseResponse { + ReleaseResponse { + node_id: self.node_id, + version: build_info::VERSION.into(), + build: build_info::BUILD.parse().unwrap_or(0), + update_supported: false, + unavailable_reason: Some(self.unavailable_reason()), + releases: Vec::new(), + } + } + + /// Explains the deliberately narrow support boundary without exposing paths. + fn unavailable_reason(&self) -> String { + "manual UDS updates require a wizard-installed single node at /usr/local/bin/uds managed by systemd; fleet updates are planned but not available".into() + } +} + +/// Runs the privileged helper for one staged operation directory. +pub fn apply_staged(operation_dir: &Path, binary: &Path) -> Result<()> { + let manifest_bytes = fs::read(operation_dir.join("latest.json"))?; + let signature = fs::read_to_string(operation_dir.join("latest.json.sig"))?; + verify_signature(&manifest_bytes, signature.trim())?; + let manifest: UpdateManifest = serde_json::from_slice(&manifest_bytes)?; + let artifact = validate_manifest(&manifest, &manifest.tag, &manifest.version)?; + let archive = fs::read(operation_dir.join("archive.tar.gz"))?; + validate_archive_bytes(&archive, artifact)?; + let executable = extract_executable(&archive, &artifact.executable_path)?; + let previous = binary.with_file_name("uds.previous"); + let candidate = binary.with_file_name("uds.new"); + fs::write(&candidate, executable)?; + set_executable(&candidate)?; + if previous.exists() { + fs::remove_file(&previous)?; + } + fs::rename(binary, &previous)?; + if let Err(error) = fs::rename(&candidate, binary) { + let _ = fs::rename(&previous, binary); + return Err(error.into()); + } + Ok(()) +} + +/// Applies all staged operations found by the bounded systemd path trigger. +/// Note that this function is invoked by a separate systemd oneshot and +/// runs with root privileges, so it must be careful to avoid exposing any +/// untrusted input. +pub fn apply_pending(data_dir: &Path, binary: &Path) -> Result<()> { + let root = data_dir.join("self-update/operations"); + for entry in fs::read_dir(&root)? { + let directory = entry?.path(); + let operation_path = directory.join("operation.json"); + let Ok(bytes) = fs::read(&operation_path) else { continue }; + let mut operation: UpdateOperation = serde_json::from_slice(&bytes)?; + if operation.status != OperationStatus::Staged { + continue; + } + let marker = root.join(format!("{}.apply", operation.request.operation_id)); + update_sync( + &operation_path, + &mut operation, + OperationStatus::Applying, + None, + )?; + let applied = apply_staged(&directory, binary); + if let Err(error) = applied { + update_sync( + &operation_path, + &mut operation, + OperationStatus::Failed, + Some(&error.to_string()), + )?; + let _ = fs::remove_file(&marker); + continue; + } + let restart = Command::new("systemctl") + .args(["restart", "uds.service"]) + .status(); + let ready = restart.is_ok_and(|status| status.success()) && wait_active(Duration::from_secs(30)); + if ready { + update_sync( + &operation_path, + &mut operation, + OperationStatus::BootConfirmed, + None, + )?; + update_sync( + &operation_path, + &mut operation, + OperationStatus::Succeeded, + None, + )?; + let _ = fs::remove_file(&marker); + continue; + } + let previous = binary.with_file_name("uds.previous"); + let rollback = fs::remove_file(binary).and_then(|_| fs::rename(&previous, binary)); + if rollback.is_ok() { + let restored = Command::new("systemctl") + .args(["restart", "uds.service"]) + .status() + .is_ok_and(|status| status.success()); + let status = if restored { + OperationStatus::RolledBack + } else { + OperationStatus::Failed + }; + let message = if restored { + "the new version did not remain ready; the previous executable was restored" + } else { + "the new version failed and the restored service could not be started" + }; + update_sync(&operation_path, &mut operation, status, Some(message))?; + } else { + update_sync( + &operation_path, + &mut operation, + OperationStatus::Failed, + Some("the new version failed and restoring the previous executable also failed"), + )?; + } + let _ = fs::remove_file(&marker); + } + Ok(()) +} + +/// Observes systemd continuously so an early crash causes rollback. +fn wait_active(duration: Duration) -> bool { + let started = Instant::now(); + while started.elapsed() < duration { + let active = Command::new("systemctl") + .args(["is-active", "--quiet", "uds.service"]) + .status() + .is_ok_and(|status| status.success()); + if !active { + return false; + } + std::thread::sleep(Duration::from_secs(1)); + } + true +} + +/// Persists helper state atomically for polling after restart. +fn update_sync( + path: &Path, + operation: &mut UpdateOperation, + status: OperationStatus, + error: Option<&str>, +) -> Result<()> { + operation.status = status; + operation.updated_at = now(); + operation.error = error.map(sanitize_error); + let temporary = path.with_extension("tmp"); + fs::write(&temporary, serde_json::to_vec_pretty(operation)?)?; + fs::rename(temporary, path)?; + Ok(()) +} + +/// Validates signed manifest invariants and selects this host's artifact. +fn validate_manifest<'a>(manifest: &'a UpdateManifest, tag: &str, version: &str) -> Result<&'a UpdateArtifact> { + if manifest.schema_version != 1 + || manifest.version != version + || manifest.tag != tag + || tag != format!("v{version}") + { + return Err(UdsError::BadRequest( + "signed manifest version or tag is inconsistent".into(), + )); + } + let parsed = Version::parse(version)?; + if parsed <= Version::parse(build_info::VERSION)? { + return Err(UdsError::BadRequest( + "selected version is not newer than the running version".into(), + )); + } + let key = format!("linux-{}", std::env::consts::ARCH); + let artifact = manifest + .artifacts + .get(&key) + .ok_or_else(|| UdsError::BadRequest("signed manifest has no artifact for this Linux architecture".into()))?; + if !artifact.self_update_supported || artifact.archive_format != "tar.gz" || artifact.size == 0 { + return Err(UdsError::BadRequest( + "signed artifact is not eligible for this update workflow".into(), + )); + } + Ok(artifact) +} + +/// Verifies the raw manifest bytes with the embedded Ed25519 public key. +fn verify_signature(manifest: &[u8], encoded: &str) -> Result<()> { + let key = VerifyingKey::from_public_key_pem(UPDATE_PUBLIC_KEY) + .map_err(|_| UdsError::BadRequest("embedded update key is invalid".into()))?; + verify_signature_with_key(&key, manifest, encoded) +} + +/// Verifies a signature with an injected key so tests never use production secrets. +fn verify_signature_with_key(key: &VerifyingKey, manifest: &[u8], encoded: &str) -> Result<()> { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| UdsError::BadRequest("manifest signature is not valid base64".into()))?; + let signature = Signature::from_slice(&bytes) + .map_err(|_| UdsError::BadRequest("manifest signature has an invalid length".into()))?; + key.verify(manifest, &signature) + .map_err(|_| UdsError::BadRequest("manifest signature verification failed".into())) +} + +/// Checks archive size and digest against the signed artifact metadata. +fn validate_archive_bytes(bytes: &[u8], artifact: &UpdateArtifact) -> Result<()> { + if bytes.len() as u64 != artifact.size { + return Err(UdsError::BadRequest( + "archive size does not match the signed manifest".into(), + )); + } + let digest = hex::encode(Sha256::digest(bytes)); + if digest != artifact.sha256.to_ascii_lowercase() { + return Err(UdsError::BadRequest( + "archive digest does not match the signed manifest".into(), + )); + } + Ok(()) +} + +/// Extracts exactly one regular executable entry without unpacking other files. +fn extract_executable(bytes: &[u8], signed_path: &str) -> Result> { + let path = Path::new(signed_path); + if path.is_absolute() + || path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(UdsError::BadRequest( + "signed executable path is unsafe".into(), + )); + } + let decoder = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + let mut found = None; + for entry in archive + .entries() + .map_err(|_| UdsError::BadRequest("archive is corrupt".into()))? + { + let mut entry = entry.map_err(|_| UdsError::BadRequest("archive is corrupt".into()))?; + let entry_path = entry + .path() + .map_err(|_| UdsError::BadRequest("archive path is invalid".into()))?; + if entry_path.is_absolute() + || entry_path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + || entry.header().entry_type().is_symlink() + || entry.header().entry_type().is_hard_link() + { + return Err(UdsError::BadRequest( + "archive contains an unsafe path or link".into(), + )); + } + if entry_path.as_ref() == path { + if !entry.header().entry_type().is_file() || found.is_some() { + return Err(UdsError::BadRequest( + "archive executable is missing, ambiguous, or not a regular file".into(), + )); + } + let mut content = Vec::new(); + entry.read_to_end(&mut content)?; + found = Some(content); + } + } + found.ok_or_else(|| UdsError::BadRequest("archive does not contain the signed executable path".into())) +} + +/// Locates a required asset in one GitHub release. +fn asset_url<'a>(release: &'a GithubRelease, name: &str) -> Result<&'a str> { + release + .assets + .iter() + .find(|asset| asset.name == name) + .map(|asset| asset.browser_download_url.as_str()) + .ok_or_else(|| UdsError::BadRequest(format!("release is missing {name}"))) +} + +/// Converts transport failures without exposing request internals to API users. +fn network_error(error: reqwest::Error) -> UdsError { + UdsError::Storage(format!("release service request failed: {error}")) +} + +/// Writes bytes through a same-directory temporary file and atomic rename. +async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let temporary = path.with_extension("tmp"); + tokio::fs::write(&temporary, bytes).await?; + tokio::fs::rename(temporary, path).await?; + Ok(()) +} + +/// Returns an RFC 3339 UTC timestamp for durable operation records. +fn now() -> String { + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "unknown".into()) +} + +/// Limits persistent error text to a safe, single-line administrative detail. +fn sanitize_error(message: &str) -> String { + message + .lines() + .next() + .unwrap_or("update failed") + .chars() + .take(300) + .collect() +} + +/// Reports whether a lifecycle state releases the node's operation lock. +fn terminal(status: OperationStatus) -> bool { + matches!( + status, + OperationStatus::Succeeded | OperationStatus::RolledBack | OperationStatus::Failed + ) +} + +/// Applies executable permissions to the staged replacement on Unix. +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + Ok(()) +} + +/// Keeps non-Unix builds compilable even though applying is unsupported there. +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + use flate2::Compression; + + /// Creates an in-memory tar.gz with one chosen entry type. + fn archive(path: &str, bytes: &[u8]) -> Vec { + let output = Vec::new(); + let encoder = flate2::write::GzEncoder::new(output, Compression::default()); + let mut builder = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, path, bytes).unwrap(); + builder.into_inner().unwrap().finish().unwrap() + } + + /// Accepts a test-key signature and rejects modified manifest bytes. + #[test] + fn signature_rejects_tampering() { + let signing = SigningKey::from_bytes(&[7; 32]); + let verifying = signing.verifying_key(); + let manifest = b"signed manifest"; + let signature = base64::engine::general_purpose::STANDARD.encode(signing.sign(manifest).to_bytes()); + assert!(verify_signature_with_key(&verifying, manifest, &signature).is_ok()); + assert!(verify_signature_with_key(&verifying, b"modified manifest", &signature).is_err()); + } + + /// Extracts only the exact signed regular executable member. + #[test] + fn archive_extracts_exact_executable() { + let bytes = archive("release/uds", b"binary"); + assert_eq!( + extract_executable(&bytes, "release/uds").unwrap(), + b"binary" + ); + assert!(extract_executable(&bytes, "release/other").is_err()); + } + + /// Rejects a signed path containing parent traversal. + #[test] + fn archive_rejects_path_traversal() { + let bytes = archive("release/uds", b"binary"); + assert!(extract_executable(&bytes, "../uds").is_err()); + } + + /// Detects both signed archive size and digest mismatches. + #[test] + fn archive_metadata_must_match() { + let artifact = UpdateArtifact { + url: "https://example.invalid/archive".into(), + sha256: hex::encode(Sha256::digest(b"archive")), + size: 7, + archive_format: "tar.gz".into(), + executable_path: "release/uds".into(), + self_update_supported: true, + }; + assert!(validate_archive_bytes(b"archive", &artifact).is_ok()); + assert!(validate_archive_bytes(b"changed", &artifact).is_err()); + } +} diff --git a/src/server_configure/mod.rs b/src/server_configure/mod.rs index c4dd700..08c68e6 100644 --- a/src/server_configure/mod.rs +++ b/src/server_configure/mod.rs @@ -28,6 +28,12 @@ const SYSTEM_BINARY: &str = "/usr/local/bin/uds"; /// Defines the UNIT PATH value used by UDS. const UNIT_PATH: &str = "/etc/systemd/system/uds.service"; +/// Root-owned oneshot unit that applies cryptographically verified staging data. +const UPDATE_UNIT_PATH: &str = "/etc/systemd/system/uds-update.service"; + +/// Bounded filesystem trigger for explicitly submitted update operations. +const UPDATE_PATH_UNIT: &str = "/etc/systemd/system/uds-update.path"; + /// Runs the run workflow for UDS. pub async fn run(args: ConfigureServerArgs) -> Result<()> { // @@ -558,7 +564,8 @@ After=network-online.target Wants=network-online.target [Service] -Type=simple +Type=notify +NotifyAccess=main User=uds Group=uds ExecStart={} server --config {} @@ -593,6 +600,50 @@ WantedBy=multi-user.target .to_string() } +/// Renders the root oneshot which alone may replace `/usr/local/bin/uds`. +pub fn render_update_unit(config: &ServerConfig) -> String { + format!( + r#"[Unit] +Description=Apply a manually selected UDS update +After=uds.service + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/local/bin/uds server apply-updates --data-dir {} --binary /usr/local/bin/uds +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/usr/local/bin {} +CapabilityBoundingSet= +RestrictSUIDSGID=true +LockPersonality=true +"#, + absolute_path(&config.data_dir).display(), + absolute_path(&config.data_dir).display() + ) +} + +/// Renders the limited path trigger installed only by the single-node wizard. +pub fn render_update_path_unit(config: &ServerConfig) -> String { + format!( + r#"[Unit] +Description=Watch for explicitly staged UDS updates + +[Path] +PathChanged={}/self-update/operations +Unit=uds-update.service + +[Install] +WantedBy=multi-user.target +"#, + absolute_path(&config.data_dir).display() + ) +} + /// Performs the install systemd operation required by UDS. fn install_systemd(config: &ServerConfig, config_path: &Path) -> Result<()> { let data_dir = absolute_path(&config.data_dir); @@ -637,6 +688,16 @@ fn install_systemd(config: &ServerConfig, config_path: &Path) -> Result<()> { &render_systemd_unit(config, &binary, config_path), 0o644, )?; + write_atomic_text( + Path::new(UPDATE_UNIT_PATH), + &render_update_unit(config), + 0o644, + )?; + write_atomic_text( + Path::new(UPDATE_PATH_UNIT), + &render_update_path_unit(config), + 0o644, + )?; run_checked( Command::new("systemctl").arg("daemon-reload"), "reload systemd", @@ -650,6 +711,10 @@ fn install_systemd(config: &ServerConfig, config_path: &Path) -> Result<()> { Command::new("systemctl").args(["enable", "--now", "uds.service"]), "enable and start uds.service", )?; + run_checked( + Command::new("systemctl").args(["enable", "--now", "uds-update.path"]), + "enable the manual update trigger", + )?; run_checked( Command::new("systemctl").args(["restart", "uds.service"]), "restart uds.service", @@ -1029,7 +1094,8 @@ After=network-online.target Wants=network-online.target [Service] -Type=simple +Type=notify +NotifyAccess=main User=uds Group=uds ExecStart=/usr/local/bin/uds server --config /etc/uds/config.toml @@ -1063,6 +1129,14 @@ WantedBy=multi-user.target "AmbientCapabilities=CAP_NET_BIND_SERVICE\nCapabilityBoundingSet=CAP_NET_BIND_SERVICE\n", ); assert_eq!(privileged, expected_privileged); + + let update = render_update_unit(&config); + assert!(update.contains("Type=oneshot")); + assert!(update.contains("User=root")); + assert!(update.contains("ReadWritePaths=/usr/local/bin /srv/uds-test/data")); + let trigger = render_update_path_unit(&config); + assert!(trigger.contains("PathChanged=/srv/uds-test/data/self-update/operations")); + assert!(trigger.contains("Unit=uds-update.service")); } /// Verifies that rejects bad url and channels. diff --git a/tests/fixtures/source_layout/attributes.rs.txt b/tests/fixtures/source_layout/attributes.rs.txt new file mode 100644 index 0000000..2afba40 --- /dev/null +++ b/tests/fixtures/source_layout/attributes.rs.txt @@ -0,0 +1,19 @@ +struct Record { + #[serde(default)] + values: Option< + Vec, + >, + + #[serde(skip)] + hidden: bool, +} + +enum Event { + #[serde(rename = "ready")] + Ready { + value: String, + }, + + #[serde(other)] + Unknown, +} diff --git a/tests/fixtures/source_layout/documented.rs.txt b/tests/fixtures/source_layout/documented.rs.txt new file mode 100644 index 0000000..e12341c --- /dev/null +++ b/tests/fixtures/source_layout/documented.rs.txt @@ -0,0 +1,15 @@ +struct Record { + /// First value. + first: String, + + /// Second value. + second: String, +} + +enum State { + /// Work has started. + Started, + + /// Work has stopped. + Stopped, +} diff --git a/tests/fixtures/source_layout/non_members.rs.txt b/tests/fixtures/source_layout/non_members.rs.txt new file mode 100644 index 0000000..5a9032e --- /dev/null +++ b/tests/fixtures/source_layout/non_members.rs.txt @@ -0,0 +1,9 @@ +/// A module-level operation. +fn run() {} + +/// A documented data type. +#[derive(Debug)] +struct Record { + plain: bool, + compact: bool, +} diff --git a/tests/fixtures/source_layout/violations.rs.txt b/tests/fixtures/source_layout/violations.rs.txt new file mode 100644 index 0000000..dfb163f --- /dev/null +++ b/tests/fixtures/source_layout/violations.rs.txt @@ -0,0 +1,11 @@ +struct Record { + /// First. + first: bool, + #[serde(default)] + second: bool, + + + /// Third. + + third: bool, +} diff --git a/tests/source_layout.rs b/tests/source_layout.rs new file mode 100644 index 0000000..e3dcb85 --- /dev/null +++ b/tests/source_layout.rs @@ -0,0 +1,96 @@ +//! Regression tests for the build-time UDS source-layout checker. + +#[path = "../build_support/source_layout.rs"] +mod source_layout; + +use std::{fs, path::PathBuf}; + +use source_layout::{check_roots, check_source, format_report}; + +/// Accepts documented fields and variants separated by one blank line. +#[test] +fn accepts_correctly_separated_documented_members() { + let source = include_str!("fixtures/source_layout/documented.rs.txt"); + + assert!(check_source("src/good.rs", source).is_empty()); +} + +/// Accepts attributes and multiline declarations kept in their visual blocks. +#[test] +fn accepts_attributes_and_multiline_members() { + let source = include_str!("fixtures/source_layout/attributes.rs.txt"); + + assert!(check_source("src/good.rs", source).is_empty()); +} + +/// Ignores documentation attached to functions, modules, and the data type itself. +#[test] +fn ignores_non_member_documentation() { + let source = include_str!("fixtures/source_layout/non_members.rs.txt"); + + assert!(check_source("src/good.rs", source).is_empty()); +} + +/// Reports every bad member position in stable source order with useful details. +#[test] +fn reports_multiple_violations_in_order() { + let source = include_str!("fixtures/source_layout/violations.rs.txt"); + + let violations = check_source("src/record.rs", source); + + assert_eq!(violations.len(), 3); + assert_eq!(violations[0].path, "src/record.rs"); + assert_eq!(violations[0].line, 4); + assert!( + violations[0] + .description + .contains("found 0 separating line(s)") + ); + assert_eq!(violations[1].line, 8); + assert!( + violations[1] + .description + .contains("must stay together without blank lines") + ); + assert_eq!(violations[2].line, 8); + assert!( + violations[2] + .description + .contains("found 2 separating line(s)") + ); +} + +/// Scans recursive roots in path order and formats file, line, and total details. +#[test] +fn scans_roots_and_formats_a_complete_sorted_report() { + let root = temporary_test_directory(); + let nested = root.join("src/nested"); + fs::create_dir_all(&nested).expect("fixture directory should be created"); + fs::write( + root.join("src/z.rs"), + include_str!("fixtures/source_layout/violations.rs.txt"), + ) + .expect("top-level fixture should be written"); + fs::write( + nested.join("a.rs"), + include_str!("fixtures/source_layout/violations.rs.txt"), + ) + .expect("nested fixture should be written"); + + let violations = check_roots(&root, &["src"]).expect("fixture tree should be checked"); + let report = format_report(&violations); + + assert_eq!(violations.len(), 6); + assert_eq!(violations[0].path, "src/nested/a.rs"); + assert_eq!(violations[0].line, 4); + assert_eq!(violations[3].path, "src/z.rs"); + assert!(report.starts_with("src/nested/a.rs:4:")); + assert!(report.ends_with("source layout check failed with 6 violation(s)\n")); + + fs::remove_dir_all(root).expect("fixture directory should be removed"); +} + +/// Returns a process-specific scratch directory for recursive checker tests. +fn temporary_test_directory() -> PathBuf { + std::env::temp_dir().join(format!("uds-source-layout-test-{}", std::process::id())) +}