From eec1bcb6ed0f0fcadf11d9b54bf48aaecac13eb8 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 20:52:36 -0700 Subject: [PATCH 01/13] Implement native macOS GUI foundation with structured API helper --- .gitignore | 7 + macos/TimeCapsuleSMB/Package.swift | 15 + .../TimeCapsuleSMBApp/BackendClient.swift | 139 +++ .../TimeCapsuleSMBApp/ContentView.swift | 211 ++++ .../Sources/TimeCapsuleSMBApp/Models.swift | 109 +++ .../TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift | 11 + src/timecapsulesmb/app/__init__.py | 2 + src/timecapsulesmb/app/events.py | 78 ++ src/timecapsulesmb/app/helper.py | 47 + src/timecapsulesmb/app/service.py | 898 ++++++++++++++++++ src/timecapsulesmb/cli/main.py | 4 +- tests/test_app_api.py | 215 +++++ 12 files changed, 1735 insertions(+), 1 deletion(-) create mode 100644 macos/TimeCapsuleSMB/Package.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift create mode 100644 src/timecapsulesmb/app/__init__.py create mode 100644 src/timecapsulesmb/app/events.py create mode 100644 src/timecapsulesmb/app/helper.py create mode 100644 src/timecapsulesmb/app/service.py create mode 100644 tests/test_app_api.py diff --git a/.gitignore b/.gitignore index a59cc144..23fb2f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,13 @@ dist/ *.egg-info/ *.egg +# Swift / macOS GUI build outputs +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate +xcuserdata/ + # Local dependencies and AirPyrt env .deps/ .airpyrt-venv/ diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift new file mode 100644 index 00000000..bd471c91 --- /dev/null +++ b/macos/TimeCapsuleSMB/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TimeCapsuleSMBMac", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBApp"]) + ], + targets: [ + .executableTarget(name: "TimeCapsuleSMBApp") + ] +) + diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift new file mode 100644 index 00000000..9ec5bcf0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -0,0 +1,139 @@ +import Foundation + +@MainActor +final class BackendClient: ObservableObject { + @Published var helperPath: String + @Published var events: [BackendEvent] = [] + @Published var isRunning = false + @Published var lastExitCode: Int32? + + init() { + helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? ".venv/bin/tcapsule" + } + + func clear() { + events.removeAll() + lastExitCode = nil + } + + func run(operation: String, params: [String: JSONValue] = [:]) { + guard !isRunning else { return } + isRunning = true + lastExitCode = nil + let helperPath = self.helperPath + Task.detached { + let exitCode = await Self.runHelper( + helperPath: helperPath, + operation: operation, + params: params + ) { event in + Task { @MainActor in + self.events.append(event) + } + } + await MainActor.run { + self.lastExitCode = exitCode + self.isRunning = false + } + } + } + + private static func runHelper( + helperPath: String, + operation: String, + params: [String: JSONValue], + onEvent: @escaping (BackendEvent) -> Void + ) async -> Int32 { + let process = Process() + process.executableURL = helperURL(for: helperPath) + process.arguments = ["api"] + process.environment = helperEnvironment() + + let input = Pipe() + let output = Pipe() + let error = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = error + + let decoder = JSONDecoder() + let parser = OutputLineParser(onEvent: onEvent) + output.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + parser.append(data) + } + + do { + try process.run() + let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] + let requestData = try JSONEncoder().encode(JSONValue.object(request)) + input.fileHandleForWriting.write(requestData) + input.fileHandleForWriting.closeFile() + process.waitUntilExit() + output.fileHandleForReading.readabilityHandler = nil + _ = error.fileHandleForReading.readDataToEndOfFile() + return process.terminationStatus + } catch { + let fallback = """ + {"type":"error","operation":"\(operation)","message":"\(error.localizedDescription)"} + """ + if let data = fallback.data(using: .utf8), let event = try? decoder.decode(BackendEvent.self, from: data) { + onEvent(event) + } + return 1 + } + } + + private static func helperURL(for path: String) -> URL { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(path) + } + + private static func helperEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + guard + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + else { + return environment + } + try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + if environment["TCAPSULE_CONFIG"] == nil { + environment["TCAPSULE_CONFIG"] = appSupport.appendingPathComponent(".env").path + } + if environment["TCAPSULE_STATE_DIR"] == nil { + environment["TCAPSULE_STATE_DIR"] = appSupport.path + } + return environment + } +} + +private final class OutputLineParser: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private let decoder = JSONDecoder() + private let onEvent: (BackendEvent) -> Void + + init(onEvent: @escaping (BackendEvent) -> Void) { + self.onEvent = onEvent + } + + func append(_ data: Data) { + lock.lock() + defer { lock.unlock() } + buffer.append(data) + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: line) else { + continue + } + onEvent(event) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift new file mode 100644 index 00000000..ac342031 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -0,0 +1,211 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var backend = BackendClient() + @State private var selection: Screen = .readiness + @State private var host = "root@192.168.x.x" + @State private var password = "" + @State private var repairPath = "" + @State private var volume = "" + @State private var nbnsEnabled = true + @State private var noReboot = false + @State private var dryRun = true + + var body: some View { + NavigationSplitView { + List(Screen.allCases, selection: $selection) { screen in + Label(screen.title, systemImage: screen.icon) + .tag(screen) + } + .navigationTitle("TimeCapsuleSMB") + } detail: { + VStack(spacing: 0) { + form + Divider() + EventList(events: backend.events) + } + .toolbar { + ToolbarItemGroup { + Button { + backend.clear() + } label: { + Label("Clear", systemImage: "trash") + } + .disabled(backend.isRunning) + } + } + } + .frame(minWidth: 980, minHeight: 680) + } + + @ViewBuilder + private var form: some View { + switch selection { + case .readiness: + CommandPanel(title: "Readiness") { + TextField("Helper", text: $backend.helperPath) + HStack { + runButton("Paths", icon: "folder", operation: "paths") + runButton("Validate", icon: "checkmark.seal", operation: "validate-install") + } + } + case .connect: + CommandPanel(title: "Discover And Connect") { + TextField("Host", text: $host) + SecureField("Password", text: $password) + HStack { + runButton("Discover", icon: "network", operation: "discover") + Button { + backend.run(operation: "configure", params: [ + "host": .string(host), + "password": .string(password) + ]) + } label: { + Label("Configure", systemImage: "lock.open") + } + .disabled(backend.isRunning || password.isEmpty) + } + } + case .deploy: + CommandPanel(title: "Deploy") { + Toggle("Enable NBNS", isOn: $nbnsEnabled) + Toggle("No Reboot", isOn: $noReboot) + Toggle("Dry Run", isOn: $dryRun) + Button { + backend.run(operation: "deploy", params: [ + "dry_run": .bool(dryRun), + "yes": .bool(true), + "no_reboot": .bool(noReboot), + "nbns_enabled": .bool(nbnsEnabled) + ]) + } label: { + Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") + } + .disabled(backend.isRunning) + } + case .doctor: + CommandPanel(title: "Doctor") { + runButton("Run Doctor", icon: "stethoscope", operation: "doctor") + } + case .maintenance: + CommandPanel(title: "Maintenance") { + TextField("Repair xattrs path", text: $repairPath) + TextField("fsck volume, optional", text: $volume) + HStack { + runButton("Activate", icon: "power", operation: "activate", params: ["yes": .bool(true)]) + runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: ["dry_run": .bool(true)]) + } + HStack { + Button { + backend.run(operation: "fsck", params: [ + "yes": .bool(true), + "volume": .string(volume) + ]) + } label: { + Label("Run fsck", systemImage: "externaldrive.badge.checkmark") + } + .disabled(backend.isRunning) + Button { + backend.run(operation: "repair-xattrs", params: [ + "path": .string(repairPath), + "dry_run": .bool(true) + ]) + } label: { + Label("Scan xattrs", systemImage: "wand.and.stars") + } + .disabled(backend.isRunning || repairPath.isEmpty) + } + } + case .advanced: + CommandPanel(title: "Advanced") { + Text("Flash backup, patch, and restore remain CLI-only in this version.") + .foregroundStyle(.secondary) + Text("Use `.venv/bin/tcapsule flash --help` for firmware operations.") + .font(.system(.body, design: .monospaced)) + } + } + } + + private func runButton( + _ title: String, + icon: String, + operation: String, + params: [String: JSONValue] = [:] + ) -> some View { + Button { + backend.run(operation: operation, params: params) + } label: { + Label(title, systemImage: icon) + } + .disabled(backend.isRunning) + } +} + +private enum Screen: String, CaseIterable, Identifiable { + case readiness + case connect + case deploy + case doctor + case maintenance + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .readiness: return "Readiness" + case .connect: return "Connect" + case .deploy: return "Deploy" + case .doctor: return "Doctor" + case .maintenance: return "Maintenance" + case .advanced: return "Advanced" + } + } + + var icon: String { + switch self { + case .readiness: return "checklist" + case .connect: return "network" + case .deploy: return "square.and.arrow.up" + case .doctor: return "stethoscope" + case .maintenance: return "wrench.and.screwdriver" + case .advanced: return "exclamationmark.triangle" + } + } +} + +private struct CommandPanel: View { + let title: String + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title2.weight(.semibold)) + content + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct EventList: View { + let events: [BackendEvent] + + var body: some View { + List(events) { event in + VStack(alignment: .leading, spacing: 4) { + Text(event.summary) + .font(.body) + if let payload = event.payload, event.type == "result" { + Text(payload.displayText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(6) + } + } + .padding(.vertical, 3) + } + } +} + diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift new file mode 100644 index 00000000..b9a70e53 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -0,0 +1,109 @@ +import Foundation + +enum JSONValue: Codable, Hashable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + self = .array(try container.decode([JSONValue].self)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + var displayText: String { + switch self { + case .string(let value): + return value + case .number(let value): + return String(value) + case .bool(let value): + return value ? "true" : "false" + case .object, .array: + guard + let data = try? JSONEncoder().encode(self), + let text = String(data: data, encoding: .utf8) + else { + return "" + } + return text + case .null: + return "null" + } + } +} + +struct BackendEvent: Decodable, Identifiable { + let id = UUID() + let type: String + let operation: String + let stage: String? + let level: String? + let message: String? + let status: String? + let ok: Bool? + let payload: JSONValue? + let details: JSONValue? + let debug: JSONValue? + + enum CodingKeys: String, CodingKey { + case type + case operation + case stage + case level + case message + case status + case ok + case payload + case details + case debug + } + + var summary: String { + switch type { + case "stage": + return stage.map { "\(operation): \($0)" } ?? operation + case "check": + return "\(status ?? "INFO") \(message ?? "")" + case "result": + return "\(operation): \(ok == true ? "finished" : "failed")" + case "error": + return "\(operation): \(message ?? "error")" + default: + return message ?? stage ?? operation + } + } +} + diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift new file mode 100644 index 00000000..390cb570 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct TimeCapsuleSMBApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + diff --git a/src/timecapsulesmb/app/__init__.py b/src/timecapsulesmb/app/__init__.py new file mode 100644 index 00000000..bd0eaf19 --- /dev/null +++ b/src/timecapsulesmb/app/__init__.py @@ -0,0 +1,2 @@ +"""Structured app backend for GUI integrations.""" + diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py new file mode 100644 index 00000000..258d9db8 --- /dev/null +++ b/src/timecapsulesmb/app/events.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + + +SENSITIVE_KEY_PARTS = ("password", "secret", "token") +REDACTED = "" + + +def redact(value: object) -> object: + if isinstance(value, dict): + redacted: dict[str, object] = {} + for key, item in value.items(): + if any(part in str(key).lower() for part in SENSITIVE_KEY_PARTS): + redacted[str(key)] = REDACTED + else: + redacted[str(key)] = redact(item) + return redacted + if isinstance(value, (list, tuple, set)): + return [redact(item) for item in value] + if isinstance(value, Path): + return str(value) + return value + + +@dataclass(frozen=True) +class AppEvent: + type: str + operation: str + fields: dict[str, object] = field(default_factory=dict) + + def to_jsonable(self) -> dict[str, object]: + data = {"type": self.type, "operation": self.operation} + data.update(redact(self.fields)) + return data + + def to_json_line(self) -> str: + return json.dumps(self.to_jsonable(), sort_keys=True) + "\n" + + +class EventSink: + def __init__(self, emit: Callable[[AppEvent], None]) -> None: + self._emit = emit + + def emit(self, event: AppEvent) -> None: + self._emit(event) + + def stage(self, operation: str, stage: str) -> None: + self.emit(AppEvent("stage", operation, {"stage": stage})) + + def log(self, operation: str, message: str, *, level: str = "info") -> None: + self.emit(AppEvent("log", operation, {"level": level, "message": message})) + + def check( + self, + operation: str, + *, + status: str, + message: str, + details: dict[str, object] | None = None, + ) -> None: + self.emit(AppEvent("check", operation, { + "status": status, + "message": message, + "details": details or {}, + })) + + def result(self, operation: str, *, ok: bool, payload: object | None = None) -> None: + self.emit(AppEvent("result", operation, {"ok": ok, "payload": payload if payload is not None else {}})) + + def error(self, operation: str, message: str, *, debug: object | None = None) -> None: + fields: dict[str, object] = {"message": message} + if debug is not None: + fields["debug"] = debug + self.emit(AppEvent("error", operation, fields)) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py new file mode 100644 index 00000000..69209238 --- /dev/null +++ b/src/timecapsulesmb/app/helper.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Optional, TextIO + +from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app.service import run_api_request + + +def _sink_for_stream(stream: TextIO) -> EventSink: + def emit(event: AppEvent) -> None: + stream.write(event.to_json_line()) + stream.flush() + + return EventSink(emit) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Run one structured TimeCapsuleSMB app backend request.") + parser.add_argument( + "--pretty-error", + action="store_true", + help="Also write request parsing errors to stderr for local debugging.", + ) + args = parser.parse_args(argv) + sink = _sink_for_stream(sys.stdout) + + raw = sys.stdin.read() + try: + request = json.loads(raw) + except json.JSONDecodeError as exc: + message = f"invalid JSON request: {exc.msg}" + sink.error("api", message, debug={"pos": exc.pos}) + if args.pretty_error: + print(message, file=sys.stderr) + return 1 + if not isinstance(request, dict): + sink.error("api", "request must be a JSON object") + return 1 + return run_api_request(request, sink) + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py new file mode 100644 index 00000000..e01d82b0 --- /dev/null +++ b/src/timecapsulesmb/app/service.py @@ -0,0 +1,898 @@ +from __future__ import annotations + +import argparse +import io +import shlex +import sys +import tempfile +import uuid +from contextlib import ExitStack, redirect_stdout +from dataclasses import asdict, dataclass, is_dataclass +from pathlib import Path +from typing import Callable + +from timecapsulesmb.app.events import EventSink, redact +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli +from timecapsulesmb.cli.deploy import render_flash_runtime_config +from timecapsulesmb.cli.doctor import build_doctor_error +from timecapsulesmb.cli.fsck import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + select_fsck_target, + _target_from_volume, +) +from timecapsulesmb.cli.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, + ssh_target_link_local_resolution_error, +) +from timecapsulesmb.core.config import ( + DEFAULTS, + MANAGED_PAYLOAD_DIR_NAME, + AppConfig, + airport_family_display_name_from_identity, + parse_bool, + parse_env_file, + write_env_file, +) +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + remote_request_shutdown_reboot, + remote_uninstall_payload, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + render_post_uninstall_verification, + verify_managed_runtime, + verify_post_uninstall, +) +from timecapsulesmb.device.compat import ( + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import ( + probe_connection_state, + probe_managed_runtime_conn, + wait_for_ssh_state_conn, +) +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + build_dry_run_payload_home, + mounted_mast_volumes_conn, + read_mast_volumes_conn, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh + + +REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) +UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." +) +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +class AppOperationError(RuntimeError): + def __init__(self, message: str, *, debug: object | None = None) -> None: + super().__init__(message) + self.debug = debug + + +@dataclass(frozen=True) +class OperationResult: + ok: bool + payload: object | None = None + + +def _jsonable(value: object) -> object: + if is_dataclass(value): + return _jsonable(asdict(value)) + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + return value + + +def _config_path(params: dict[str, object]) -> Path | None: + value = params.get("config") + if value in (None, ""): + return None + return Path(str(value)) + + +def _bool_param(params: dict[str, object], name: str, default: bool = False) -> bool: + value = params.get(name, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y"} + return bool(value) + + +def _int_param(params: dict[str, object], name: str, default: int) -> int: + value = params.get(name, default) + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer") from exc + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater") + return parsed + + +def _string_param(params: dict[str, object], name: str, default: str = "") -> str: + value = params.get(name, default) + return "" if value is None else str(value) + + +def _require_string_param(params: dict[str, object], name: str) -> str: + value = _string_param(params, name).strip() + if not value: + raise AppOperationError(f"missing required parameter: {name}") + return value + + +def _selected_record_properties(params: dict[str, object]) -> dict[str, str]: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return {} + properties = selected.get("properties") + if not isinstance(properties, dict): + return {} + return {str(key): str(value) for key, value in properties.items()} + + +def _selected_record_host(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + record = BonjourResolvedService( + name=str(selected.get("name") or ""), + hostname=str(selected.get("hostname") or ""), + service_type=str(selected.get("service_type") or ""), + port=int(selected.get("port") or 0), + ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), + ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), + properties=_selected_record_properties(params), + fullname=str(selected.get("fullname") or ""), + ) + return discovered_record_root_host(record) or "" + + +def _snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, _snapshot_payload(snapshot)) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_to_jsonable(app_paths)) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=_config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") + password = _require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error) + + values = { + "TC_HOST": host, + "TC_PASSWORD": password, + "TC_SSH_OPTS": ssh_opts, + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _bool_param( + params, + "internal_share_use_disk_root", + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ) else "false", + "TC_ANY_PROTOCOL": "true" if _bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ) else "false", + "TC_CONFIGURE_ID": configure_id, + } + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not _bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError(probe.error or "The provided AirPort SSH target and password did not work.") + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility)) + + selected_props = _selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + write_env_file(env_path, values) + return OperationResult(True, { + "config_path": str(env_path), + "host": host, + "configure_id": configure_id, + "ssh_authenticated": True, + "device_syap": observed_syap, + "device_model": observed_model, + "compatibility": _jsonable(compatibility) if compatibility is not None else None, + }) + + +def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + from timecapsulesmb.cli.flows import wait_for_tcp_port_state + + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + verbose=False, + service_name="SSH port", + ) + + +def _require_supported_payload(target, *, allow_unsupported: bool) -> object: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility)) + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.") + return compatibility + + +def _load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, object]: + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = _bool_param(params, "nbns_enabled", True) + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + yes = _bool_param(params, "yes", True) + mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = _bool_param(params, "allow_unsupported") + debug_logging = _bool_param(params, "debug_logging") + + config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=_config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures)) + + sink.stage(operation, "check_compatibility") + compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " + f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart." + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError(f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.") + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deployment_plan_to_jsonable(plan)) + + if is_netbsd4 and not yes: + raise AppOperationError("NetBSD 4 deploy requires explicit confirmation.") + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, { + "payload_dir": plan.payload_dir, + "netbsd4": True, + "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + }) + + if no_reboot: + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) + if not yes: + device_name = airport_family_display_name_from_identity( + model=target.probe_state.probe_result.airport_model if target.probe_state else None, + syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, + ) + raise AppOperationError(f"Deploy requires confirmation to reboot the {device_name}.") + + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) + + +def _verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError(f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}") + + +def _verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError(f"Managed runtime did not become ready. {verification.detail.strip()}".strip()) + + +def _request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + _request_ssh_reboot(operation, sink, connection, shutdown=False) + else: + _request_ssh_reboot(operation, sink, connection, shutdown=True) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message) + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) + sink.log(operation, "Device is back online.") + + +def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: + try: + if shutdown: + remote_request_shutdown_reboot(connection) + else: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + yes = _bool_param(params, "yes", True) + dry_run = _bool_param(params, "dry_run") + _, target = _load_config_and_target(operation, params, sink, profile="activate", include_probe=True) + compatibility = _require_supported_payload(target, allow_unsupported=False) + if not is_netbsd4_payload_family(compatibility.payload_family): + raise AppOperationError("activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.") + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, _jsonable(plan)) + if not yes: + raise AppOperationError("NetBSD4 activation requires explicit confirmation.") + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, {"already_active": True}) + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + yes = _bool_param(params, "yes", True) + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_to_jsonable(plan)) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, {"rebooted": False, "verified": False}) + if not yes: + raise AppOperationError("Uninstall requires explicit confirmation to reboot.") + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.") + return OperationResult(True, {"rebooted": True, "verified": True}) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + yes = _bool_param(params, "yes", True) + no_reboot = _bool_param(params, "no_reboot") + no_wait = _bool_param(params, "no_wait") + if not yes: + raise AppOperationError("fsck requires explicit confirmation.") + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + tuple(_target_from_volume(volume) for volume in mounted_volumes), + _string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc)) from exc + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, { + "device": target.device, + "mountpoint": target.mountpoint, + "returncode": proc.returncode, + }) + if no_wait: + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) + _observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) + + +def _observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message) + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) + + +class _RepairContext: + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self.sink.stage(self.operation, stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + dry_run = _bool_param(params, "dry_run") + yes = _bool_param(params, "yes") + if not dry_run and not yes: + raise AppOperationError("repair-xattrs requires dry_run or explicit confirmation.") + if sys.platform != "darwin": + raise AppOperationError("repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.") + config = load_optional_env_config(env_path=_config_path(params)) + args = argparse.Namespace( + path=Path(str(params["path"])) if params.get("path") else None, + dry_run=dry_run, + yes=yes, + recursive=_bool_param(params, "recursive", True), + max_depth=params.get("max_depth"), + include_hidden=_bool_param(params, "include_hidden"), + include_time_machine=_bool_param(params, "include_time_machine"), + fix_permissions=_bool_param(params, "fix_permissions"), + verbose=_bool_param(params, "verbose"), + ) + if args.max_depth is not None: + args.max_depth = int(args.max_depth) + context = _RepairContext(operation, sink) + output = io.StringIO() + with redirect_stdout(output): + try: + rc = repair_xattrs_cli.run_repair(args, context, config) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message) from exc + for line in output.getvalue().splitlines(): + sink.log(operation, line) + return OperationResult(rc == 0, {"returncode": rc, "telemetry_result": context.result, "error": context.error}) + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + app_paths = resolve_app_paths(config_path=_config_path(params)) + connection = None + if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=_bool_param(params, "skip_ssh"), + skip_bonjour=_bool_param(params, "skip_bonjour"), + skip_smb=_bool_param(params, "skip_smb"), + on_result=on_result, + debug_fields=debug_fields, + ) + payload = { + "fatal": fatal, + "results": [_jsonable(result) for result in results], + "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", + } + if fatal: + payload["error"] = build_doctor_error(results, debug_fields) + return OperationResult(not fatal, payload) + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} + + +def run_api_request(request: dict[str, object], sink: EventSink) -> int: + operation = str(request.get("operation") or "") + params = request.get("params") or {} + if not operation: + sink.error("api", "missing required field: operation") + return 1 + if not isinstance(params, dict): + sink.error(operation, "params must be a JSON object") + return 1 + handler = OPERATIONS.get(operation) + if handler is None: + sink.error(operation, f"unknown operation: {operation}", debug={"known_operations": sorted(OPERATIONS)}) + return 1 + try: + result = handler(params, sink) + except AppOperationError as exc: + sink.error(operation, str(exc), debug=redact(exc.debug) if exc.debug is not None else None) + return 1 + except (SystemExit, KeyboardInterrupt): + raise + except Exception as exc: + sink.error(operation, f"{type(exc).__name__}: {exc}") + return 1 + sink.result(operation, ok=result.ok, payload=result.payload) + return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/cli/main.py b/src/timecapsulesmb/cli/main.py index 0dc61e2f..0062d5a0 100644 --- a/src/timecapsulesmb/cli/main.py +++ b/src/timecapsulesmb/cli/main.py @@ -5,11 +5,13 @@ from typing import Optional from . import activate, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install +from timecapsulesmb.app import helper as app_helper from timecapsulesmb.core.paths import DistributionRootError from .version_check import check_client_version, render_version_block_message COMMANDS = { + "api": app_helper.main, "bootstrap": bootstrap.main, "activate": activate.main, "configure": configure.main, @@ -36,7 +38,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) - if "-h" not in args.args and "--help" not in args.args: + if args.command != "api" and "-h" not in args.args and "--help" not in args.args: try: version_check = check_client_version() if version_check.should_block: diff --git a/tests/test_app_api.py b/tests/test_app_api.py new file mode 100644 index 00000000..975d2de1 --- /dev/null +++ b/tests/test_app_api.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app import helper, service +from timecapsulesmb.cli import main as cli_main +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.device.compat import DeviceCompatibility +from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState +from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance +from timecapsulesmb.transport.ssh import SshConnection + + +class CollectingSink: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + self.sink = EventSink(lambda event: self.events.append(event.to_jsonable())) + + def events_of_type(self, event_type: str) -> list[dict[str, object]]: + return [event for event in self.events if event["type"] == event_type] + + +def supported_compatibility(payload_family: str = "netbsd6_samba4") -> DeviceCompatibility: + return DeviceCompatibility( + os_name="NetBSD", + os_release="6.0", + arch="earmv4", + elf_endianness="little", + payload_family=payload_family, + device_generation="gen5", + supported=True, + reason_code="supported_netbsd6", + syap_candidates=("119",), + model_candidates=("TimeCapsule8,119",), + ) + + +def probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="6.0", + arch="earmv4", + elf_endianness="little", + airport_model="TimeCapsule8,119", + airport_syap="119", + ), + compatibility=supported_compatibility(), + ) + + +class AppApiTests(unittest.TestCase): + def test_event_redacts_password_fields(self) -> None: + event = AppEvent("result", "configure", { + "ok": True, + "payload": { + "password": "secret", + "nested": {"TC_PASSWORD": "secret"}, + }, + }) + + data = event.to_jsonable() + + self.assertEqual(data["payload"]["password"], "") + self.assertEqual(data["payload"]["nested"]["TC_PASSWORD"], "") + + def test_result_event_preserves_falsey_payloads(self) -> None: + collector = CollectingSink() + + collector.sink.result("paths", ok=True, payload=[]) + + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"], []) + + def test_unknown_operation_emits_error_without_result(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "nope", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(len(collector.events_of_type("error")), 1) + self.assertEqual(collector.events_of_type("result"), []) + + def test_discover_operation_returns_snapshot_payload(self) -> None: + collector = CollectingSink() + snapshot = BonjourDiscoverySnapshot( + instances=[BonjourServiceInstance("_airport._tcp.local.", "TC", "TC._airport._tcp.local.")], + resolved=[ + BonjourResolvedService( + name="TC", + hostname="tc.local.", + service_type="_airport._tcp.local.", + port=5009, + ipv4=("10.0.0.2",), + properties={"syAP": "119"}, + ) + ], + ) + + with mock.patch("timecapsulesmb.app.service.discover_snapshot", return_value=snapshot): + rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) + + self.assertEqual(rc, 0) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["resolved"][0]["name"], "TC") + self.assertEqual(result["payload"]["resolved"][0]["ipv4"], ["10.0.0.2"]) + + def test_configure_writes_env_without_leaking_password_to_events(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.service.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertIn("TC_HOST=root@10.0.0.2", config_path.read_text()) + self.assertIn("TC_PASSWORD=goodpw", config_path.read_text()) + serialized_events = json.dumps(collector.events) + self.assertNotIn("goodpw", serialized_events) + + def test_doctor_streams_check_events(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + def fake_run_doctor_checks(*_args, **kwargs): + kwargs["on_result"](CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})) + return [CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})], False + + with mock.patch("timecapsulesmb.app.service.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.service.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.service.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + checks = collector.events_of_type("check") + self.assertEqual(len(checks), 1) + self.assertEqual(checks[0]["status"], "PASS") + self.assertEqual(checks[0]["details"], {"port": 445}) + + def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.service.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.service.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.service.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.service.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.service.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["host"], "root@10.0.0.2") + self.assertEqual(result["payload"]["reboot_required"], True) + + def test_helper_reads_request_and_writes_ndjson(self) -> None: + output = io.StringIO() + fake_stdin = io.StringIO('{"operation":"paths","params":{}}') + with mock.patch.object(sys, "stdin", fake_stdin): + with mock.patch("timecapsulesmb.app.helper.run_api_request") as run_mock: + run_mock.side_effect = lambda request, sink: (sink.result(request["operation"], ok=True, payload={"ok": True}) or 0) + with redirect_stdout(output): + rc = helper.main([]) + + self.assertEqual(rc, 0) + line = json.loads(output.getvalue()) + self.assertEqual(line["type"], "result") + self.assertEqual(line["operation"], "paths") + + def test_api_command_is_registered(self) -> None: + self.assertIs(cli_main.COMMANDS["api"], helper.main) + + +if __name__ == "__main__": + unittest.main() From 7716369d8e5665a7aab19b6cf771af8c8b5612c2 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 21:56:03 -0700 Subject: [PATCH 02/13] Implement macOS helper runner, confirmations, and service-layer app operations --- macos/TimeCapsuleSMB/Package.swift | 29 +- .../TimeCapsuleSMBApp/BackendClient.swift | 122 +-- .../TimeCapsuleSMBApp/ContentView.swift | 64 +- .../TimeCapsuleSMBApp/HelperLocator.swift | 195 ++++ .../TimeCapsuleSMBApp/HelperRunner.swift | 171 ++++ .../Sources/TimeCapsuleSMBApp/Models.swift | 86 +- .../TimeCapsuleSMBApp/OutputLineParser.swift | 42 + .../PendingConfirmation.swift | 86 ++ .../main.swift} | 4 +- .../BackendEventTests.swift | 37 + .../HelperLocatorTests.swift | 69 ++ .../HelperRunnerTests.swift | 90 ++ .../OutputLineParserTests.swift | 20 + .../PendingConfirmationTests.swift | 41 + .../TemporaryDirectory.swift | 10 + src/timecapsulesmb/app/events.py | 39 +- src/timecapsulesmb/app/helper.py | 10 +- src/timecapsulesmb/app/operations.py | 863 +++++++++++++++++ src/timecapsulesmb/app/service.py | 909 +----------------- src/timecapsulesmb/cli/repair_xattrs.py | 150 ++- src/timecapsulesmb/services/__init__.py | 2 + src/timecapsulesmb/services/app.py | 80 ++ tests/test_app_api.py | 523 +++++++++- 23 files changed, 2558 insertions(+), 1084 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift rename macos/TimeCapsuleSMB/Sources/{TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift => TimeCapsuleSMBExecutable/main.swift} (64%) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift create mode 100644 src/timecapsulesmb/app/operations.py create mode 100644 src/timecapsulesmb/services/__init__.py create mode 100644 src/timecapsulesmb/services/app.py diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift index bd471c91..a5ccd2a6 100644 --- a/macos/TimeCapsuleSMB/Package.swift +++ b/macos/TimeCapsuleSMB/Package.swift @@ -1,15 +1,38 @@ // swift-tools-version: 5.9 +import Foundation import PackageDescription +let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "/Applications/Xcode.app/Contents/Developer" +let xcodeFrameworkPath = "\(developerDir)/Platforms/MacOSX.platform/Developer/Library/Frameworks" +let xcodeFrameworkFlags = FileManager.default.fileExists(atPath: xcodeFrameworkPath) + ? ["-F", xcodeFrameworkPath] + : [] +let xcodeSwiftSettings: [SwiftSetting] = xcodeFrameworkFlags.isEmpty ? [] : [.unsafeFlags(xcodeFrameworkFlags)] +let xcodeLinkerSettings: [LinkerSetting] = xcodeFrameworkFlags.isEmpty ? [] : [.unsafeFlags(xcodeFrameworkFlags)] + let package = Package( name: "TimeCapsuleSMBMac", platforms: [.macOS(.v13)], products: [ - .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBApp"]) + .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) ], targets: [ - .executableTarget(name: "TimeCapsuleSMBApp") + .target( + name: "TimeCapsuleSMBApp", + path: "Sources/TimeCapsuleSMBApp" + ), + .executableTarget( + name: "TimeCapsuleSMBExecutable", + dependencies: ["TimeCapsuleSMBApp"], + path: "Sources/TimeCapsuleSMBExecutable" + ), + .testTarget( + name: "TimeCapsuleSMBAppTests", + dependencies: ["TimeCapsuleSMBApp"], + path: "Tests/TimeCapsuleSMBAppTests", + swiftSettings: xcodeSwiftSettings, + linkerSettings: xcodeLinkerSettings + ) ] ) - diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index 9ec5bcf0..27a0f10e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -7,8 +7,12 @@ final class BackendClient: ObservableObject { @Published var isRunning = false @Published var lastExitCode: Int32? - init() { - helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? ".venv/bin/tcapsule" + private let runner: HelperRunner + private var runTask: Task? + + init(runner: HelperRunner = HelperRunner()) { + self.runner = runner + helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" } func clear() { @@ -20,10 +24,10 @@ final class BackendClient: ObservableObject { guard !isRunning else { return } isRunning = true lastExitCode = nil - let helperPath = self.helperPath - Task.detached { - let exitCode = await Self.runHelper( - helperPath: helperPath, + let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) + runTask = Task { + let result = await runner.run( + helperPath: helperPath.isEmpty ? nil : helperPath, operation: operation, params: params ) { event in @@ -31,109 +35,13 @@ final class BackendClient: ObservableObject { self.events.append(event) } } - await MainActor.run { - self.lastExitCode = exitCode - self.isRunning = false - } - } - } - - private static func runHelper( - helperPath: String, - operation: String, - params: [String: JSONValue], - onEvent: @escaping (BackendEvent) -> Void - ) async -> Int32 { - let process = Process() - process.executableURL = helperURL(for: helperPath) - process.arguments = ["api"] - process.environment = helperEnvironment() - - let input = Pipe() - let output = Pipe() - let error = Pipe() - process.standardInput = input - process.standardOutput = output - process.standardError = error - - let decoder = JSONDecoder() - let parser = OutputLineParser(onEvent: onEvent) - output.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - parser.append(data) - } - - do { - try process.run() - let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] - let requestData = try JSONEncoder().encode(JSONValue.object(request)) - input.fileHandleForWriting.write(requestData) - input.fileHandleForWriting.closeFile() - process.waitUntilExit() - output.fileHandleForReading.readabilityHandler = nil - _ = error.fileHandleForReading.readDataToEndOfFile() - return process.terminationStatus - } catch { - let fallback = """ - {"type":"error","operation":"\(operation)","message":"\(error.localizedDescription)"} - """ - if let data = fallback.data(using: .utf8), let event = try? decoder.decode(BackendEvent.self, from: data) { - onEvent(event) - } - return 1 - } - } - - private static func helperURL(for path: String) -> URL { - if path.hasPrefix("/") { - return URL(fileURLWithPath: path) - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(path) - } - - private static func helperEnvironment() -> [String: String] { - var environment = ProcessInfo.processInfo.environment - guard - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first?.appendingPathComponent("TimeCapsuleSMB", isDirectory: true) - else { - return environment - } - try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - if environment["TCAPSULE_CONFIG"] == nil { - environment["TCAPSULE_CONFIG"] = appSupport.appendingPathComponent(".env").path - } - if environment["TCAPSULE_STATE_DIR"] == nil { - environment["TCAPSULE_STATE_DIR"] = appSupport.path + self.lastExitCode = result.exitCode + self.isRunning = false + self.runTask = nil } - return environment - } -} - -private final class OutputLineParser: @unchecked Sendable { - private let lock = NSLock() - private var buffer = Data() - private let decoder = JSONDecoder() - private let onEvent: (BackendEvent) -> Void - - init(onEvent: @escaping (BackendEvent) -> Void) { - self.onEvent = onEvent } - func append(_ data: Data) { - lock.lock() - defer { lock.unlock() } - buffer.append(data) - while let newline = buffer.firstIndex(of: 0x0A) { - let line = buffer.prefix(upTo: newline) - buffer.removeSubrange(...newline) - guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: line) else { - continue - } - onEvent(event) - } + func cancel() { + runTask?.cancel() } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index ac342031..1feeef25 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ContentView: View { +public struct ContentView: View { @StateObject private var backend = BackendClient() @State private var selection: Screen = .readiness @State private var host = "root@192.168.x.x" @@ -10,8 +10,11 @@ struct ContentView: View { @State private var nbnsEnabled = true @State private var noReboot = false @State private var dryRun = true + @State private var pendingConfirmation: PendingConfirmation? - var body: some View { + public init() {} + + public var body: some View { NavigationSplitView { List(Screen.allCases, selection: $selection) { screen in Label(screen.title, systemImage: screen.icon) @@ -32,10 +35,26 @@ struct ContentView: View { Label("Clear", systemImage: "trash") } .disabled(backend.isRunning) + Button { + backend.cancel() + } label: { + Label("Cancel", systemImage: "xmark.circle") + } + .disabled(!backend.isRunning) } } } .frame(minWidth: 980, minHeight: 680) + .alert(item: $pendingConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text(confirmation.actionTitle)) { + backend.run(operation: confirmation.operation, params: confirmation.params) + }, + secondaryButton: .cancel() + ) + } } @ViewBuilder @@ -72,12 +91,15 @@ struct ContentView: View { Toggle("No Reboot", isOn: $noReboot) Toggle("Dry Run", isOn: $dryRun) Button { - backend.run(operation: "deploy", params: [ - "dry_run": .bool(dryRun), - "yes": .bool(true), - "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled) - ]) + if dryRun { + backend.run(operation: "deploy", params: [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "nbns_enabled": .bool(nbnsEnabled) + ]) + } else { + pendingConfirmation = .deploy(noReboot: noReboot, nbnsEnabled: nbnsEnabled) + } } label: { Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") } @@ -91,16 +113,25 @@ struct ContentView: View { CommandPanel(title: "Maintenance") { TextField("Repair xattrs path", text: $repairPath) TextField("fsck volume, optional", text: $volume) + Toggle("No Reboot", isOn: $noReboot) HStack { - runButton("Activate", icon: "power", operation: "activate", params: ["yes": .bool(true)]) + Button { + pendingConfirmation = .activate() + } label: { + Label("Activate", systemImage: "power") + } + .disabled(backend.isRunning) runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: ["dry_run": .bool(true)]) + Button { + pendingConfirmation = .uninstall(noReboot: noReboot) + } label: { + Label("Uninstall", systemImage: "xmark.bin.fill") + } + .disabled(backend.isRunning) } HStack { Button { - backend.run(operation: "fsck", params: [ - "yes": .bool(true), - "volume": .string(volume) - ]) + pendingConfirmation = .fsck(volume: volume, noReboot: noReboot) } label: { Label("Run fsck", systemImage: "externaldrive.badge.checkmark") } @@ -114,6 +145,12 @@ struct ContentView: View { Label("Scan xattrs", systemImage: "wand.and.stars") } .disabled(backend.isRunning || repairPath.isEmpty) + Button { + pendingConfirmation = .repairXattrs(path: repairPath) + } label: { + Label("Repair xattrs", systemImage: "wand.and.stars.inverse") + } + .disabled(backend.isRunning || repairPath.isEmpty) } } case .advanced: @@ -208,4 +245,3 @@ private struct EventList: View { } } } - diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift new file mode 100644 index 00000000..9ee981dd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift @@ -0,0 +1,195 @@ +import Foundation + +public struct HelperResolution: Equatable { + public let executableURL: URL + public let distributionRootURL: URL? + public let attemptedPaths: [String] +} + +public enum HelperLocatorError: Error, Equatable, LocalizedError { + case notFound([String]) + + public var errorDescription: String? { + switch self { + case .notFound(let attempts): + let attempted = attempts.isEmpty ? "none" : attempts.joined(separator: ", ") + return "Could not find the TimeCapsuleSMB helper. Attempted: \(attempted)" + } + } +} + +public struct HelperLocator { + public var environment: [String: String] + public var currentDirectory: URL + public var bundle: Bundle + public var fileManager: FileManager + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + currentDirectory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true), + bundle: Bundle = .main, + fileManager: FileManager = .default + ) { + self.environment = environment + self.currentDirectory = currentDirectory + self.bundle = bundle + self.fileManager = fileManager + } + + public func resolve(helperPath: String?) throws -> HelperResolution { + var attempts: [String] = [] + if let explicit = normalized(helperPath) { + return try resolveExplicitPath(explicit, attempts: &attempts) + } + if let fromEnvironment = normalized(environment["TCAPSULE_HELPER"]) { + return try resolveExplicitPath(fromEnvironment, attempts: &attempts) + } + + for candidate in bundledHelperCandidates() + devHelperCandidates() { + attempts.append(candidate.path) + if isExecutable(candidate) { + return HelperResolution( + executableURL: candidate, + distributionRootURL: distributionRoot(for: candidate), + attemptedPaths: attempts + ) + } + } + throw HelperLocatorError.notFound(attempts) + } + + public func helperEnvironment(for resolution: HelperResolution) -> [String: String] { + var output = environment + if let appSupport = applicationSupportDirectory() { + try? fileManager.createDirectory(at: appSupport, withIntermediateDirectories: true) + if output["TCAPSULE_CONFIG"] == nil { + output["TCAPSULE_CONFIG"] = appSupport.appendingPathComponent(".env").path + } + if output["TCAPSULE_STATE_DIR"] == nil { + output["TCAPSULE_STATE_DIR"] = appSupport.path + } + } + if output["TCAPSULE_DISTRIBUTION_ROOT"] == nil, let distributionRoot = resolution.distributionRootURL { + output["TCAPSULE_DISTRIBUTION_ROOT"] = distributionRoot.path + } + return output + } + + private func resolveExplicitPath(_ path: String, attempts: inout [String]) throws -> HelperResolution { + let candidate = url(forPath: path) + attempts.append(candidate.path) + guard isExecutable(candidate) else { + throw HelperLocatorError.notFound(attempts) + } + return HelperResolution( + executableURL: candidate, + distributionRootURL: distributionRoot(for: candidate), + attemptedPaths: attempts + ) + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func url(forPath path: String) -> URL { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + return currentDirectory.appendingPathComponent(path) + } + + private func bundledHelperCandidates() -> [URL] { + var candidates: [URL] = [] + if let helper = bundle.url(forResource: "tcapsule", withExtension: nil, subdirectory: "Helpers") { + candidates.append(helper) + } + if let helper = bundle.url(forResource: "tcapsule", withExtension: nil) { + candidates.append(helper) + } + return candidates + } + + private func devHelperCandidates() -> [URL] { + var roots: [URL] = [] + if let explicitRoot = normalized(environment["TCAPSULE_SOURCE_ROOT"]) { + roots.append(url(forPath: explicitRoot)) + } + roots.append(contentsOf: ancestorDirectories(startingAt: currentDirectory)) + return unique(roots).map { $0.appendingPathComponent(".venv/bin/tcapsule") } + } + + private func distributionRoot(for helperURL: URL) -> URL? { + if let explicit = normalized(environment["TCAPSULE_DISTRIBUTION_ROOT"]) { + return url(forPath: explicit) + } + if let repo = repoRoot(containing: helperURL) { + return repo + } + if let bundled = bundle.resourceURL?.appendingPathComponent("Distribution"), isDirectory(bundled) { + return bundled + } + return nil + } + + private func repoRoot(containing helperURL: URL) -> URL? { + for candidate in ancestorDirectories(startingAt: helperURL.deletingLastPathComponent()) { + if isRepoRoot(candidate) { + return candidate + } + } + return nil + } + + private func ancestorDirectories(startingAt start: URL) -> [URL] { + var output: [URL] = [] + var current = start.standardizedFileURL.path + while true { + output.append(URL(fileURLWithPath: current, isDirectory: true)) + let parent = (current as NSString).deletingLastPathComponent + if parent == current || parent.isEmpty { + break + } + current = parent + } + return output + } + + private func unique(_ urls: [URL]) -> [URL] { + var seen: Set = [] + var output: [URL] = [] + for url in urls { + let path = url.standardizedFileURL.path + if seen.insert(path).inserted { + output.append(url.standardizedFileURL) + } + } + return output + } + + private func isExecutable(_ url: URL) -> Bool { + fileManager.isExecutableFile(atPath: url.path) + } + + private func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + private func isRepoRoot(_ url: URL) -> Bool { + let pyproject = url.appendingPathComponent("pyproject.toml") + let bin = url.appendingPathComponent("bin") + let sourcePackage = url.appendingPathComponent("src/timecapsulesmb") + return fileManager.fileExists(atPath: pyproject.path) + && isDirectory(bin) + && isDirectory(sourcePackage) + } + + private func applicationSupportDirectory() -> URL? { + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift new file mode 100644 index 00000000..79654aa8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -0,0 +1,171 @@ +import Darwin +import Foundation + +public struct HelperRunResult: Equatable { + public let exitCode: Int32 + public let sawTerminalEvent: Bool + public let stderr: String +} + +public final class HelperRunner { + private let locator: HelperLocator + private let stderrLimit: Int + + public init(locator: HelperLocator = HelperLocator(), stderrLimit: Int = 64 * 1024) { + self.locator = locator + self.stderrLimit = stderrLimit + } + + public func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + onEvent: @escaping (BackendEvent) -> Void + ) async -> HelperRunResult { + let terminalTracker = TerminalEventTracker() + let eventSink: (BackendEvent) -> Void = { event in + terminalTracker.record(event) + onEvent(event) + } + + let resolution: HelperResolution + do { + resolution = try locator.resolve(helperPath: helperPath) + } catch { + eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let process = Process() + process.executableURL = resolution.executableURL + process.arguments = ["api"] + process.environment = locator.helperEnvironment(for: resolution) + + let input = Pipe() + let output = Pipe() + let error = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = error + + let parser = OutputLineParser(onEvent: eventSink) + do { + try process.run() + } catch { + eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let stdoutTask = Task.detached { + Self.readOutput(output.fileHandleForReading, parser: parser) + } + let stderrTask = Task.detached { + Self.readCapped(error.fileHandleForReading, limit: self.stderrLimit) + } + + do { + let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] + let requestData = try JSONEncoder().encode(JSONValue.object(request)) + try input.fileHandleForWriting.write(contentsOf: requestData) + try input.fileHandleForWriting.close() + } catch { + try? input.fileHandleForWriting.close() + terminate(process) + eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + await stdoutTask.value + let stderr = await stderrTask.value + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + + var cancelled = false + while process.isRunning { + if Task.isCancelled { + cancelled = true + terminate(process) + break + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + + await stdoutTask.value + let stderrText = await stderrTask.value + let sawTerminalEvent = terminalTracker.sawTerminalEvent + if cancelled { + eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: "Operation cancelled.", + debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) + )) + } else if !sawTerminalEvent { + eventSink(BackendEvent.error( + operation: operation, + code: "missing_terminal_event", + message: "Helper exited without a result or error event.", + debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) + )) + } + + return HelperRunResult( + exitCode: cancelled ? 130 : process.terminationStatus, + sawTerminalEvent: terminalTracker.sawTerminalEvent, + stderr: stderrText + ) + } + + private static func readOutput(_ handle: FileHandle, parser: OutputLineParser) { + while true { + let data = handle.availableData + if data.isEmpty { + parser.finish() + return + } + parser.append(data) + } + } + + private static func readCapped(_ handle: FileHandle, limit: Int) -> String { + var output = Data() + while true { + let data = handle.availableData + if data.isEmpty { + break + } + if output.count < limit { + output.append(data.prefix(limit - output.count)) + } + } + return String(data: output, encoding: .utf8) ?? "" + } + + private func terminate(_ process: Process) { + process.terminate() + for _ in 0..<10 { + if !process.isRunning { + return + } + Thread.sleep(forTimeInterval: 0.1) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + } +} + +private final class TerminalEventTracker: @unchecked Sendable { + private let lock = NSLock() + private var seen = false + + var sawTerminalEvent: Bool { + lock.lock() + defer { lock.unlock() } + return seen + } + + func record(_ event: BackendEvent) { + guard event.type == "result" || event.type == "error" else { return } + lock.lock() + seen = true + lock.unlock() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index b9a70e53..bd2598fa 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -1,6 +1,6 @@ import Foundation -enum JSONValue: Codable, Hashable { +public enum JSONValue: Codable, Hashable { case string(String) case number(Double) case bool(Bool) @@ -8,7 +8,7 @@ enum JSONValue: Codable, Hashable { case array([JSONValue]) case null - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null @@ -25,7 +25,7 @@ enum JSONValue: Codable, Hashable { } } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .string(let value): @@ -43,7 +43,7 @@ enum JSONValue: Codable, Hashable { } } - var displayText: String { + public var displayText: String { switch self { case .string(let value): return value @@ -65,22 +65,73 @@ enum JSONValue: Codable, Hashable { } } -struct BackendEvent: Decodable, Identifiable { - let id = UUID() - let type: String - let operation: String - let stage: String? - let level: String? - let message: String? - let status: String? - let ok: Bool? - let payload: JSONValue? - let details: JSONValue? - let debug: JSONValue? +public struct BackendEvent: Decodable, Identifiable { + public let id = UUID() + public let schemaVersion: Int? + public let requestId: String? + public let type: String + public let operation: String + public let code: String? + public let stage: String? + public let level: String? + public let message: String? + public let status: String? + public let ok: Bool? + public let payload: JSONValue? + public let details: JSONValue? + public let debug: JSONValue? + + public init( + schemaVersion: Int? = 1, + requestId: String? = UUID().uuidString, + type: String, + operation: String, + code: String? = nil, + stage: String? = nil, + level: String? = nil, + message: String? = nil, + status: String? = nil, + ok: Bool? = nil, + payload: JSONValue? = nil, + details: JSONValue? = nil, + debug: JSONValue? = nil + ) { + self.schemaVersion = schemaVersion + self.requestId = requestId + self.type = type + self.operation = operation + self.code = code + self.stage = stage + self.level = level + self.message = message + self.status = status + self.ok = ok + self.payload = payload + self.details = details + self.debug = debug + } + + public static func error( + operation: String, + code: String, + message: String, + debug: JSONValue? = nil + ) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: code, + message: message, + debug: debug + ) + } enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case requestId = "request_id" case type case operation + case code case stage case level case message @@ -91,7 +142,7 @@ struct BackendEvent: Decodable, Identifiable { case debug } - var summary: String { + public var summary: String { switch type { case "stage": return stage.map { "\(operation): \($0)" } ?? operation @@ -106,4 +157,3 @@ struct BackendEvent: Decodable, Identifiable { } } } - diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift new file mode 100644 index 00000000..4e702be2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift @@ -0,0 +1,42 @@ +import Foundation + +public final class OutputLineParser: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private let decoder = JSONDecoder() + private let onEvent: (BackendEvent) -> Void + + public init(onEvent: @escaping (BackendEvent) -> Void) { + self.onEvent = onEvent + } + + public func append(_ data: Data) { + lock.lock() + defer { lock.unlock() } + buffer.append(data) + consumeCompleteLines() + } + + public func finish() { + lock.lock() + defer { lock.unlock() } + guard !buffer.isEmpty else { return } + emit(buffer) + buffer.removeAll() + } + + private func consumeCompleteLines() { + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + emit(line) + } + } + + private func emit(_ line: Data.SubSequence) { + guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: Data(line)) else { + return + } + onEvent(event) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift new file mode 100644 index 00000000..09bbb35f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -0,0 +1,86 @@ +import Foundation + +struct PendingConfirmation: Identifiable { + let id = UUID() + let title: String + let message: String + let actionTitle: String + let operation: String + let params: [String: JSONValue] + + static func deploy(noReboot: Bool, nbnsEnabled: Bool) -> PendingConfirmation { + PendingConfirmation( + title: noReboot ? "Deploy Without Reboot?" : "Deploy And Reboot?", + message: noReboot + ? "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device." + : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately.", + actionTitle: noReboot ? "Deploy" : "Deploy And Allow Reboot", + operation: "deploy", + params: [ + "dry_run": .bool(false), + "confirm_deploy": .bool(true), + "confirm_reboot": .bool(!noReboot), + "confirm_netbsd4_activation": .bool(true), + "no_reboot": .bool(noReboot), + "nbns_enabled": .bool(nbnsEnabled) + ] + ) + } + + static func activate() -> PendingConfirmation { + PendingConfirmation( + title: "Activate NetBSD 4 Runtime?", + message: "This will restart the deployed Samba runtime on an older NetBSD 4 device.", + actionTitle: "Activate", + operation: "activate", + params: ["confirm_netbsd4_activation": .bool(true)] + ) + } + + static func fsck(volume: String, noReboot: Bool) -> PendingConfirmation { + PendingConfirmation( + title: noReboot ? "Run Disk Repair Without Reboot?" : "Run Disk Repair And Reboot?", + message: noReboot + ? "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward." + : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot.", + actionTitle: "Run fsck", + operation: "fsck", + params: [ + "confirm_fsck": .bool(true), + "no_reboot": .bool(noReboot), + "volume": .string(volume) + ] + ) + } + + static func uninstall(noReboot: Bool) -> PendingConfirmation { + PendingConfirmation( + title: noReboot ? "Uninstall Without Reboot?" : "Uninstall And Reboot?", + message: noReboot + ? "This will remove the managed TimeCapsuleSMB payload without rebooting the device." + : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot.", + actionTitle: "Uninstall", + operation: "uninstall", + params: [ + "dry_run": .bool(false), + "confirm_uninstall": .bool(true), + "confirm_reboot": .bool(!noReboot), + "no_reboot": .bool(noReboot) + ] + ) + } + + static func repairXattrs(path: String) -> PendingConfirmation { + PendingConfirmation( + title: "Repair Extended Attributes?", + message: "This will repair extended attributes at the selected mounted SMB path.", + actionTitle: "Repair xattrs", + operation: "repair-xattrs", + params: [ + "path": .string(path), + "dry_run": .bool(false), + "confirm_repair": .bool(true) + ] + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift similarity index 64% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift index 390cb570..b3620ecd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -1,11 +1,11 @@ import SwiftUI +import TimeCapsuleSMBApp @main -struct TimeCapsuleSMBApp: App { +struct TimeCapsuleSMBExecutable: App { var body: some Scene { WindowGroup { ContentView() } } } - diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift new file mode 100644 index 00000000..8f80a4e5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -0,0 +1,37 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class BackendEventTests: XCTestCase { + func testBackendEventDecodesContractFields() throws { + let data = """ + {"schema_version":1,"request_id":"req-1","type":"error","operation":"deploy","code":"remote_error","message":"failed","debug":{"stderr":"detail"}} + """.data(using: .utf8)! + + let event = try JSONDecoder().decode(BackendEvent.self, from: data) + + XCTAssertEqual(event.schemaVersion, 1) + XCTAssertEqual(event.requestId, "req-1") + XCTAssertEqual(event.type, "error") + XCTAssertEqual(event.operation, "deploy") + XCTAssertEqual(event.code, "remote_error") + XCTAssertEqual(event.message, "failed") + XCTAssertEqual(event.debug, .object(["stderr": .string("detail")])) + } + + func testJSONValueRoundTripsNestedObjects() throws { + let value = JSONValue.object([ + "operation": .string("paths"), + "params": .object([ + "dry_run": .bool(true), + "mount_wait": .number(30), + "items": .array([.string("one"), .null]) + ]) + ]) + + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + + XCTAssertEqual(decoded, value) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift new file mode 100644 index 00000000..0aaf26bc --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -0,0 +1,69 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class HelperLocatorTests: XCTestCase { + func testLocatorUsesExplicitHelperAndSetsAppEnvironment() throws { + let temp = try TemporaryDirectory() + let helper = temp.url.appendingPathComponent("tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let locator = HelperLocator( + environment: [:], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: helper.path) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(resolution.executableURL.path, helper.path) + XCTAssertNotNil(environment["TCAPSULE_CONFIG"]) + XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + } + + func testLocatorDiscoversRepoHelperFromSourceRoot() throws { + let temp = try TemporaryDirectory() + let repo = temp.url.appendingPathComponent("Repo", isDirectory: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent(".venv/bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("src/timecapsulesmb", isDirectory: true), withIntermediateDirectories: true) + try "".write(to: repo.appendingPathComponent("pyproject.toml"), atomically: true, encoding: .utf8) + let helper = repo.appendingPathComponent(".venv/bin/tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let locator = HelperLocator( + environment: ["TCAPSULE_SOURCE_ROOT": repo.path], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: nil) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(resolution.executableURL.path, helper.path) + XCTAssertEqual(resolution.distributionRootURL?.path, repo.path) + XCTAssertEqual(environment["TCAPSULE_DISTRIBUTION_ROOT"], repo.path) + } + + func testLocatorReportsAttemptedPathsWhenMissing() throws { + let temp = try TemporaryDirectory() + let locator = HelperLocator( + environment: ["TCAPSULE_SOURCE_ROOT": temp.url.path], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + XCTAssertThrowsError(try locator.resolve(helperPath: nil)) { error in + guard case HelperLocatorError.notFound(let attempts) = error else { + return XCTFail("unexpected error \(error)") + } + XCTAssertFalse(attempts.isEmpty) + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift new file mode 100644 index 00000000..31ca3583 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class HelperRunnerTests: XCTestCase { + func testRunnerStreamsEventsFromHelper() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"stage","operation":"paths","stage":"start"}' + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"paths","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { + recorder.append($0) + } + + let events = recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.map(\.type), ["stage", "result"]) + XCTAssertEqual(events.last?.ok, true) + } + + func testRunnerSynthesizesErrorWhenHelperHasNoTerminalEvent() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"type":"log","operation":"doctor","level":"info","message":"working"}' + echo 'stderr detail' >&2 + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + recorder.append($0) + } + + let events = recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "missing_terminal_event") + XCTAssertEqual(events.last?.debug, .object(["stderr": .string("stderr detail\n")])) + } + + func testRunnerReportsMissingHelper() async { + let locator = HelperLocator(environment: [:], currentDirectory: URL(fileURLWithPath: NSTemporaryDirectory()), bundle: .main, fileManager: .default) + let runner = HelperRunner(locator: locator) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: "/missing/tcapsule", operation: "paths", params: [:]) { + recorder.append($0) + } + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(recorder.events.last?.type, "error") + XCTAssertEqual(recorder.events.last?.code, "helper_not_found") + } + + private func makeHelper(in directory: URL, body: String) throws -> URL { + let helper = directory.appendingPathComponent("tcapsule") + try "#!/bin/sh\n\(body)\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + return helper + } +} + +private final class EventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [BackendEvent] = [] + + var events: [BackendEvent] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ event: BackendEvent) { + lock.lock() + storage.append(event) + lock.unlock() + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift new file mode 100644 index 00000000..93c87319 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift @@ -0,0 +1,20 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class OutputLineParserTests: XCTestCase { + func testParserHandlesSplitMultipleAndUnterminatedLines() { + var events: [BackendEvent] = [] + let parser = OutputLineParser { events.append($0) } + + parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8)) + parser.append(Data(#"_paths"}"#.utf8)) + parser.append(Data("\nnot-json\n".utf8)) + parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8)) + parser.finish() + + XCTAssertEqual(events.map(\.type), ["stage", "result"]) + XCTAssertEqual(events.first?.stage, "resolve_paths") + XCTAssertEqual(events.last?.ok, true) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift new file mode 100644 index 00000000..2861050b --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class PendingConfirmationTests: XCTestCase { + func testDeployConfirmationCarriesDeployAndRebootConsent() { + let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true) + + XCTAssertEqual(confirmation.operation, "deploy") + XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) + XCTAssertEqual(confirmation.params["confirm_deploy"], .bool(true)) + XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(true)) + XCTAssertEqual(confirmation.params["confirm_netbsd4_activation"], .bool(true)) + XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) + XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) + } + + func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { + let confirmation = PendingConfirmation.uninstall(noReboot: true) + + XCTAssertEqual(confirmation.operation, "uninstall") + XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) + XCTAssertEqual(confirmation.params["confirm_uninstall"], .bool(true)) + XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(false)) + XCTAssertEqual(confirmation.params["no_reboot"], .bool(true)) + } + + func testMaintenanceConfirmationsCarryExplicitOperationConsent() { + let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true) + let repair = PendingConfirmation.repairXattrs(path: "/Volumes/Data") + + XCTAssertEqual(fsck.operation, "fsck") + XCTAssertEqual(fsck.params["confirm_fsck"], .bool(true)) + XCTAssertEqual(fsck.params["no_reboot"], .bool(true)) + XCTAssertEqual(fsck.params["volume"], .string("Data")) + + XCTAssertEqual(repair.operation, "repair-xattrs") + XCTAssertEqual(repair.params["path"], .string("/Volumes/Data")) + XCTAssertEqual(repair.params["dry_run"], .bool(false)) + XCTAssertEqual(repair.params["confirm_repair"], .bool(true)) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift new file mode 100644 index 00000000..9e16daeb --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift @@ -0,0 +1,10 @@ +import Foundation + +struct TemporaryDirectory { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } +} diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index 258d9db8..2accfd9e 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Callable @@ -31,9 +32,13 @@ class AppEvent: type: str operation: str fields: dict[str, object] = field(default_factory=dict) + request_id: str | None = None + schema_version: int = 1 def to_jsonable(self) -> dict[str, object]: - data = {"type": self.type, "operation": self.operation} + data = {"schema_version": self.schema_version, "type": self.type, "operation": self.operation} + if self.request_id: + data["request_id"] = self.request_id data.update(redact(self.fields)) return data @@ -42,10 +47,29 @@ def to_json_line(self) -> str: class EventSink: - def __init__(self, emit: Callable[[AppEvent], None]) -> None: + def __init__( + self, + emit: Callable[[AppEvent], None], + *, + request_id: str | None = None, + schema_version: int = 1, + ) -> None: self._emit = emit + self.request_id = request_id or str(uuid.uuid4()) + self.schema_version = schema_version + + def with_request_id(self, request_id: str) -> "EventSink": + return EventSink(self._emit, request_id=request_id, schema_version=self.schema_version) def emit(self, event: AppEvent) -> None: + if event.request_id is None: + event = AppEvent( + event.type, + event.operation, + event.fields, + request_id=self.request_id, + schema_version=self.schema_version, + ) self._emit(event) def stage(self, operation: str, stage: str) -> None: @@ -71,8 +95,15 @@ def check( def result(self, operation: str, *, ok: bool, payload: object | None = None) -> None: self.emit(AppEvent("result", operation, {"ok": ok, "payload": payload if payload is not None else {}})) - def error(self, operation: str, message: str, *, debug: object | None = None) -> None: - fields: dict[str, object] = {"message": message} + def error( + self, + operation: str, + message: str, + *, + code: str = "operation_failed", + debug: object | None = None, + ) -> None: + fields: dict[str, object] = {"code": code, "message": message} if debug is not None: fields["debug"] = debug self.emit(AppEvent("error", operation, fields)) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py index 69209238..bf2ace48 100644 --- a/src/timecapsulesmb/app/helper.py +++ b/src/timecapsulesmb/app/helper.py @@ -3,6 +3,7 @@ import argparse import json import sys +import uuid from typing import Optional, TextIO from timecapsulesmb.app.events import AppEvent, EventSink @@ -25,23 +26,22 @@ def main(argv: Optional[list[str]] = None) -> int: help="Also write request parsing errors to stderr for local debugging.", ) args = parser.parse_args(argv) - sink = _sink_for_stream(sys.stdout) + sink = _sink_for_stream(sys.stdout).with_request_id(str(uuid.uuid4())) raw = sys.stdin.read() try: request = json.loads(raw) except json.JSONDecodeError as exc: message = f"invalid JSON request: {exc.msg}" - sink.error("api", message, debug={"pos": exc.pos}) + sink.error("api", message, code="invalid_request", debug={"pos": exc.pos}) if args.pretty_error: - print(message, file=sys.stderr) + print("invalid JSON request", file=sys.stderr) return 1 if not isinstance(request, dict): - sink.error("api", "request must be a JSON object") + sink.error("api", "request must be a JSON object", code="invalid_request") return 1 return run_api_request(request, sink) if __name__ == "__main__": raise SystemExit(main()) - diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py new file mode 100644 index 00000000..4d658550 --- /dev/null +++ b/src/timecapsulesmb/app/operations.py @@ -0,0 +1,863 @@ +from __future__ import annotations + +import argparse +import shlex +import sys +import tempfile +import uuid +from collections.abc import Callable +from contextlib import ExitStack +from pathlib import Path + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli +from timecapsulesmb.cli.deploy import render_flash_runtime_config +from timecapsulesmb.cli.doctor import build_doctor_error +from timecapsulesmb.cli.fsck import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + select_fsck_target, + _target_from_volume, +) +from timecapsulesmb.cli.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, + ssh_target_link_local_resolution_error, +) +from timecapsulesmb.core.config import ( + DEFAULTS, + MANAGED_PAYLOAD_DIR_NAME, + AppConfig, + airport_family_display_name_from_identity, + parse_bool, + parse_env_file, + write_env_file, +) +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + remote_request_shutdown_reboot, + remote_uninstall_payload, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + render_post_uninstall_verification, + verify_managed_runtime, + verify_post_uninstall, +) +from timecapsulesmb.device.compat import ( + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import ( + probe_connection_state, + probe_managed_runtime_conn, + wait_for_ssh_state_conn, +) +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + build_dry_run_payload_home, + mounted_mast_volumes_conn, + read_mast_volumes_conn, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param as _bool_param, + config_path as _config_path, + confirm_param as _confirm_param, + int_param as _int_param, + jsonable as _jsonable, + require_string_param as _require_string_param, + string_param as _string_param, +) +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh + + +REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) +UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." +) +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +def _selected_record_properties(params: dict[str, object]) -> dict[str, str]: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return {} + properties = selected.get("properties") + if not isinstance(properties, dict): + return {} + return {str(key): str(value) for key, value in properties.items()} + + +def _selected_record_host(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + record = BonjourResolvedService( + name=str(selected.get("name") or ""), + hostname=str(selected.get("hostname") or ""), + service_type=str(selected.get("service_type") or ""), + port=int(selected.get("port") or 0), + ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), + ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), + properties=_selected_record_properties(params), + fullname=str(selected.get("fullname") or ""), + ) + return discovered_record_root_host(record) or "" + + +def _snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, _snapshot_payload(snapshot)) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_to_jsonable(app_paths)) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=_config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") + password = _require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host", code="validation_failed") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error, code="config_error") + + values = { + "TC_HOST": host, + "TC_PASSWORD": password, + "TC_SSH_OPTS": ssh_opts, + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _bool_param( + params, + "internal_share_use_disk_root", + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ) else "false", + "TC_ANY_PROTOCOL": "true" if _bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ) else "false", + "TC_CONFIGURE_ID": configure_id, + } + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not _bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError( + probe.error or "The provided AirPort SSH target and password did not work.", + code="auth_failed", + ) + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + + selected_props = _selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + write_env_file(env_path, values) + return OperationResult(True, { + "config_path": str(env_path), + "host": host, + "configure_id": configure_id, + "ssh_authenticated": True, + "device_syap": observed_syap, + "device_model": observed_model, + "compatibility": _jsonable(compatibility) if compatibility is not None else None, + }) + + +def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + from timecapsulesmb.cli.flows import wait_for_tcp_port_state + + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + verbose=False, + service_name="SSH port", + ) + + +def _require_supported_payload(target, *, allow_unsupported: bool) -> object: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") + return compatibility + + +def _load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, object]: + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = _bool_param(params, "nbns_enabled", True) + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + confirm_deploy = _confirm_param(params, "confirm_deploy") + confirm_reboot = _confirm_param(params, "confirm_reboot") + confirm_netbsd4_activation = _confirm_param(params, "confirm_netbsd4_activation") + mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = _bool_param(params, "allow_unsupported") + debug_logging = _bool_param(params, "debug_logging") + + if not dry_run and not confirm_deploy: + raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") + + config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=_config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures), code="validation_failed") + + sink.stage(operation, "check_compatibility") + compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + if not dry_run: + if is_netbsd4 and not confirm_netbsd4_activation: + raise AppOperationError( + "NetBSD 4 deploy requires explicit activation confirmation.", + code="confirmation_required", + ) + if not is_netbsd4 and not no_reboot and not confirm_reboot: + device_name = airport_family_display_name_from_identity( + model=target.probe_state.probe_result.airport_model if target.probe_state else None, + syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, + ) + raise AppOperationError( + f"Deploy requires confirmation to reboot the {device_name}.", + code="confirmation_required", + ) + + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " + f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart.", + code="remote_error", + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError( + f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.", + code="remote_error", + ) + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deployment_plan_to_jsonable(plan)) + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, { + "payload_dir": plan.payload_dir, + "netbsd4": True, + "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + }) + + if no_reboot: + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) + + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) + + +def _verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError( + f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}", + code="remote_error", + ) + + +def _verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError( + f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), + code="remote_error", + ) + + +def _request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + _request_ssh_reboot(operation, sink, connection, shutdown=False) + else: + _request_ssh_reboot(operation, sink, connection, shutdown=True) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + sink.log(operation, "Device is back online.") + + +def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: + try: + if shutdown: + remote_request_shutdown_reboot(connection) + else: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + confirm_activation = _confirm_param(params, "confirm_netbsd4_activation") + dry_run = _bool_param(params, "dry_run") + _, target = _load_config_and_target(operation, params, sink, profile="activate", include_probe=True) + compatibility = _require_supported_payload(target, allow_unsupported=False) + if not is_netbsd4_payload_family(compatibility.payload_family): + raise AppOperationError( + "activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", + code="unsupported_device", + ) + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, _jsonable(plan)) + if not confirm_activation: + raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, {"already_active": True}) + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + confirm_uninstall = _confirm_param(params, "confirm_uninstall") + confirm_reboot = _confirm_param(params, "confirm_reboot") + if not dry_run and not confirm_uninstall: + raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") + if not dry_run and not no_reboot and not confirm_reboot: + raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_to_jsonable(plan)) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, {"rebooted": False, "verified": False}) + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") + return OperationResult(True, {"rebooted": True, "verified": True}) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + confirm_fsck = _confirm_param(params, "confirm_fsck") + no_reboot = _bool_param(params, "no_reboot") + no_wait = _bool_param(params, "no_wait") + if not confirm_fsck: + raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + tuple(_target_from_volume(volume) for volume in mounted_volumes), + _string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, { + "device": target.device, + "mountpoint": target.mountpoint, + "returncode": proc.returncode, + }) + if no_wait: + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) + _observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) + + +def _observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + + +class _RepairContext: + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self.sink.stage(self.operation, stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + dry_run = _bool_param(params, "dry_run") + confirm_repair = _confirm_param(params, "confirm_repair") + if not dry_run and not confirm_repair: + raise AppOperationError( + "repair-xattrs requires dry_run or explicit confirmation.", + code="confirmation_required", + ) + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) + config = load_optional_env_config(env_path=_config_path(params)) + args = argparse.Namespace( + path=Path(str(params["path"])) if params.get("path") else None, + dry_run=dry_run, + yes=confirm_repair, + recursive=_bool_param(params, "recursive", True), + max_depth=params.get("max_depth"), + include_hidden=_bool_param(params, "include_hidden"), + include_time_machine=_bool_param(params, "include_time_machine"), + fix_permissions=_bool_param(params, "fix_permissions"), + verbose=_bool_param(params, "verbose"), + ) + if args.max_depth is not None: + args.max_depth = int(args.max_depth) + context = _RepairContext(operation, sink) + try: + result = repair_xattrs_cli.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message, code="operation_failed") from exc + return OperationResult(result.returncode == 0, { + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "summary": _jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error, + }) + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + app_paths = resolve_app_paths(config_path=_config_path(params)) + connection = None + if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=_bool_param(params, "skip_ssh"), + skip_bonjour=_bool_param(params, "skip_bonjour"), + skip_smb=_bool_param(params, "skip_smb"), + on_result=on_result, + debug_fields=debug_fields, + ) + payload = { + "fatal": fatal, + "results": [_jsonable(result) for result in results], + "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", + } + if fatal: + payload["error"] = build_doctor_error(results, debug_fields) + return OperationResult(not fatal, payload) + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index e01d82b0..4c82cb40 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -1,898 +1,69 @@ from __future__ import annotations -import argparse -import io -import shlex -import sys -import tempfile -import uuid -from contextlib import ExitStack, redirect_stdout -from dataclasses import asdict, dataclass, is_dataclass -from pathlib import Path -from typing import Callable +from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.checks.doctor import run_doctor_checks -from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli -from timecapsulesmb.cli.deploy import render_flash_runtime_config -from timecapsulesmb.cli.doctor import build_doctor_error -from timecapsulesmb.cli.fsck import ( - FSCK_REBOOT_NO_DOWN_MESSAGE, - FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - build_remote_fsck_script, - select_fsck_target, - _target_from_volume, +from timecapsulesmb.app.operations import ( + OPERATIONS, + AppOperationError, + OperationResult, + activate_operation, + configure_operation, + deploy_operation, + discover_operation, + doctor_operation, + fsck_operation, + paths_operation, + repair_xattrs_operation, + uninstall_operation, + validate_install_operation, ) -from timecapsulesmb.cli.runtime import ( - load_env_config, - load_optional_env_config, - resolve_env_connection, - resolve_validated_managed_target, - ssh_target_link_local_resolution_error, -) -from timecapsulesmb.core.config import ( - DEFAULTS, - MANAGED_PAYLOAD_DIR_NAME, - AppConfig, - airport_family_display_name_from_identity, - parse_bool, - parse_env_file, - write_env_file, -) -from timecapsulesmb.core.errors import system_exit_message -from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP -from timecapsulesmb.core.net import extract_host -from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts -from timecapsulesmb.deploy.artifacts import validate_artifacts -from timecapsulesmb.deploy.auth import render_smbpasswd -from timecapsulesmb.deploy.boot_assets import boot_asset_path -from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable -from timecapsulesmb.deploy.executor import ( - flush_remote_filesystem_writes, - remote_request_reboot, - remote_request_shutdown_reboot, - remote_uninstall_payload, - run_remote_actions, - upload_deployment_payload, -) -from timecapsulesmb.deploy.planner import ( - BINARY_MDNS_SOURCE, - BINARY_NBNS_SOURCE, - BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - GENERATED_FLASH_CONFIG_SOURCE, - GENERATED_SMBPASSWD_SOURCE, - GENERATED_USERNAME_MAP_SOURCE, - PACKAGED_COMMON_SH_SOURCE, - PACKAGED_DFREE_SH_SOURCE, - PACKAGED_RC_LOCAL_SOURCE, - PACKAGED_START_SAMBA_SOURCE, - PACKAGED_WATCHDOG_SOURCE, - build_deployment_plan, - build_netbsd4_activation_plan, - build_uninstall_plan, -) -from timecapsulesmb.deploy.verify import ( - managed_runtime_ready, - render_managed_runtime_verification, - render_post_uninstall_verification, - verify_managed_runtime, - verify_post_uninstall, -) -from timecapsulesmb.device.compat import ( - is_netbsd4_payload_family, - payload_family_description, - render_compatibility_message, - require_compatibility, -) -from timecapsulesmb.device.probe import ( - probe_connection_state, - probe_managed_runtime_conn, - wait_for_ssh_state_conn, -) -from timecapsulesmb.device.storage import ( - MAST_DISCOVERY_ATTEMPTS, - MAST_DISCOVERY_DELAY_SECONDS, - UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, - build_dry_run_payload_home, - mounted_mast_volumes_conn, - read_mast_volumes_conn, - select_payload_home_with_diagnostics_conn, - verify_payload_home_conn, - wait_for_mast_volumes_conn, -) -from timecapsulesmb.discovery.bonjour import ( - DEFAULT_BROWSE_TIMEOUT_SEC, - BonjourDiscoverySnapshot, - BonjourResolvedService, - discover_snapshot, - discovered_record_root_host, - discovery_record_to_jsonable, - service_instance_to_jsonable, -) -from timecapsulesmb.install_validation import ( - install_checks_to_jsonable, - install_ok, - paths_to_jsonable, - validate_install, -) -from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot -from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh - - -REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." -DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) -UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." -) -ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 - - -class AppOperationError(RuntimeError): - def __init__(self, message: str, *, debug: object | None = None) -> None: - super().__init__(message) - self.debug = debug - - -@dataclass(frozen=True) -class OperationResult: - ok: bool - payload: object | None = None - - -def _jsonable(value: object) -> object: - if is_dataclass(value): - return _jsonable(asdict(value)) - if isinstance(value, Path): - return str(value) - if isinstance(value, dict): - return {str(key): _jsonable(item) for key, item in value.items()} - if isinstance(value, (list, tuple, set)): - return [_jsonable(item) for item in value] - return value - - -def _config_path(params: dict[str, object]) -> Path | None: - value = params.get("config") - if value in (None, ""): - return None - return Path(str(value)) - - -def _bool_param(params: dict[str, object], name: str, default: bool = False) -> bool: - value = params.get(name, default) - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y"} - return bool(value) - - -def _int_param(params: dict[str, object], name: str, default: int) -> int: - value = params.get(name, default) - try: - parsed = int(value) - except (TypeError, ValueError) as exc: - raise AppOperationError(f"{name} must be an integer") from exc - if parsed < 0: - raise AppOperationError(f"{name} must be 0 or greater") - return parsed +from timecapsulesmb.core.config import ConfigError +from timecapsulesmb.transport.errors import TransportError -def _string_param(params: dict[str, object], name: str, default: str = "") -> str: - value = params.get(name, default) - return "" if value is None else str(value) +def _request_operation(request: dict[str, object]) -> str: + return str(request.get("operation") or "") -def _require_string_param(params: dict[str, object], name: str) -> str: - value = _string_param(params, name).strip() - if not value: - raise AppOperationError(f"missing required parameter: {name}") - return value - - -def _selected_record_properties(params: dict[str, object]) -> dict[str, str]: - selected = params.get("selected_record") - if not isinstance(selected, dict): +def _request_params(request: dict[str, object]) -> object: + if "params" not in request or request.get("params") is None: return {} - properties = selected.get("properties") - if not isinstance(properties, dict): - return {} - return {str(key): str(value) for key, value in properties.items()} - - -def _selected_record_host(params: dict[str, object]) -> str: - selected = params.get("selected_record") - if not isinstance(selected, dict): - return "" - record = BonjourResolvedService( - name=str(selected.get("name") or ""), - hostname=str(selected.get("hostname") or ""), - service_type=str(selected.get("service_type") or ""), - port=int(selected.get("port") or 0), - ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), - ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), - properties=_selected_record_properties(params), - fullname=str(selected.get("fullname") or ""), - ) - return discovered_record_root_host(record) or "" - - -def _snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: - return { - "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], - "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], - } - - -def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "discover" - timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) - sink.stage(operation, "bonjour_discovery") - snapshot = discover_snapshot(timeout=timeout) - return OperationResult(True, _snapshot_payload(snapshot)) - - -def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "paths" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "summarize_artifacts") - return OperationResult(True, paths_to_jsonable(app_paths)) - - -def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "validate-install" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "validate_install") - checks = validate_install(app_paths) - ok = install_ok(checks) - for check in checks: - sink.check( - operation, - status="PASS" if check.ok else "FAIL", - message=check.message, - details=check.details, - ) - return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) - - -def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "configure" - sink.stage(operation, "load_existing_config") - app_paths = resolve_app_paths(config_path=_config_path(params)) - env_path = app_paths.config_path - existing = parse_env_file(env_path) - configure_id = str(uuid.uuid4()) - ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) - host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") - password = _require_string_param(params, "password") - if not host: - raise AppOperationError("missing required parameter: host") - - resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) - if resolution_error is not None: - raise AppOperationError(resolution_error) - - values = { - "TC_HOST": host, - "TC_PASSWORD": password, - "TC_SSH_OPTS": ssh_opts, - "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _bool_param( - params, - "internal_share_use_disk_root", - parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), - ) else "false", - "TC_ANY_PROTOCOL": "true" if _bool_param( - params, - "any_protocol", - parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), - ) else "false", - "TC_CONFIGURE_ID": configure_id, - } - - sink.stage(operation, "ssh_probe") - connection = SshConnection(host, password, ssh_opts) - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_port_reachable: - if not _bool_param(params, "enable_ssh", True): - raise AppOperationError("SSH is not reachable and enable_ssh is false.") - sink.stage(operation, "acp_enable_ssh") - try: - enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) - except ACPAuthError as exc: - raise AppOperationError("The AirPort admin password did not work.", debug=str(exc)) from exc - except ACPError as exc: - raise AppOperationError(f"Failed to enable SSH via ACP: {exc}") from exc - - sink.stage(operation, "wait_for_ssh_after_acp") - if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): - raise AppOperationError("SSH did not open after enabling via ACP.") - sink.stage(operation, "ssh_probe_after_acp") - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_authenticated: - raise AppOperationError(probe.error or "The provided AirPort SSH target and password did not work.") - - compatibility = probed_state.compatibility - if compatibility is not None and not compatibility.supported: - raise AppOperationError(render_compatibility_message(compatibility)) - - selected_props = _selected_record_properties(params) - observed_syap = None if compatibility is None else compatibility.exact_syap - observed_model = None if compatibility is None else compatibility.exact_model - if observed_syap is None: - observed_syap = selected_props.get("syAP") or None - - sink.stage(operation, "write_env") - env_path.parent.mkdir(parents=True, exist_ok=True) - write_env_file(env_path, values) - return OperationResult(True, { - "config_path": str(env_path), - "host": host, - "configure_id": configure_id, - "ssh_authenticated": True, - "device_syap": observed_syap, - "device_model": observed_model, - "compatibility": _jsonable(compatibility) if compatibility is not None else None, - }) - - -def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: - from timecapsulesmb.cli.flows import wait_for_tcp_port_state - - return wait_for_tcp_port_state( - extract_host(host), - 22, - expected_state=True, - timeout_seconds=timeout_seconds, - verbose=False, - service_name="SSH port", - ) - - -def _require_supported_payload(target, *, allow_unsupported: bool) -> object: - probe_state = target.probe_state - if probe_state is None: - raise AppOperationError("Failed to determine remote device OS compatibility.") - compatibility = require_compatibility( - probe_state.compatibility, - fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) - if not compatibility.supported and not allow_unsupported: - raise AppOperationError(render_compatibility_message(compatibility)) - if not compatibility.payload_family: - raise AppOperationError("No deployable payload is available for this detected device.") - return compatibility - - -def _load_config_and_target( - operation: str, - params: dict[str, object], - sink: EventSink, - *, - profile: str, - include_probe: bool, -) -> tuple[AppConfig, object]: - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_managed_target") - target = resolve_validated_managed_target( - config, - command_name=operation, - profile=profile, - include_probe=include_probe, - ) - return config, target - - -def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "deploy" - nbns_enabled = _bool_param(params, "nbns_enabled", True) - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - yes = _bool_param(params, "yes", True) - mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) - allow_unsupported = _bool_param(params, "allow_unsupported") - debug_logging = _bool_param(params, "debug_logging") - - config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) - connection = target.connection - app_paths = resolve_app_paths(config_path=_config_path(params)) - - sink.stage(operation, "validate_artifacts") - failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] - if failures: - raise AppOperationError("; ".join(failures)) - - sink.stage(operation, "check_compatibility") - compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) - payload_family = compatibility.payload_family - is_netbsd4 = is_netbsd4_payload_family(payload_family) - sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") - resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) - - if dry_run: - payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) - else: - sink.stage(operation, "read_mast") - mast_discovery = wait_for_mast_volumes_conn( - connection, - attempts=MAST_DISCOVERY_ATTEMPTS, - delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, - ) - if not mast_discovery.volumes: - raise AppOperationError( - f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " - f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart." - ) - sink.stage(operation, "select_payload_home") - selection = select_payload_home_with_diagnostics_conn( - connection, - mast_discovery.volumes, - MANAGED_PAYLOAD_DIR_NAME, - wait_seconds=mount_wait, - ) - if selection.payload_home is None: - raise AppOperationError(f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.") - payload_home = selection.payload_home - - sink.stage(operation, "build_deployment_plan") - plan = build_deployment_plan( - connection.host, - payload_home, - resolved_artifacts["smbd"].absolute_path, - resolved_artifacts["mdns-advertiser"].absolute_path, - resolved_artifacts["nbns-advertiser"].absolute_path, - activate_netbsd4=is_netbsd4, - reboot_after_deploy=not no_reboot, - apple_mount_wait_seconds=mount_wait, - ) - if dry_run: - return OperationResult(True, deployment_plan_to_jsonable(plan)) - - if is_netbsd4 and not yes: - raise AppOperationError("NetBSD 4 deploy requires explicit confirmation.") - - sink.stage(operation, "pre_upload_actions") - run_remote_actions(connection, plan.pre_upload_actions) - sink.stage(operation, "prepare_deployment_files") - flash_config_text = render_flash_runtime_config( - config, - payload_home, - nbns_enabled=nbns_enabled, - debug_logging=debug_logging, - ) - with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: - tmpdir = Path(tmp) - generated_flash_config = tmpdir / "tcapsulesmb.conf" - generated_smbpasswd = tmpdir / "smbpasswd" - generated_username_map = tmpdir / "username.map" - generated_flash_config.write_text(flash_config_text) - smbpasswd_text, username_map_text = render_smbpasswd(connection.password) - generated_smbpasswd.write_text(smbpasswd_text) - generated_username_map.write_text(username_map_text) - upload_sources = { - BINARY_SMBD_SOURCE: plan.smbd_path, - BINARY_MDNS_SOURCE: plan.mdns_path, - BINARY_NBNS_SOURCE: plan.nbns_path, - GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, - GENERATED_USERNAME_MAP_SOURCE: generated_username_map, - GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, - PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), - PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), - PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), - PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), - PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), - } - sink.stage(operation, "upload_payload") - upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) - - sink.stage(operation, "post_upload_actions") - run_remote_actions(connection, plan.post_upload_actions) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) - sink.stage(operation, "flush_payload_upload") - sink.log(operation, "Flushing deployed payload to disk...") - flush_remote_filesystem_writes(connection) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) - - if is_netbsd4: - sink.stage(operation, "netbsd4_activation") - run_remote_actions(connection, plan.activation_actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, { - "payload_dir": plan.payload_dir, - "netbsd4": True, - "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", - }) - - if no_reboot: - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) - if not yes: - device_name = airport_family_display_name_from_identity( - model=target.probe_state.probe_result.airport_model if target.probe_state else None, - syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, - ) - raise AppOperationError(f"Deploy requires confirmation to reboot the {device_name}.") - - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="ssh_shutdown_then_reboot", - reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, - ) - _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) - - -def _verify_payload_upload( - operation: str, - sink: EventSink, - connection: SshConnection, - payload_home, - *, - wait_seconds: int, - post_sync: bool = False, -) -> None: - sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") - verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) - sink.log(operation, verification.detail) - if not verification.ok: - raise AppOperationError(f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}") - - -def _verify_runtime( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - stage: str, - timeout_seconds: int, -) -> None: - sink.stage(operation, stage) - verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) - for line in render_managed_runtime_verification( - verification, - heading="Waiting for managed runtime to finish starting...", - ): - sink.log(operation, line) - if not managed_runtime_ready(verification): - raise AppOperationError(f"Managed runtime did not become ready. {verification.detail.strip()}".strip()) - - -def _request_reboot_and_wait( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - strategy: str, - reboot_no_down_message: str, - down_timeout_seconds: int = 60, - up_timeout_seconds: int = 240, -) -> None: - sink.stage(operation, "reboot") - if strategy == "acp_then_ssh": - try: - acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) - sink.log(operation, "ACP reboot requested.") - except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") - _request_ssh_reboot(operation, sink, connection, shutdown=False) - else: - _request_ssh_reboot(operation, sink, connection, shutdown=True) - - sink.stage(operation, "wait_for_reboot_down") - sink.log(operation, "Waiting for the device to go down...") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message) - sink.stage(operation, "wait_for_reboot_up") - sink.log(operation, "Waiting for the device to come back up...") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) - sink.log(operation, "Device is back online.") - - -def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: - try: - if shutdown: - remote_request_shutdown_reboot(connection) - else: - remote_request_reboot(connection) - except SshCommandTimeout as exc: - sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") - return - except SshError as exc: - sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") - return - sink.log(operation, "SSH reboot requested.") - - -def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "activate" - yes = _bool_param(params, "yes", True) - dry_run = _bool_param(params, "dry_run") - _, target = _load_config_and_target(operation, params, sink, profile="activate", include_probe=True) - compatibility = _require_supported_payload(target, allow_unsupported=False) - if not is_netbsd4_payload_family(compatibility.payload_family): - raise AppOperationError("activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.") - sink.stage(operation, "build_activation_plan") - plan = build_netbsd4_activation_plan() - if dry_run: - return OperationResult(True, _jsonable(plan)) - if not yes: - raise AppOperationError("NetBSD4 activation requires explicit confirmation.") - connection = target.connection - sink.stage(operation, "probe_runtime") - if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: - return OperationResult(True, {"already_active": True}) - sink.stage(operation, "run_activation") - run_remote_actions(connection, plan.actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) - - -def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "uninstall" - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - yes = _bool_param(params, "yes", True) - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - if dry_run: - volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] - payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] - else: - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_mast_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - volume_roots = [volume.volume_root for volume in mounted_volumes] - payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] - sink.stage(operation, "build_uninstall_plan") - plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) - if dry_run: - return OperationResult(True, uninstall_plan_to_jsonable(plan)) - sink.stage(operation, "uninstall_payload") - remote_uninstall_payload(connection, plan) - if no_reboot: - return OperationResult(True, {"rebooted": False, "verified": False}) - if not yes: - raise AppOperationError("Uninstall requires explicit confirmation to reboot.") - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="acp_then_ssh", - reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, - ) - sink.stage(operation, "verify_post_uninstall") - verification = verify_post_uninstall(connection, plan) - for line in render_post_uninstall_verification(verification): - sink.log(operation, line) - if not verification: - raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.") - return OperationResult(True, {"rebooted": True, "verified": True}) - - -def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "fsck" - yes = _bool_param(params, "yes", True) - no_reboot = _bool_param(params, "no_reboot") - no_wait = _bool_param(params, "no_wait") - if not yes: - raise AppOperationError("fsck requires explicit confirmation.") - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_hfs_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - sink.stage(operation, "select_fsck_volume") - try: - target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), - _string_param(params, "volume") or None, - prompt=False, - ) - except RuntimeError as exc: - raise AppOperationError(str(exc)) from exc - sink.stage(operation, "run_fsck") - script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) - proc = run_ssh( - connection, - f"/bin/sh -c {shlex.quote(script)}", - check=False, - timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - ) - if proc.stdout: - for line in proc.stdout.splitlines(): - sink.log(operation, line) - if no_reboot: - return OperationResult(proc.returncode == 0, { - "device": target.device, - "mountpoint": target.mountpoint, - "returncode": proc.returncode, - }) - if no_wait: - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) - _observe_reboot_cycle( - operation, - sink, - connection, - reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, - down_timeout_seconds=90, - up_timeout_seconds=420, - ) - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) - - -def _observe_reboot_cycle( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - reboot_no_down_message: str, - down_timeout_seconds: int, - up_timeout_seconds: int, -) -> None: - sink.stage(operation, "wait_for_reboot_down") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message) - sink.stage(operation, "wait_for_reboot_up") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) - - -class _RepairContext: - def __init__(self, operation: str, sink: EventSink) -> None: - self.operation = operation - self.sink = sink - self.result = "failure" - self.error: str | None = None - - def set_stage(self, stage: str) -> None: - self.sink.stage(self.operation, stage) - - def update_fields(self, **_fields: object) -> None: - pass - - def succeed(self) -> None: - self.result = "success" - - def fail_with_error(self, message: str) -> None: - self.result = "failure" - self.error = message - - -def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "repair-xattrs" - dry_run = _bool_param(params, "dry_run") - yes = _bool_param(params, "yes") - if not dry_run and not yes: - raise AppOperationError("repair-xattrs requires dry_run or explicit confirmation.") - if sys.platform != "darwin": - raise AppOperationError("repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.") - config = load_optional_env_config(env_path=_config_path(params)) - args = argparse.Namespace( - path=Path(str(params["path"])) if params.get("path") else None, - dry_run=dry_run, - yes=yes, - recursive=_bool_param(params, "recursive", True), - max_depth=params.get("max_depth"), - include_hidden=_bool_param(params, "include_hidden"), - include_time_machine=_bool_param(params, "include_time_machine"), - fix_permissions=_bool_param(params, "fix_permissions"), - verbose=_bool_param(params, "verbose"), - ) - if args.max_depth is not None: - args.max_depth = int(args.max_depth) - context = _RepairContext(operation, sink) - output = io.StringIO() - with redirect_stdout(output): - try: - rc = repair_xattrs_cli.run_repair(args, context, config) - except SystemExit as exc: - message = system_exit_message(exc) or "repair-xattrs failed" - raise AppOperationError(message) from exc - for line in output.getvalue().splitlines(): - sink.log(operation, line) - return OperationResult(rc == 0, {"returncode": rc, "telemetry_result": context.result, "error": context.error}) - - -def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "doctor" - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - app_paths = resolve_app_paths(config_path=_config_path(params)) - connection = None - if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - debug_fields: dict[str, object] = {} - - def on_result(result: CheckResult) -> None: - sink.check(operation, status=result.status, message=result.message, details=result.details) - - sink.stage(operation, "run_checks") - results, fatal = run_doctor_checks( - config, - repo_root=app_paths.distribution_root, - connection=connection, - skip_ssh=_bool_param(params, "skip_ssh"), - skip_bonjour=_bool_param(params, "skip_bonjour"), - skip_smb=_bool_param(params, "skip_smb"), - on_result=on_result, - debug_fields=debug_fields, - ) - payload = { - "fatal": fatal, - "results": [_jsonable(result) for result in results], - "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", - } - if fatal: - payload["error"] = build_doctor_error(results, debug_fields) - return OperationResult(not fatal, payload) - - -OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { - "activate": activate_operation, - "configure": configure_operation, - "deploy": deploy_operation, - "discover": discover_operation, - "doctor": doctor_operation, - "fsck": fsck_operation, - "paths": paths_operation, - "repair-xattrs": repair_xattrs_operation, - "uninstall": uninstall_operation, - "validate-install": validate_install_operation, -} + return request.get("params") def run_api_request(request: dict[str, object], sink: EventSink) -> int: - operation = str(request.get("operation") or "") - params = request.get("params") or {} + request_id = request.get("request_id") + if request_id is not None and str(request_id).strip(): + sink = sink.with_request_id(str(request_id)) + + operation = _request_operation(request) + params = _request_params(request) if not operation: - sink.error("api", "missing required field: operation") + sink.error("api", "missing required field: operation", code="invalid_request") return 1 if not isinstance(params, dict): - sink.error(operation, "params must be a JSON object") + sink.error(operation, "params must be a JSON object", code="invalid_request") return 1 - handler = OPERATIONS.get(operation) + handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) if handler is None: - sink.error(operation, f"unknown operation: {operation}", debug={"known_operations": sorted(OPERATIONS)}) + sink.error(operation, f"unknown operation: {operation}", code="unknown_operation", debug={"known_operations": sorted(OPERATIONS)}) return 1 try: result = handler(params, sink) except AppOperationError as exc: - sink.error(operation, str(exc), debug=redact(exc.debug) if exc.debug is not None else None) + sink.error(operation, str(exc), code=exc.code, debug=redact(exc.debug) if exc.debug is not None else None) + return 1 + except ConfigError as exc: + sink.error(operation, str(exc), code="config_error") + return 1 + except TransportError as exc: + sink.error(operation, str(exc), code="remote_error") return 1 except (SystemExit, KeyboardInterrupt): raise except Exception as exc: - sink.error(operation, f"{type(exc).__name__}: {exc}") + sink.error(operation, f"{type(exc).__name__}: {exc}", code="operation_failed") return 1 sink.result(operation, ok=result.ok, payload=result.payload) return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index bb00930a..2b60abd5 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -2,8 +2,9 @@ import argparse import sys +from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Callable, Optional from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.runtime import add_config_argument, confirm as confirm_prompt, load_optional_env_config @@ -44,15 +45,33 @@ from timecapsulesmb.telemetry import TelemetryClient -def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + + +def render_candidate_lines(candidates: list[RepairCandidate], *, dry_run: bool) -> list[str]: verb = "Would repair" if dry_run else "Repairable" + lines: list[str] = [] for candidate in candidates: actions = ", ".join(candidate.actions) or "none" flags = f", flags: {candidate.flags}" if candidate.flags else "" - print(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + lines.append(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + return lines -def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: +def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: + for line in render_candidate_lines(candidates, dry_run=dry_run): + print(line) + + +def render_diagnostic_lines(findings: list[RepairFinding], *, verbose: bool) -> list[str]: + lines: list[str] = [] for finding in findings: if finding.repairable: continue @@ -62,30 +81,61 @@ def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: detail += f" flags={finding.flags}" if finding.xattr_error: detail += f" xattr_error={finding.xattr_error}" - print(f"WARN {detail}") + lines.append(f"WARN {detail}") + return lines -def print_summary(summary: RepairSummary, *, dry_run: bool) -> None: - print("") - print("Summary:") - print(f" scanned paths: {summary.scanned}") - print(f" scanned files: {summary.scanned_files}") - print(f" scanned directories: {summary.scanned_dirs}") - print(f" skipped: {summary.skipped}") - print(f" unreadable xattrs: {summary.unreadable}") - print(f" not repairable: {summary.not_repairable}") - print(f" repairable: {summary.repairable}") - print(f" permission repairs: {summary.permission_repairable}") +def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: + for line in render_diagnostic_lines(findings, verbose=verbose): + print(line) + + +def render_summary_lines(summary: RepairSummary, *, dry_run: bool) -> list[str]: + lines = [ + "", + "Summary:", + f" scanned paths: {summary.scanned}", + f" scanned files: {summary.scanned_files}", + f" scanned directories: {summary.scanned_dirs}", + f" skipped: {summary.skipped}", + f" unreadable xattrs: {summary.unreadable}", + f" not repairable: {summary.not_repairable}", + f" repairable: {summary.repairable}", + f" permission repairs: {summary.permission_repairable}", + ] if not dry_run: - print(f" repaired: {summary.repaired}") - print(f" failed: {summary.failed}") + lines.extend([ + f" repaired: {summary.repaired}", + f" failed: {summary.failed}", + ]) + return lines + + +def print_summary(summary: RepairSummary, *, dry_run: bool) -> None: + for line in render_summary_lines(summary, dry_run=dry_run): + print(line) def confirm(prompt_text: str) -> bool: return confirm_prompt(prompt_text, default=False, eof_default=False, interrupt_default=False) -def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: +def _emit_lines(emit: Callable[[str], None], lines: list[str]) -> None: + for line in lines: + emit(line) + + +def run_repair_structured( + args: argparse.Namespace, + command_context: CommandContext, + config: AppConfig, + *, + emit_log: Callable[[str], None] | None = None, +) -> RepairRunResult: + def emit(message: str) -> None: + if emit_log is not None: + emit_log(message) + command_context.set_stage("resolve_scan_root") command_context.update_fields( dry_run=args.dry_run, @@ -117,7 +167,7 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config summary = RepairSummary() command_context.update_fields(repair_root=str(root)) command_context.set_stage("scan_findings") - print(f"Scanning {root}") + emit(f"Scanning {root}") try: findings = find_findings( root, @@ -146,61 +196,69 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config ) if not findings: - print("No repairable files found.") - print_summary(summary, dry_run=True) + emit("No repairable files found.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) command_context.succeed() - return 0 + return RepairRunResult(0, root, findings, candidates, summary) command_context.set_stage("report_findings") - print_diagnostics(findings, verbose=args.verbose) + _emit_lines(emit, render_diagnostic_lines(findings, verbose=args.verbose)) if candidates: - print_candidates(candidates, dry_run=args.dry_run) + _emit_lines(emit, render_candidate_lines(candidates, dry_run=args.dry_run)) if args.dry_run: - print_summary(summary, dry_run=True) - print("No changes made.") - command_context.fail_with_error(build_repair_report(findings)) - return 0 + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) if not candidates: - print("No known-safe repairs are available for the detected issues.") - print_summary(summary, dry_run=True) - command_context.fail_with_error(build_repair_report(findings)) - return 1 + emit("No known-safe repairs are available for the detected issues.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) command_context.set_stage("confirm_repair") if not args.yes and not confirm(f"Repair {len(candidates)} paths with known-safe fixes?"): - print("No changes made.") - print_summary(summary, dry_run=True) - command_context.fail_with_error(build_repair_report(findings)) - return 0 + emit("No changes made.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) command_context.set_stage("repair_findings") failed_findings: list[RepairFinding] = [] for finding, candidate in zip(repairs, candidates): - print(f"Repairing: {candidate.path}") + emit(f"Repairing: {candidate.path}") if repair_candidate(candidate): summary.repaired += 1 if ACTION_CLEAR_ARCH_FLAG in candidate.actions: - print(f"PASS xattr now readable: {candidate.path}") + emit(f"PASS xattr now readable: {candidate.path}") if ACTION_FIX_PERMISSIONS in candidate.actions: - print(f"PASS permissions repaired: {candidate.path}") + emit(f"PASS permissions repaired: {candidate.path}") else: summary.failed += 1 failed_findings.append(finding) if ACTION_CLEAR_ARCH_FLAG in candidate.actions: - print(f"FAIL repair did not make xattr readable: {candidate.path}") + emit(f"FAIL repair did not make xattr readable: {candidate.path}") else: - print(f"FAIL repair did not fix detected issue: {candidate.path}") + emit(f"FAIL repair did not fix detected issue: {candidate.path}") unresolved = unresolved_findings_after_success(findings) + failed_findings command_context.update_fields(repaired_count=summary.repaired, repair_failed_count=summary.failed) - print_summary(summary, dry_run=False) + _emit_lines(emit, render_summary_lines(summary, dry_run=False)) if unresolved: - command_context.fail_with_error(build_repair_report(findings, failed=unresolved)) - return 1 + report = build_repair_report(findings, failed=unresolved) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) command_context.succeed() - return 0 + return RepairRunResult(0, root, findings, candidates, summary) + + +def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: + return run_repair_structured(args, command_context, config, emit_log=print).returncode def main(argv: Optional[list[str]] = None) -> int: diff --git a/src/timecapsulesmb/services/__init__.py b/src/timecapsulesmb/services/__init__.py new file mode 100644 index 00000000..da30ee94 --- /dev/null +++ b/src/timecapsulesmb/services/__init__.py @@ -0,0 +1,2 @@ +"""Non-interactive service helpers shared by CLI and app adapters.""" + diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py new file mode 100644 index 00000000..1468a5ec --- /dev/null +++ b/src/timecapsulesmb/services/app.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from pathlib import Path + + +class AppOperationError(RuntimeError): + def __init__( + self, + message: str, + *, + code: str = "operation_failed", + debug: object | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.debug = debug + + +@dataclass(frozen=True) +class OperationResult: + ok: bool + payload: object | None = None + + +def jsonable(value: object) -> object: + if is_dataclass(value): + return jsonable(asdict(value)) + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [jsonable(item) for item in value] + return value + + +def config_path(params: dict[str, object]) -> Path | None: + value = params.get("config") + if value in (None, ""): + return None + return Path(str(value)) + + +def bool_param(params: dict[str, object], name: str, default: bool = False) -> bool: + value = params.get(name, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y"} + return bool(value) + + +def confirm_param(params: dict[str, object], name: str) -> bool: + if name in params: + return bool_param(params, name) + return bool_param(params, "yes") + + +def int_param(params: dict[str, object], name: str, default: int) -> int: + value = params.get(name, default) + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") + return parsed + + +def string_param(params: dict[str, object], name: str, default: str = "") -> str: + value = params.get(name, default) + return "" if value is None else str(value) + + +def require_string_param(params: dict[str, object], name: str) -> str: + value = string_param(params, name).strip() + if not value: + raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") + return value diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 975d2de1..c8db3d84 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -17,13 +17,16 @@ sys.path.insert(0, str(SRC_ROOT)) from timecapsulesmb.app.events import AppEvent, EventSink -from timecapsulesmb.app import helper, service +from timecapsulesmb import repair_xattrs as repair_xattrs_domain +from timecapsulesmb.app import helper, operations, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.core.config import AppConfig, ConfigError from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance +from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.transport.errors import TransportError from timecapsulesmb.transport.ssh import SshConnection @@ -51,6 +54,21 @@ def supported_compatibility(payload_family: str = "netbsd6_samba4") -> DeviceCom ) +def unsupported_compatibility() -> DeviceCompatibility: + return DeviceCompatibility( + os_name="NetBSD", + os_release="3.0", + arch="i386", + elf_endianness="little", + payload_family=None, + device_generation=None, + supported=False, + reason_code="unsupported_os", + syap_candidates=(), + model_candidates=(), + ) + + def probed_state() -> ProbedDeviceState: return ProbedDeviceState( probe_result=ProbeResult( @@ -68,7 +86,44 @@ def probed_state() -> ProbedDeviceState: ) +def netbsd4_probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="4.0", + arch="powerpc", + elf_endianness="big", + airport_model="TimeCapsule6,116", + airport_syap="116", + ), + compatibility=supported_compatibility("netbsd4be_samba4"), + ) + + +def unreachable_probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=False, + ssh_authenticated=False, + error="connection refused", + os_name="", + os_release="", + arch="", + elf_endianness="", + ), + compatibility=None, + ) + + class AppApiTests(unittest.TestCase): + def assert_single_terminal_event(self, collector: CollectingSink, event_type: str) -> dict[str, object]: + terminals = collector.events_of_type("result") + collector.events_of_type("error") + self.assertEqual([event["type"] for event in terminals], [event_type]) + return terminals[0] + def test_event_redacts_password_fields(self) -> None: event = AppEvent("result", "configure", { "ok": True, @@ -90,6 +145,37 @@ def test_result_event_preserves_falsey_payloads(self) -> None: result = collector.events_of_type("result")[0] self.assertEqual(result["payload"], []) + self.assertEqual(result["schema_version"], 1) + self.assertTrue(result["request_id"]) + + def test_request_id_propagates_to_every_event(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"request_id": "req-123", "operation": "paths", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + self.assertTrue(collector.events) + self.assertEqual({event["request_id"] for event in collector.events}, {"req-123"}) + self.assert_single_terminal_event(collector, "result") + + def test_missing_params_defaults_to_empty_object(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "paths"}, collector.sink) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["operation"], "paths") + + def test_missing_operation_emits_invalid_request_error(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["operation"], "api") + self.assertEqual(error["code"], "invalid_request") def test_unknown_operation_emits_error_without_result(self) -> None: collector = CollectingSink() @@ -97,8 +183,37 @@ def test_unknown_operation_emits_error_without_result(self) -> None: rc = service.run_api_request({"operation": "nope", "params": {}}, collector.sink) self.assertEqual(rc, 1) - self.assertEqual(len(collector.events_of_type("error")), 1) - self.assertEqual(collector.events_of_type("result"), []) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "unknown_operation") + + def test_non_object_params_emits_invalid_request_error(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "paths", "params": []}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "invalid_request") + + def test_dispatcher_maps_recoverable_and_unexpected_error_states(self) -> None: + cases = ( + ("config-error", ConfigError("bad config"), "config_error"), + ("transport-error", TransportError("remote failed"), "remote_error"), + ("unexpected-error", RuntimeError("boom"), "operation_failed"), + ) + for operation, exception, code in cases: + with self.subTest(code=code): + collector = CollectingSink() + + def fail(_params, _sink, exc=exception): + raise exc + + with mock.patch.dict(service.OPERATIONS, {operation: fail}): + rc = service.run_api_request({"operation": operation, "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], code) def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() @@ -116,7 +231,7 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: ], ) - with mock.patch("timecapsulesmb.app.service.discover_snapshot", return_value=snapshot): + with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot): rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) self.assertEqual(rc, 0) @@ -128,7 +243,7 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.service.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -147,6 +262,54 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.operations.enable_ssh", side_effect=ACPAuthError("bad password")): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "badpw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertFalse(config_path.exists()) + self.assertEqual(collector.events_of_type("error")[0]["code"], "auth_failed") + self.assertNotIn("badpw", json.dumps(collector.events)) + + def test_configure_reports_unsupported_device(self) -> None: + collector = CollectingSink() + unsupported_state = ProbedDeviceState( + probe_result=probed_state().probe_result, + compatibility=unsupported_compatibility(), + ) + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unsupported_state): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "pw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertFalse(config_path.exists()) + self.assertEqual(collector.events_of_type("error")[0]["code"], "unsupported_device") + def test_doctor_streams_check_events(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -155,10 +318,10 @@ def fake_run_doctor_checks(*_args, **kwargs): kwargs["on_result"](CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})) return [CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})], False - with mock.patch("timecapsulesmb.app.service.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.service.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.service.run_doctor_checks", side_effect=fake_run_doctor_checks): + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) self.assertEqual(rc, 0) @@ -167,6 +330,27 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertEqual(checks[0]["status"], "PASS") self.assertEqual(checks[0]["details"], {"port": 445}) + def test_doctor_fatal_returns_nonzero_result_without_error_event(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + def fake_run_doctor_checks(*_args, **kwargs): + kwargs["on_result"](CheckResult("FAIL", "SMB is not reachable", {"password": "pw"})) + return [CheckResult("FAIL", "SMB is not reachable", {"password": "pw"})], True + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error"), []) + result = collector.events_of_type("result")[0] + self.assertEqual(result["ok"], False) + self.assertTrue(result["payload"]["fatal"]) + self.assertNotIn("pw", json.dumps(collector.events)) + def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -177,12 +361,12 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.service.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.service.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.service.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.service.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.service.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, collector.sink, @@ -193,6 +377,283 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> self.assertEqual(result["payload"]["host"], "root@10.0.0.2") self.assertEqual(result["payload"]["reboot_required"], True) + def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + remote_actions.assert_not_called() + + def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4-netbsd4be/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns-netbsd4be/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + read_mast.assert_not_called() + remote_actions.assert_not_called() + + def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + load_config.assert_not_called() + + def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): + with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "no_reboot": True, + "confirm_deploy": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + wait.assert_not_called() + self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) + + def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "remote_error") + + def test_activate_requires_explicit_confirmation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace( + connection=connection, + probe_state=ProbedDeviceState( + probe_result=probed_state().probe_result, + compatibility=supported_compatibility("netbsd4le_samba4"), + ), + ) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + remote_actions.assert_not_called() + + def test_activate_accepts_yes_alias_for_confirmation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "activate", "params": {"yes": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["payload"], {"already_active": True}) + remote_actions.assert_not_called() + + def test_uninstall_requires_confirmation_before_remote_removal(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: + with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + rc = service.run_api_request({"operation": "uninstall", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_connection.assert_not_called() + uninstall.assert_not_called() + + def test_uninstall_requires_reboot_confirmation_before_remote_connection(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"confirm_uninstall": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + load_config.assert_not_called() + + def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"dry_run": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertIn("remote_actions", result["payload"]) + uninstall.assert_not_called() + + def test_fsck_requires_confirmation_before_remote_connection(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_connection.assert_not_called() + + def test_repair_xattrs_uses_structured_runner(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1, scanned_files=1, unreadable=1, repairable=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[SimpleNamespace(path=Path("/Volumes/Data/broken"))], + candidates=[SimpleNamespace(path=Path("/Volumes/Data/broken"))], + summary=summary, + report="detected issues", + ) + + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + runner.assert_called_once() + self.assertEqual(collector.events_of_type("result")[0]["payload"]["finding_count"], 1) + + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + runner.assert_not_called() + def test_helper_reads_request_and_writes_ndjson(self) -> None: output = io.StringIO() fake_stdin = io.StringIO('{"operation":"paths","params":{}}') @@ -206,6 +667,36 @@ def test_helper_reads_request_and_writes_ndjson(self) -> None: line = json.loads(output.getvalue()) self.assertEqual(line["type"], "result") self.assertEqual(line["operation"], "paths") + self.assertEqual(line["schema_version"], 1) + self.assertTrue(line["request_id"]) + + def test_helper_rejects_invalid_json_without_leaking_pretty_error_details(self) -> None: + output = io.StringIO() + error_output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO('{"operation":"paths","password":"secret"')): + with redirect_stdout(output): + with mock.patch.object(sys, "stderr", error_output): + rc = helper.main(["--pretty-error"]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["code"], "invalid_request") + self.assertNotIn("secret", error_output.getvalue()) + + def test_helper_rejects_top_level_non_object_json(self) -> None: + output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO('["paths"]')): + with redirect_stdout(output): + rc = helper.main([]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["operation"], "api") + self.assertEqual(event["code"], "invalid_request") + self.assertEqual(event["schema_version"], 1) + self.assertTrue(event["request_id"]) def test_api_command_is_registered(self) -> None: self.assertIs(cli_main.COMMANDS["api"], helper.main) From 308b78bce6c40aa268d5f1788d5bed0f367b34c5 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 22:05:07 -0700 Subject: [PATCH 03/13] Improve app API diagnostics, env preservation, and helper robustness --- .../HelperRunnerTests.swift | 27 ++++++ src/timecapsulesmb/app/operations.py | 51 ++++++++++-- src/timecapsulesmb/app/service.py | 8 +- src/timecapsulesmb/core/config.py | 23 +++++ tests/test_app_api.py | 83 ++++++++++++++++++- tests/test_config.py | 36 ++++++++ 6 files changed, 217 insertions(+), 11 deletions(-) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index 31ca3583..3495cbf8 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -50,6 +50,33 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(events.last?.debug, .object(["stderr": .string("stderr detail\n")])) } + func testRunnerDrainsLargeStderrWhileHelperIsRunning() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + i=0 + while [ "$i" -lt 2000 ]; do + printf '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\\n' >&2 + i=$((i + 1)) + done + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"doctor","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + recorder.append($0) + } + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr.count, 64 * 1024) + XCTAssertEqual(recorder.events.last?.type, "result") + XCTAssertEqual(recorder.events.last?.ok, true) + } + func testRunnerReportsMissingHelper() async { let locator = HelperLocator(environment: [:], currentDirectory: URL(fileURLWithPath: NSTemporaryDirectory()), bundle: .main, fileManager: .default) let runner = HelperRunner(locator: locator) diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py index 4d658550..bf6b854b 100644 --- a/src/timecapsulesmb/app/operations.py +++ b/src/timecapsulesmb/app/operations.py @@ -6,7 +6,7 @@ import tempfile import uuid from collections.abc import Callable -from contextlib import ExitStack +from contextlib import ExitStack, redirect_stderr, redirect_stdout from pathlib import Path from timecapsulesmb.app.events import EventSink @@ -36,6 +36,7 @@ airport_family_display_name_from_identity, parse_bool, parse_env_file, + preserved_env_file_values, write_env_file, ) from timecapsulesmb.core.errors import system_exit_message @@ -227,7 +228,8 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation if resolution_error is not None: raise AppOperationError(resolution_error, code="config_error") - values = { + values = preserved_env_file_values(existing) + values.update({ "TC_HOST": host, "TC_PASSWORD": password, "TC_SSH_OPTS": ssh_opts, @@ -242,7 +244,7 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), ) else "false", "TC_CONFIGURE_ID": configure_id, - } + }) sink.stage(operation, "ssh_probe") connection = SshConnection(host, password, ssh_opts) @@ -763,6 +765,31 @@ def fail_with_error(self, message: str) -> None: self.error = message +class _StreamLogCapture: + def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: + self.operation = operation + self.sink = sink + self.level = level + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self.sink.log(self.operation, message, level=self.level) + + def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" dry_run = _bool_param(params, "dry_run") @@ -792,16 +819,22 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera if args.max_depth is not None: args.max_depth = int(args.max_depth) context = _RepairContext(operation, sink) + stdout_capture = _StreamLogCapture(operation, sink, level="info") + stderr_capture = _StreamLogCapture(operation, sink, level="warning") try: - result = repair_xattrs_cli.run_repair_structured( - args, - context, - config, - emit_log=lambda message: sink.log(operation, message), - ) + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = repair_xattrs_cli.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) except SystemExit as exc: message = system_exit_message(exc) or "repair-xattrs failed" raise AppOperationError(message, code="operation_failed") from exc + finally: + stdout_capture.flush() + stderr_capture.flush() return OperationResult(result.returncode == 0, { "returncode": result.returncode, "root": str(result.root), diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index 4c82cb40..d434ab14 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import traceback from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact @@ -63,7 +64,12 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: except (SystemExit, KeyboardInterrupt): raise except Exception as exc: - sink.error(operation, f"{type(exc).__name__}: {exc}", code="operation_failed") + sink.error( + operation, + f"{type(exc).__name__}: {exc}", + code="operation_failed", + debug={"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))}, + ) return 1 sink.result(operation, ok=result.ok, payload=result.payload) return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 1d23bda3..8f3d612b 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -85,6 +85,19 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_ANY_PROTOCOL", "TC_CONFIGURE_ID", ] +ENV_FILE_OMIT_KEYS = frozenset({ + # Runtime-derived/deprecated naming keys may still exist in older .env + # files, but new configure writes should not keep them alive. + "TC_AIRPORT_SYAP", + "TC_MDNS_DEVICE_MODEL", + "TC_MDNS_HOST_LABEL", + "TC_MDNS_INSTANCE_NAME", + "TC_NETBIOS_NAME", + "TC_NET_IFACE", + "NET_IPV4_HINT", + "TC_SAMBA_USER", + "TC_PAYLOAD_DIR_NAME", +}) CONFIG_HEADER = """# Local user/device configuration for TimeCapsuleSMB. # Generated by tcapsule configure @@ -640,9 +653,19 @@ def render_env_text(values: dict[str, str]) -> str: for key in ENV_FILE_KEYS: rendered_value = values.get(key, DEFAULTS.get(key, "")) lines.append(f"{key}={shell_quote(rendered_value)}") + extra_keys = sorted(key for key in values if key not in ENV_FILE_KEYS and key not in ENV_FILE_OMIT_KEYS) + if extra_keys: + lines.append("") + lines.append("# Preserved custom settings") + for key in extra_keys: + lines.append(f"{key}={shell_quote(values[key])}") lines.append("") return "\n".join(lines) +def preserved_env_file_values(values: dict[str, str]) -> dict[str, str]: + return {key: value for key, value in values.items() if key not in ENV_FILE_OMIT_KEYS} + + def write_env_file(path: Path, values: dict[str, str]) -> None: path.write_text(render_env_text(values)) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index c8db3d84..5c754e10 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -21,7 +21,7 @@ from timecapsulesmb.app import helper, operations, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.core.config import AppConfig, ConfigError +from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance @@ -215,6 +215,21 @@ def fail(_params, _sink, exc=exception): error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], code) + def test_dispatcher_includes_traceback_for_unexpected_errors(self) -> None: + collector = CollectingSink() + + def fail(_params, _sink): + raise RuntimeError("boom") + + with mock.patch.dict(service.OPERATIONS, {"boom": fail}): + rc = service.run_api_request({"operation": "boom", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "operation_failed") + self.assertIn("Traceback", error["debug"]["traceback"]) + self.assertIn("RuntimeError: boom", error["debug"]["traceback"]) + def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() snapshot = BonjourDiscoverySnapshot( @@ -262,6 +277,39 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) + def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + config_path.write_text( + "TC_HOST=root@10.0.0.1\n" + "TC_PASSWORD=oldpw\n" + "TC_CUSTOM_SETTING='keep me'\n" + "TC_SAMBA_USER=old-admin\n" + "TC_PAYLOAD_DIR_NAME=old-payload\n" + ) + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "newpw", + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_HOST"], "root@10.0.0.2") + self.assertEqual(values["TC_PASSWORD"], "newpw") + self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") + self.assertNotIn("TC_SAMBA_USER", values) + self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: @@ -637,6 +685,39 @@ def test_repair_xattrs_uses_structured_runner(self) -> None: runner.assert_called_once() self.assertEqual(collector.events_of_type("result")[0]["payload"]["finding_count"], 1) + def test_repair_xattrs_captures_direct_stdout_and_stderr_logs(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + def fake_runner(*_args, **_kwargs): + print("stdout detail") + print("stderr detail", file=sys.stderr) + return repair_result + + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", side_effect=fake_runner): + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + logs = collector.events_of_type("log") + self.assertEqual(rc, 0) + self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) + self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() diff --git a/tests/test_config.py b/tests/test_config.py index 1a20280d..74c6c9e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,6 +25,7 @@ parse_bool, parse_env_file, parse_env_value, + preserved_env_file_values, require_valid_app_config, render_env_text, validate_app_config, @@ -148,6 +149,41 @@ def test_render_env_text_contains_config_keys(self) -> None: self.assertIn("TC_ANY_PROTOCOL=false", rendered) self.assertIn("TC_CONFIGURE_ID=12345678-1234-1234-1234-123456789012", rendered) + def test_render_env_text_preserves_custom_settings_but_omits_deprecated_keys(self) -> None: + values = dict(DEFAULTS) + values.update({ + "TC_PASSWORD": "secret", + "TC_CUSTOM_SETTING": "kept value", + "CUSTOM_FLAG": "", + "TC_SAMBA_USER": "admin", + "TC_PAYLOAD_DIR_NAME": "samba4", + "TC_MDNS_INSTANCE_NAME": "old-name", + }) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + path.write_text(render_env_text(values)) + reparsed = parse_env_file(path) + + self.assertEqual(reparsed["TC_CUSTOM_SETTING"], "kept value") + self.assertEqual(reparsed["CUSTOM_FLAG"], "") + self.assertNotIn("TC_SAMBA_USER", reparsed) + self.assertNotIn("TC_PAYLOAD_DIR_NAME", reparsed) + self.assertNotIn("TC_MDNS_INSTANCE_NAME", reparsed) + + def test_preserved_env_file_values_filters_deprecated_runtime_keys(self) -> None: + values = { + "TC_HOST": "root@10.0.0.2", + "TC_CUSTOM_SETTING": "kept", + "TC_AIRPORT_SYAP": "119", + "TC_MDNS_DEVICE_MODEL": "TimeCapsule8,119", + "NET_IPV4_HINT": "10.0.0.2", + } + + preserved = preserved_env_file_values(values) + + self.assertEqual(preserved, {"TC_HOST": "root@10.0.0.2", "TC_CUSTOM_SETTING": "kept"}) + def test_env_example_does_not_include_runtime_derived_settings(self) -> None: values = parse_env_file(REPO_ROOT / ".env.example") self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) From 175195804ba67fd29baed0d04d962d9eed5fea70 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 22:54:41 -0700 Subject: [PATCH 04/13] Stabilize app service contracts and split backend operations --- .../TimeCapsuleSMBApp/ContentView.swift | 34 +- .../TimeCapsuleSMBApp/HelperRunner.swift | 52 +- .../Sources/TimeCapsuleSMBApp/Models.swift | 18 +- .../BackendEventTests.swift | 20 +- .../HelperRunnerTests.swift | 28 + src/timecapsulesmb/app/contracts.py | 218 ++++ src/timecapsulesmb/app/events.py | 16 +- src/timecapsulesmb/app/helper.py | 16 +- src/timecapsulesmb/app/operations.py | 943 ++---------------- src/timecapsulesmb/app/ops/__init__.py | 30 + src/timecapsulesmb/app/ops/configure.py | 133 +++ src/timecapsulesmb/app/ops/deploy.py | 371 +++++++ src/timecapsulesmb/app/ops/doctor.py | 39 + src/timecapsulesmb/app/ops/maintenance.py | 330 ++++++ src/timecapsulesmb/app/ops/readiness.py | 92 ++ src/timecapsulesmb/app/recovery.py | 288 ++++++ src/timecapsulesmb/app/service.py | 64 +- src/timecapsulesmb/app/stage_policy.py | 107 ++ src/timecapsulesmb/cli/deploy.py | 78 +- src/timecapsulesmb/cli/doctor.py | 3 +- src/timecapsulesmb/cli/fsck.py | 2 +- src/timecapsulesmb/cli/uninstall.py | 7 +- src/timecapsulesmb/services/app.py | 33 + src/timecapsulesmb/services/configure.py | 33 + src/timecapsulesmb/services/deploy.py | 61 ++ src/timecapsulesmb/services/doctor.py | 10 + src/timecapsulesmb/services/maintenance.py | 8 + tests/test_app_api.py | 164 ++- 28 files changed, 2241 insertions(+), 957 deletions(-) create mode 100644 src/timecapsulesmb/app/contracts.py create mode 100644 src/timecapsulesmb/app/ops/__init__.py create mode 100644 src/timecapsulesmb/app/ops/configure.py create mode 100644 src/timecapsulesmb/app/ops/deploy.py create mode 100644 src/timecapsulesmb/app/ops/doctor.py create mode 100644 src/timecapsulesmb/app/ops/maintenance.py create mode 100644 src/timecapsulesmb/app/ops/readiness.py create mode 100644 src/timecapsulesmb/app/recovery.py create mode 100644 src/timecapsulesmb/app/stage_policy.py create mode 100644 src/timecapsulesmb/services/configure.py create mode 100644 src/timecapsulesmb/services/deploy.py create mode 100644 src/timecapsulesmb/services/doctor.py create mode 100644 src/timecapsulesmb/services/maintenance.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 1feeef25..f8e6eddc 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -45,18 +45,34 @@ public struct ContentView: View { } } .frame(minWidth: 980, minHeight: 680) - .alert(item: $pendingConfirmation) { confirmation in - Alert( - title: Text(confirmation.title), - message: Text(confirmation.message), - primaryButton: .destructive(Text(confirmation.actionTitle)) { - backend.run(operation: confirmation.operation, params: confirmation.params) - }, - secondaryButton: .cancel() - ) + .alert( + pendingConfirmation?.title ?? "", + isPresented: confirmationPresented, + presenting: pendingConfirmation + ) { confirmation in + Button(confirmation.actionTitle, role: .destructive) { + backend.run(operation: confirmation.operation, params: confirmation.params) + pendingConfirmation = nil + } + Button("Cancel", role: .cancel) { + pendingConfirmation = nil + } + } message: { confirmation in + Text(confirmation.message) } } + private var confirmationPresented: Binding { + Binding( + get: { pendingConfirmation != nil }, + set: { isPresented in + if !isPresented { + pendingConfirmation = nil + } + } + ) + } + @ViewBuilder private var form: some View { switch selection { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 79654aa8..cbef5bcf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -70,22 +70,21 @@ public final class HelperRunner { try input.fileHandleForWriting.close() } catch { try? input.fileHandleForWriting.close() - terminate(process) + await Self.terminate(process) eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) await stdoutTask.value let stderr = await stderrTask.value return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) } - var cancelled = false - while process.isRunning { - if Task.isCancelled { - cancelled = true - terminate(process) - break + await withTaskCancellationHandler { + await Self.waitForExit(process) + } onCancel: { + Task { + await Self.terminate(process) } - try? await Task.sleep(nanoseconds: 100_000_000) } + let cancelled = Task.isCancelled await stdoutTask.value let stderrText = await stderrTask.value @@ -138,13 +137,29 @@ public final class HelperRunner { return String(data: output, encoding: .utf8) ?? "" } - private func terminate(_ process: Process) { + private static func waitForExit(_ process: Process) async { + if !process.isRunning { + return + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + let box = TerminationContinuation(continuation) + process.terminationHandler = { _ in + box.resume() + } + if !process.isRunning { + box.resume() + } + } + process.terminationHandler = nil + } + + private static func terminate(_ process: Process) async { process.terminate() for _ in 0..<10 { if !process.isRunning { return } - Thread.sleep(forTimeInterval: 0.1) + try? await Task.sleep(nanoseconds: 100_000_000) } if process.isRunning { kill(process.processIdentifier, SIGKILL) @@ -152,6 +167,23 @@ public final class HelperRunner { } } +private final class TerminationContinuation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume() { + lock.lock() + let continuation = continuation + self.continuation = nil + lock.unlock() + continuation?.resume() + } +} + private final class TerminalEventTracker: @unchecked Sendable { private let lock = NSLock() private var seen = false diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index bd2598fa..ec67bea4 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -80,6 +80,10 @@ public struct BackendEvent: Decodable, Identifiable { public let payload: JSONValue? public let details: JSONValue? public let debug: JSONValue? + public let recovery: JSONValue? + public let risk: String? + public let cancellable: Bool? + public let description: String? public init( schemaVersion: Int? = 1, @@ -94,7 +98,11 @@ public struct BackendEvent: Decodable, Identifiable { ok: Bool? = nil, payload: JSONValue? = nil, details: JSONValue? = nil, - debug: JSONValue? = nil + debug: JSONValue? = nil, + recovery: JSONValue? = nil, + risk: String? = nil, + cancellable: Bool? = nil, + description: String? = nil ) { self.schemaVersion = schemaVersion self.requestId = requestId @@ -109,6 +117,10 @@ public struct BackendEvent: Decodable, Identifiable { self.payload = payload self.details = details self.debug = debug + self.recovery = recovery + self.risk = risk + self.cancellable = cancellable + self.description = description } public static func error( @@ -140,6 +152,10 @@ public struct BackendEvent: Decodable, Identifiable { case payload case details case debug + case recovery + case risk + case cancellable + case description } public var summary: String { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift index 8f80a4e5..98696fb7 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -5,7 +5,7 @@ import XCTest final class BackendEventTests: XCTestCase { func testBackendEventDecodesContractFields() throws { let data = """ - {"schema_version":1,"request_id":"req-1","type":"error","operation":"deploy","code":"remote_error","message":"failed","debug":{"stderr":"detail"}} + {"schema_version":1,"request_id":"req-1","type":"error","operation":"deploy","code":"remote_error","message":"failed","debug":{"stderr":"detail"},"recovery":{"title":"No HFS volumes found","retryable":true,"actions":["retry"]}} """.data(using: .utf8)! let event = try JSONDecoder().decode(BackendEvent.self, from: data) @@ -17,6 +17,24 @@ final class BackendEventTests: XCTestCase { XCTAssertEqual(event.code, "remote_error") XCTAssertEqual(event.message, "failed") XCTAssertEqual(event.debug, .object(["stderr": .string("detail")])) + XCTAssertEqual(event.recovery, .object([ + "title": .string("No HFS volumes found"), + "retryable": .bool(true), + "actions": .array([.string("retry")]) + ])) + } + + func testBackendEventDecodesStagePolicyFields() throws { + let data = """ + {"schema_version":1,"type":"stage","operation":"deploy","stage":"upload_payload","risk":"remote_write","cancellable":false,"description":"Upload managed Samba payload files."} + """.data(using: .utf8)! + + let event = try JSONDecoder().decode(BackendEvent.self, from: data) + + XCTAssertEqual(event.stage, "upload_payload") + XCTAssertEqual(event.risk, "remote_write") + XCTAssertEqual(event.cancellable, false) + XCTAssertEqual(event.description, "Upload managed Samba payload files.") } func testJSONValueRoundTripsNestedObjects() throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index 3495cbf8..d595d2a0 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -91,6 +91,34 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(recorder.events.last?.code, "helper_not_found") } + func testRunnerCancelsLongRunningHelper() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + while true; do + sleep 1 + done + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let task = Task { + await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + recorder.append($0) + } + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + let result = await task.value + + XCTAssertEqual(result.exitCode, 130) + XCTAssertEqual(recorder.events.last?.type, "error") + XCTAssertEqual(recorder.events.last?.code, "cancelled") + } + private func makeHelper(in directory: URL, body: String) throws -> URL { let helper = directory.appendingPathComponent("tcapsule") try "#!/bin/sh\n\(body)\n".write(to: helper, atomically: true, encoding: .utf8) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py new file mode 100644 index 00000000..ba8b4221 --- /dev/null +++ b/src/timecapsulesmb/app/contracts.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.doctor import doctor_status_counts + + +SCHEMA_VERSION = 1 + + +def _with_schema(payload: Mapping[str, object]) -> dict[str, object]: + data = dict(payload) + data.setdefault("schema_version", SCHEMA_VERSION) + return data + + +def _device_payload(*, host: str | None = None, syap: str | None = None, model: str | None = None) -> dict[str, object]: + return { + "host": host, + "syap": syap, + "model": model, + } + + +def discover_payload(raw: Mapping[str, object]) -> dict[str, object]: + instances = list(raw.get("instances", [])) if isinstance(raw.get("instances"), list) else [] + resolved = list(raw.get("resolved", [])) if isinstance(raw.get("resolved"), list) else [] + return _with_schema({ + **raw, + "counts": { + "instances": len(instances), + "resolved": len(resolved), + }, + "summary": f"discovered {len(resolved)} resolved AirPort service(s).", + }) + + +def paths_payload(raw: Mapping[str, object]) -> dict[str, object]: + artifacts = raw.get("artifacts") + artifact_count = len(artifacts) if isinstance(artifacts, list) else 0 + return _with_schema({ + **raw, + "counts": {"artifacts": artifact_count}, + "summary": f"resolved app paths with {artifact_count} artifact path(s).", + }) + + +def install_validation_payload(*, ok: bool, checks: list[object]) -> dict[str, object]: + checks_payload = jsonable(checks) + checks_list = checks_payload if isinstance(checks_payload, list) else [] + pass_count = sum(1 for check in checks_list if isinstance(check, dict) and check.get("ok") is True) + fail_count = sum(1 for check in checks_list if isinstance(check, dict) and check.get("ok") is False) + return _with_schema({ + "ok": ok, + "checks": checks_list, + "counts": { + "checks": len(checks_list), + "pass": pass_count, + "fail": fail_count, + }, + "summary": "install validation passed." if ok else "install validation failed.", + }) + + +def configure_payload( + *, + config_path: str, + host: str, + configure_id: str, + ssh_authenticated: bool, + device_syap: str | None, + device_model: str | None, + compatibility: object | None, +) -> dict[str, object]: + return _with_schema({ + "config_path": config_path, + "host": host, + "configure_id": configure_id, + "ssh_authenticated": ssh_authenticated, + "device_syap": device_syap, + "device_model": device_model, + "compatibility": jsonable(compatibility), + "device": _device_payload(host=host, syap=device_syap, model=device_model), + "summary": "configuration saved and SSH authentication verified.", + }) + + +def deploy_plan_payload(raw: Mapping[str, object], *, payload_family: str | None, netbsd4: bool) -> dict[str, object]: + requires_reboot = bool(raw.get("reboot_required")) + return _with_schema({ + **raw, + "requires_reboot": requires_reboot, + "payload_family": payload_family, + "netbsd4": netbsd4, + "summary": "deployment dry-run plan generated.", + }) + + +def deploy_result_payload( + *, + payload_dir: str, + rebooted: bool | None = None, + netbsd4: bool = False, + message: str | None = None, + payload_family: str | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "payload_dir": payload_dir, + "netbsd4": netbsd4, + "payload_family": payload_family, + "requires_reboot": False if netbsd4 else bool(rebooted), + "summary": "deployment completed.", + } + if rebooted is not None: + payload["rebooted"] = rebooted + if message is not None: + payload["message"] = message + payload["summary"] = message + return _with_schema(payload) + + +def activation_plan_payload(raw: object) -> dict[str, object]: + payload = jsonable(raw) + if not isinstance(payload, dict): + payload = {"plan": payload} + actions = payload.get("actions") + action_count = len(actions) if isinstance(actions, list) else 0 + return _with_schema({ + **payload, + "counts": {"actions": action_count}, + "summary": "NetBSD4 activation dry-run plan generated.", + }) + + +def activation_result_payload(*, already_active: bool, message: str | None = None) -> dict[str, object]: + payload: dict[str, object] = { + "already_active": already_active, + "summary": "NetBSD4 payload was already active." if already_active else "NetBSD4 activation completed.", + } + if message is not None: + payload["message"] = message + payload["summary"] = message + return _with_schema(payload) + + +def uninstall_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + requires_reboot = bool(raw.get("reboot_required")) + payload_dirs = raw.get("payload_dirs") + payload_dir_count = len(payload_dirs) if isinstance(payload_dirs, list) else 0 + return _with_schema({ + **raw, + "requires_reboot": requires_reboot, + "counts": {"payload_dirs": payload_dir_count}, + "summary": "uninstall dry-run plan generated.", + }) + + +def uninstall_result_payload(*, rebooted: bool, verified: bool) -> dict[str, object]: + return _with_schema({ + "rebooted": rebooted, + "verified": verified, + "requires_reboot": rebooted, + "summary": "uninstall completed." if verified else "uninstall completed without post-reboot verification.", + }) + + +def fsck_result_payload( + *, + device: str, + mountpoint: str, + returncode: int | None = None, + waited: bool | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "device": device, + "mountpoint": mountpoint, + "summary": "fsck completed.", + } + if returncode is not None: + payload["returncode"] = returncode + if waited is not None: + payload["waited"] = waited + return _with_schema(payload) + + +def repair_xattrs_payload(raw: Mapping[str, object]) -> dict[str, object]: + finding_count = int(raw.get("finding_count") or 0) + repairable_count = int(raw.get("repairable_count") or 0) + return _with_schema({ + **raw, + "counts": { + "findings": finding_count, + "repairable": repairable_count, + }, + "summary_text": f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable.", + }) + + +def doctor_payload( + *, + fatal: bool, + results: list[CheckResult], + error: str | None = None, +) -> dict[str, object]: + result_payload = [jsonable(result) for result in results] + counts = doctor_status_counts(results) + payload: dict[str, object] = { + "fatal": fatal, + "results": result_payload, + "counts": counts, + "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", + } + if error: + payload["error"] = error + return _with_schema(payload) diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index 2accfd9e..e66df37a 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Callable +from timecapsulesmb.app.stage_policy import stage_policy + SENSITIVE_KEY_PARTS = ("password", "secret", "token") REDACTED = "" @@ -57,6 +59,7 @@ def __init__( self._emit = emit self.request_id = request_id or str(uuid.uuid4()) self.schema_version = schema_version + self._current_stage_by_operation: dict[str, str] = {} def with_request_id(self, request_id: str) -> "EventSink": return EventSink(self._emit, request_id=request_id, schema_version=self.schema_version) @@ -72,8 +75,16 @@ def emit(self, event: AppEvent) -> None: ) self._emit(event) + def current_stage(self, operation: str) -> str | None: + return self._current_stage_by_operation.get(operation) + def stage(self, operation: str, stage: str) -> None: - self.emit(AppEvent("stage", operation, {"stage": stage})) + self._current_stage_by_operation[operation] = stage + fields: dict[str, object] = {"stage": stage} + policy = stage_policy(operation, stage) + if policy is not None: + fields.update(policy.to_jsonable()) + self.emit(AppEvent("stage", operation, fields)) def log(self, operation: str, message: str, *, level: str = "info") -> None: self.emit(AppEvent("log", operation, {"level": level, "message": message})) @@ -102,8 +113,11 @@ def error( *, code: str = "operation_failed", debug: object | None = None, + recovery: object | None = None, ) -> None: fields: dict[str, object] = {"code": code, "message": message} if debug is not None: fields["debug"] = debug + if recovery is not None: + fields["recovery"] = recovery self.emit(AppEvent("error", operation, fields)) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py index bf2ace48..f35d0988 100644 --- a/src/timecapsulesmb/app/helper.py +++ b/src/timecapsulesmb/app/helper.py @@ -7,6 +7,7 @@ from typing import Optional, TextIO from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app.recovery import recovery_for from timecapsulesmb.app.service import run_api_request @@ -33,12 +34,23 @@ def main(argv: Optional[list[str]] = None) -> int: request = json.loads(raw) except json.JSONDecodeError as exc: message = f"invalid JSON request: {exc.msg}" - sink.error("api", message, code="invalid_request", debug={"pos": exc.pos}) + sink.error( + "api", + message, + code="invalid_request", + debug={"pos": exc.pos}, + recovery=recovery_for("api", "invalid_request"), + ) if args.pretty_error: print("invalid JSON request", file=sys.stderr) return 1 if not isinstance(request, dict): - sink.error("api", "request must be a JSON object", code="invalid_request") + sink.error( + "api", + "request must be a JSON object", + code="invalid_request", + recovery=recovery_for("api", "invalid_request"), + ) return 1 return run_api_request(request, sink) diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py index bf6b854b..95b346ce 100644 --- a/src/timecapsulesmb/app/operations.py +++ b/src/timecapsulesmb/app/operations.py @@ -1,885 +1,160 @@ from __future__ import annotations -import argparse -import shlex -import sys -import tempfile -import uuid +# Compatibility shim for callers that imported or monkeypatched the original +# monolithic module. New code should import from timecapsulesmb.app.ops. + from collections.abc import Callable -from contextlib import ExitStack, redirect_stderr, redirect_stdout -from pathlib import Path from timecapsulesmb.app.events import EventSink -from timecapsulesmb.checks.doctor import run_doctor_checks -from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli -from timecapsulesmb.cli.deploy import render_flash_runtime_config -from timecapsulesmb.cli.doctor import build_doctor_error -from timecapsulesmb.cli.fsck import ( - FSCK_REBOOT_NO_DOWN_MESSAGE, - FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - build_remote_fsck_script, - select_fsck_target, - _target_from_volume, -) -from timecapsulesmb.cli.runtime import ( - load_env_config, - load_optional_env_config, - resolve_env_connection, - resolve_validated_managed_target, - ssh_target_link_local_resolution_error, -) -from timecapsulesmb.core.config import ( - DEFAULTS, - MANAGED_PAYLOAD_DIR_NAME, - AppConfig, - airport_family_display_name_from_identity, - parse_bool, - parse_env_file, - preserved_env_file_values, - write_env_file, -) -from timecapsulesmb.core.errors import system_exit_message -from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP -from timecapsulesmb.core.net import extract_host -from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts -from timecapsulesmb.deploy.artifacts import validate_artifacts -from timecapsulesmb.deploy.auth import render_smbpasswd -from timecapsulesmb.deploy.boot_assets import boot_asset_path -from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable -from timecapsulesmb.deploy.executor import ( - flush_remote_filesystem_writes, - remote_request_reboot, - remote_request_shutdown_reboot, - remote_uninstall_payload, - run_remote_actions, - upload_deployment_payload, -) -from timecapsulesmb.deploy.planner import ( - BINARY_MDNS_SOURCE, - BINARY_NBNS_SOURCE, - BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - GENERATED_FLASH_CONFIG_SOURCE, - GENERATED_SMBPASSWD_SOURCE, - GENERATED_USERNAME_MAP_SOURCE, - PACKAGED_COMMON_SH_SOURCE, - PACKAGED_DFREE_SH_SOURCE, - PACKAGED_RC_LOCAL_SOURCE, - PACKAGED_START_SAMBA_SOURCE, - PACKAGED_WATCHDOG_SOURCE, - build_deployment_plan, - build_netbsd4_activation_plan, - build_uninstall_plan, -) -from timecapsulesmb.deploy.verify import ( - managed_runtime_ready, - render_managed_runtime_verification, - render_post_uninstall_verification, - verify_managed_runtime, - verify_post_uninstall, -) -from timecapsulesmb.device.compat import ( - is_netbsd4_payload_family, - payload_family_description, - render_compatibility_message, - require_compatibility, -) -from timecapsulesmb.device.probe import ( - probe_connection_state, - probe_managed_runtime_conn, - wait_for_ssh_state_conn, -) -from timecapsulesmb.device.storage import ( - MAST_DISCOVERY_ATTEMPTS, - MAST_DISCOVERY_DELAY_SECONDS, - UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, - build_dry_run_payload_home, - mounted_mast_volumes_conn, - read_mast_volumes_conn, - select_payload_home_with_diagnostics_conn, - verify_payload_home_conn, - wait_for_mast_volumes_conn, -) -from timecapsulesmb.discovery.bonjour import ( - DEFAULT_BROWSE_TIMEOUT_SEC, - BonjourDiscoverySnapshot, - BonjourResolvedService, - discover_snapshot, - discovered_record_root_host, - discovery_record_to_jsonable, - service_instance_to_jsonable, -) -from timecapsulesmb.install_validation import ( - install_checks_to_jsonable, - install_ok, - paths_to_jsonable, - validate_install, -) -from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot +from timecapsulesmb.app.ops import configure as _configure +from timecapsulesmb.app.ops import deploy as _deploy +from timecapsulesmb.app.ops import doctor as _doctor +from timecapsulesmb.app.ops import maintenance as _maintenance +from timecapsulesmb.app.ops import readiness as _readiness +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME +from timecapsulesmb.device.storage import build_dry_run_payload_home from timecapsulesmb.services.app import ( AppOperationError, OperationResult, bool_param as _bool_param, config_path as _config_path, confirm_param as _confirm_param, + float_param as _float_param, int_param as _int_param, jsonable as _jsonable, + optional_int_param as _optional_int_param, require_string_param as _require_string_param, string_param as _string_param, ) -from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh -REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." -DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) -UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." -) -ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 - - -def _selected_record_properties(params: dict[str, object]) -> dict[str, str]: - selected = params.get("selected_record") - if not isinstance(selected, dict): - return {} - properties = selected.get("properties") - if not isinstance(properties, dict): - return {} - return {str(key): str(value) for key, value in properties.items()} - - -def _selected_record_host(params: dict[str, object]) -> str: - selected = params.get("selected_record") - if not isinstance(selected, dict): - return "" - record = BonjourResolvedService( - name=str(selected.get("name") or ""), - hostname=str(selected.get("hostname") or ""), - service_type=str(selected.get("service_type") or ""), - port=int(selected.get("port") or 0), - ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), - ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), - properties=_selected_record_properties(params), - fullname=str(selected.get("fullname") or ""), - ) - return discovered_record_root_host(record) or "" - - -def _snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: - return { - "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], - "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], - } +discover_snapshot = _readiness.discover_snapshot + +probe_connection_state = _configure.probe_connection_state +enable_ssh = _configure.enable_ssh + +load_env_config = _deploy.load_env_config +resolve_validated_managed_target = _deploy.resolve_validated_managed_target +resolve_app_paths = _deploy.resolve_app_paths +validate_artifacts = _deploy.validate_artifacts +resolve_payload_artifacts = _deploy.resolve_payload_artifacts +run_remote_actions = _deploy.run_remote_actions +wait_for_mast_volumes_conn = _deploy.wait_for_mast_volumes_conn +select_payload_home_with_diagnostics_conn = _deploy.select_payload_home_with_diagnostics_conn +verify_payload_home_conn = _deploy.verify_payload_home_conn +upload_deployment_payload = _deploy.upload_deployment_payload +flush_remote_filesystem_writes = _deploy.flush_remote_filesystem_writes +wait_for_ssh_state_conn = _deploy.wait_for_ssh_state_conn + +resolve_env_connection = _maintenance.resolve_env_connection +remote_uninstall_payload = _maintenance.remote_uninstall_payload +probe_managed_runtime_conn = _maintenance.probe_managed_runtime_conn +load_optional_env_config = _maintenance.load_optional_env_config +repair_xattrs_cli = _maintenance.repair_xattrs_cli +sys = _maintenance.sys + +run_doctor_checks = _doctor.run_doctor_checks + + +def _sync_compat_bindings() -> None: + _readiness.discover_snapshot = discover_snapshot + _readiness.resolve_app_paths = resolve_app_paths + + _configure.probe_connection_state = probe_connection_state + _configure.enable_ssh = enable_ssh + _configure.resolve_app_paths = resolve_app_paths + + _deploy.load_env_config = load_env_config + _deploy.resolve_validated_managed_target = resolve_validated_managed_target + _deploy.resolve_app_paths = resolve_app_paths + _deploy.validate_artifacts = validate_artifacts + _deploy.resolve_payload_artifacts = resolve_payload_artifacts + _deploy.run_remote_actions = run_remote_actions + _deploy.wait_for_mast_volumes_conn = wait_for_mast_volumes_conn + _deploy.select_payload_home_with_diagnostics_conn = select_payload_home_with_diagnostics_conn + _deploy.verify_payload_home_conn = verify_payload_home_conn + _deploy.upload_deployment_payload = upload_deployment_payload + _deploy.flush_remote_filesystem_writes = flush_remote_filesystem_writes + _deploy.wait_for_ssh_state_conn = wait_for_ssh_state_conn + + _maintenance.load_env_config = load_env_config + _maintenance.resolve_env_connection = resolve_env_connection + _maintenance.remote_uninstall_payload = remote_uninstall_payload + _maintenance.run_remote_actions = run_remote_actions + _maintenance.probe_managed_runtime_conn = probe_managed_runtime_conn + _maintenance.load_optional_env_config = load_optional_env_config + _maintenance.repair_xattrs_cli = repair_xattrs_cli + _maintenance.sys = sys + + _doctor.load_env_config = load_env_config + _doctor.resolve_app_paths = resolve_app_paths + _doctor.resolve_env_connection = resolve_env_connection + _doctor.run_doctor_checks = run_doctor_checks def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "discover" - timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) - sink.stage(operation, "bonjour_discovery") - snapshot = discover_snapshot(timeout=timeout) - return OperationResult(True, _snapshot_payload(snapshot)) + _sync_compat_bindings() + return _readiness.discover_operation(params, sink) def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "paths" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "summarize_artifacts") - return OperationResult(True, paths_to_jsonable(app_paths)) + _sync_compat_bindings() + return _readiness.paths_operation(params, sink) def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "validate-install" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "validate_install") - checks = validate_install(app_paths) - ok = install_ok(checks) - for check in checks: - sink.check( - operation, - status="PASS" if check.ok else "FAIL", - message=check.message, - details=check.details, - ) - return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) + _sync_compat_bindings() + return _readiness.validate_install_operation(params, sink) def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "configure" - sink.stage(operation, "load_existing_config") - app_paths = resolve_app_paths(config_path=_config_path(params)) - env_path = app_paths.config_path - existing = parse_env_file(env_path) - configure_id = str(uuid.uuid4()) - ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) - host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") - password = _require_string_param(params, "password") - if not host: - raise AppOperationError("missing required parameter: host", code="validation_failed") - - resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) - if resolution_error is not None: - raise AppOperationError(resolution_error, code="config_error") - - values = preserved_env_file_values(existing) - values.update({ - "TC_HOST": host, - "TC_PASSWORD": password, - "TC_SSH_OPTS": ssh_opts, - "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _bool_param( - params, - "internal_share_use_disk_root", - parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), - ) else "false", - "TC_ANY_PROTOCOL": "true" if _bool_param( - params, - "any_protocol", - parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), - ) else "false", - "TC_CONFIGURE_ID": configure_id, - }) - - sink.stage(operation, "ssh_probe") - connection = SshConnection(host, password, ssh_opts) - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_port_reachable: - if not _bool_param(params, "enable_ssh", True): - raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") - sink.stage(operation, "acp_enable_ssh") - try: - enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) - except ACPAuthError as exc: - raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc - except ACPError as exc: - raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc - - sink.stage(operation, "wait_for_ssh_after_acp") - if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): - raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") - sink.stage(operation, "ssh_probe_after_acp") - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_authenticated: - raise AppOperationError( - probe.error or "The provided AirPort SSH target and password did not work.", - code="auth_failed", - ) - - compatibility = probed_state.compatibility - if compatibility is not None and not compatibility.supported: - raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") - - selected_props = _selected_record_properties(params) - observed_syap = None if compatibility is None else compatibility.exact_syap - observed_model = None if compatibility is None else compatibility.exact_model - if observed_syap is None: - observed_syap = selected_props.get("syAP") or None - - sink.stage(operation, "write_env") - env_path.parent.mkdir(parents=True, exist_ok=True) - write_env_file(env_path, values) - return OperationResult(True, { - "config_path": str(env_path), - "host": host, - "configure_id": configure_id, - "ssh_authenticated": True, - "device_syap": observed_syap, - "device_model": observed_model, - "compatibility": _jsonable(compatibility) if compatibility is not None else None, - }) - - -def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: - from timecapsulesmb.cli.flows import wait_for_tcp_port_state - - return wait_for_tcp_port_state( - extract_host(host), - 22, - expected_state=True, - timeout_seconds=timeout_seconds, - verbose=False, - service_name="SSH port", - ) - - -def _require_supported_payload(target, *, allow_unsupported: bool) -> object: - probe_state = target.probe_state - if probe_state is None: - raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") - compatibility = require_compatibility( - probe_state.compatibility, - fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) - if not compatibility.supported and not allow_unsupported: - raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") - if not compatibility.payload_family: - raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") - return compatibility - - -def _load_config_and_target( - operation: str, - params: dict[str, object], - sink: EventSink, - *, - profile: str, - include_probe: bool, -) -> tuple[AppConfig, object]: - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_managed_target") - target = resolve_validated_managed_target( - config, - command_name=operation, - profile=profile, - include_probe=include_probe, - ) - return config, target + _sync_compat_bindings() + return _configure.configure_operation(params, sink) def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "deploy" - nbns_enabled = _bool_param(params, "nbns_enabled", True) - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - confirm_deploy = _confirm_param(params, "confirm_deploy") - confirm_reboot = _confirm_param(params, "confirm_reboot") - confirm_netbsd4_activation = _confirm_param(params, "confirm_netbsd4_activation") - mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) - allow_unsupported = _bool_param(params, "allow_unsupported") - debug_logging = _bool_param(params, "debug_logging") - - if not dry_run and not confirm_deploy: - raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") - - config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) - connection = target.connection - app_paths = resolve_app_paths(config_path=_config_path(params)) - - sink.stage(operation, "validate_artifacts") - failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] - if failures: - raise AppOperationError("; ".join(failures), code="validation_failed") - - sink.stage(operation, "check_compatibility") - compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) - payload_family = compatibility.payload_family - is_netbsd4 = is_netbsd4_payload_family(payload_family) - sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") - resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) - if not dry_run: - if is_netbsd4 and not confirm_netbsd4_activation: - raise AppOperationError( - "NetBSD 4 deploy requires explicit activation confirmation.", - code="confirmation_required", - ) - if not is_netbsd4 and not no_reboot and not confirm_reboot: - device_name = airport_family_display_name_from_identity( - model=target.probe_state.probe_result.airport_model if target.probe_state else None, - syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, - ) - raise AppOperationError( - f"Deploy requires confirmation to reboot the {device_name}.", - code="confirmation_required", - ) - - if dry_run: - payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) - else: - sink.stage(operation, "read_mast") - mast_discovery = wait_for_mast_volumes_conn( - connection, - attempts=MAST_DISCOVERY_ATTEMPTS, - delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, - ) - if not mast_discovery.volumes: - raise AppOperationError( - f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " - f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart.", - code="remote_error", - ) - sink.stage(operation, "select_payload_home") - selection = select_payload_home_with_diagnostics_conn( - connection, - mast_discovery.volumes, - MANAGED_PAYLOAD_DIR_NAME, - wait_seconds=mount_wait, - ) - if selection.payload_home is None: - raise AppOperationError( - f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.", - code="remote_error", - ) - payload_home = selection.payload_home - - sink.stage(operation, "build_deployment_plan") - plan = build_deployment_plan( - connection.host, - payload_home, - resolved_artifacts["smbd"].absolute_path, - resolved_artifacts["mdns-advertiser"].absolute_path, - resolved_artifacts["nbns-advertiser"].absolute_path, - activate_netbsd4=is_netbsd4, - reboot_after_deploy=not no_reboot, - apple_mount_wait_seconds=mount_wait, - ) - if dry_run: - return OperationResult(True, deployment_plan_to_jsonable(plan)) - - sink.stage(operation, "pre_upload_actions") - run_remote_actions(connection, plan.pre_upload_actions) - sink.stage(operation, "prepare_deployment_files") - flash_config_text = render_flash_runtime_config( - config, - payload_home, - nbns_enabled=nbns_enabled, - debug_logging=debug_logging, - ) - with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: - tmpdir = Path(tmp) - generated_flash_config = tmpdir / "tcapsulesmb.conf" - generated_smbpasswd = tmpdir / "smbpasswd" - generated_username_map = tmpdir / "username.map" - generated_flash_config.write_text(flash_config_text) - smbpasswd_text, username_map_text = render_smbpasswd(connection.password) - generated_smbpasswd.write_text(smbpasswd_text) - generated_username_map.write_text(username_map_text) - upload_sources = { - BINARY_SMBD_SOURCE: plan.smbd_path, - BINARY_MDNS_SOURCE: plan.mdns_path, - BINARY_NBNS_SOURCE: plan.nbns_path, - GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, - GENERATED_USERNAME_MAP_SOURCE: generated_username_map, - GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, - PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), - PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), - PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), - PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), - PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), - } - sink.stage(operation, "upload_payload") - upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) - - sink.stage(operation, "post_upload_actions") - run_remote_actions(connection, plan.post_upload_actions) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) - sink.stage(operation, "flush_payload_upload") - sink.log(operation, "Flushing deployed payload to disk...") - flush_remote_filesystem_writes(connection) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) - - if is_netbsd4: - sink.stage(operation, "netbsd4_activation") - run_remote_actions(connection, plan.activation_actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, { - "payload_dir": plan.payload_dir, - "netbsd4": True, - "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", - }) - - if no_reboot: - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) - - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="ssh_shutdown_then_reboot", - reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, - ) - _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) - - -def _verify_payload_upload( - operation: str, - sink: EventSink, - connection: SshConnection, - payload_home, - *, - wait_seconds: int, - post_sync: bool = False, -) -> None: - sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") - verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) - sink.log(operation, verification.detail) - if not verification.ok: - raise AppOperationError( - f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}", - code="remote_error", - ) - - -def _verify_runtime( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - stage: str, - timeout_seconds: int, -) -> None: - sink.stage(operation, stage) - verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) - for line in render_managed_runtime_verification( - verification, - heading="Waiting for managed runtime to finish starting...", - ): - sink.log(operation, line) - if not managed_runtime_ready(verification): - raise AppOperationError( - f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), - code="remote_error", - ) - - -def _request_reboot_and_wait( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - strategy: str, - reboot_no_down_message: str, - down_timeout_seconds: int = 60, - up_timeout_seconds: int = 240, -) -> None: - sink.stage(operation, "reboot") - if strategy == "acp_then_ssh": - try: - acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) - sink.log(operation, "ACP reboot requested.") - except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") - _request_ssh_reboot(operation, sink, connection, shutdown=False) - else: - _request_ssh_reboot(operation, sink, connection, shutdown=True) - - sink.stage(operation, "wait_for_reboot_down") - sink.log(operation, "Waiting for the device to go down...") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message, code="remote_error") - sink.stage(operation, "wait_for_reboot_up") - sink.log(operation, "Waiting for the device to come back up...") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") - sink.log(operation, "Device is back online.") - - -def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: - try: - if shutdown: - remote_request_shutdown_reboot(connection) - else: - remote_request_reboot(connection) - except SshCommandTimeout as exc: - sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") - return - except SshError as exc: - sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") - return - sink.log(operation, "SSH reboot requested.") + _sync_compat_bindings() + return _deploy.deploy_operation(params, sink) def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "activate" - confirm_activation = _confirm_param(params, "confirm_netbsd4_activation") - dry_run = _bool_param(params, "dry_run") - _, target = _load_config_and_target(operation, params, sink, profile="activate", include_probe=True) - compatibility = _require_supported_payload(target, allow_unsupported=False) - if not is_netbsd4_payload_family(compatibility.payload_family): - raise AppOperationError( - "activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", - code="unsupported_device", - ) - sink.stage(operation, "build_activation_plan") - plan = build_netbsd4_activation_plan() - if dry_run: - return OperationResult(True, _jsonable(plan)) - if not confirm_activation: - raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") - connection = target.connection - sink.stage(operation, "probe_runtime") - if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: - return OperationResult(True, {"already_active": True}) - sink.stage(operation, "run_activation") - run_remote_actions(connection, plan.actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) + _sync_compat_bindings() + return _maintenance.activate_operation(params, sink) def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "uninstall" - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - confirm_uninstall = _confirm_param(params, "confirm_uninstall") - confirm_reboot = _confirm_param(params, "confirm_reboot") - if not dry_run and not confirm_uninstall: - raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") - if not dry_run and not no_reboot and not confirm_reboot: - raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - if dry_run: - volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] - payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] - else: - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_mast_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - volume_roots = [volume.volume_root for volume in mounted_volumes] - payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] - sink.stage(operation, "build_uninstall_plan") - plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) - if dry_run: - return OperationResult(True, uninstall_plan_to_jsonable(plan)) - sink.stage(operation, "uninstall_payload") - remote_uninstall_payload(connection, plan) - if no_reboot: - return OperationResult(True, {"rebooted": False, "verified": False}) - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="acp_then_ssh", - reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, - ) - sink.stage(operation, "verify_post_uninstall") - verification = verify_post_uninstall(connection, plan) - for line in render_post_uninstall_verification(verification): - sink.log(operation, line) - if not verification: - raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") - return OperationResult(True, {"rebooted": True, "verified": True}) + _sync_compat_bindings() + return _maintenance.uninstall_operation(params, sink) def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "fsck" - confirm_fsck = _confirm_param(params, "confirm_fsck") - no_reboot = _bool_param(params, "no_reboot") - no_wait = _bool_param(params, "no_wait") - if not confirm_fsck: - raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_hfs_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - sink.stage(operation, "select_fsck_volume") - try: - target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), - _string_param(params, "volume") or None, - prompt=False, - ) - except RuntimeError as exc: - raise AppOperationError(str(exc), code="validation_failed") from exc - sink.stage(operation, "run_fsck") - script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) - proc = run_ssh( - connection, - f"/bin/sh -c {shlex.quote(script)}", - check=False, - timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - ) - if proc.stdout: - for line in proc.stdout.splitlines(): - sink.log(operation, line) - if no_reboot: - return OperationResult(proc.returncode == 0, { - "device": target.device, - "mountpoint": target.mountpoint, - "returncode": proc.returncode, - }) - if no_wait: - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) - _observe_reboot_cycle( - operation, - sink, - connection, - reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, - down_timeout_seconds=90, - up_timeout_seconds=420, - ) - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) - - -def _observe_reboot_cycle( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - reboot_no_down_message: str, - down_timeout_seconds: int, - up_timeout_seconds: int, -) -> None: - sink.stage(operation, "wait_for_reboot_down") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message, code="remote_error") - sink.stage(operation, "wait_for_reboot_up") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") - - -class _RepairContext: - def __init__(self, operation: str, sink: EventSink) -> None: - self.operation = operation - self.sink = sink - self.result = "failure" - self.error: str | None = None - - def set_stage(self, stage: str) -> None: - self.sink.stage(self.operation, stage) - - def update_fields(self, **_fields: object) -> None: - pass - - def succeed(self) -> None: - self.result = "success" - - def fail_with_error(self, message: str) -> None: - self.result = "failure" - self.error = message - - -class _StreamLogCapture: - def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: - self.operation = operation - self.sink = sink - self.level = level - self._buffer = "" - - def write(self, text: str) -> int: - self._buffer += text - while "\n" in self._buffer: - line, self._buffer = self._buffer.split("\n", 1) - self._emit(line) - return len(text) - - def flush(self) -> None: - if self._buffer: - self._emit(self._buffer) - self._buffer = "" - - def _emit(self, line: str) -> None: - message = line.rstrip("\r") - if message: - self.sink.log(self.operation, message, level=self.level) + _sync_compat_bindings() + return _maintenance.fsck_operation(params, sink) def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "repair-xattrs" - dry_run = _bool_param(params, "dry_run") - confirm_repair = _confirm_param(params, "confirm_repair") - if not dry_run and not confirm_repair: - raise AppOperationError( - "repair-xattrs requires dry_run or explicit confirmation.", - code="confirmation_required", - ) - if sys.platform != "darwin": - raise AppOperationError( - "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", - code="validation_failed", - ) - config = load_optional_env_config(env_path=_config_path(params)) - args = argparse.Namespace( - path=Path(str(params["path"])) if params.get("path") else None, - dry_run=dry_run, - yes=confirm_repair, - recursive=_bool_param(params, "recursive", True), - max_depth=params.get("max_depth"), - include_hidden=_bool_param(params, "include_hidden"), - include_time_machine=_bool_param(params, "include_time_machine"), - fix_permissions=_bool_param(params, "fix_permissions"), - verbose=_bool_param(params, "verbose"), - ) - if args.max_depth is not None: - args.max_depth = int(args.max_depth) - context = _RepairContext(operation, sink) - stdout_capture = _StreamLogCapture(operation, sink, level="info") - stderr_capture = _StreamLogCapture(operation, sink, level="warning") - try: - with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): - result = repair_xattrs_cli.run_repair_structured( - args, - context, - config, - emit_log=lambda message: sink.log(operation, message), - ) - except SystemExit as exc: - message = system_exit_message(exc) or "repair-xattrs failed" - raise AppOperationError(message, code="operation_failed") from exc - finally: - stdout_capture.flush() - stderr_capture.flush() - return OperationResult(result.returncode == 0, { - "returncode": result.returncode, - "root": str(result.root), - "finding_count": len(result.findings), - "repairable_count": len(result.candidates), - "summary": _jsonable(result.summary), - "report": result.report, - "telemetry_result": context.result, - "error": context.error, - }) + _sync_compat_bindings() + return _maintenance.repair_xattrs_operation(params, sink) def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "doctor" - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - app_paths = resolve_app_paths(config_path=_config_path(params)) - connection = None - if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - debug_fields: dict[str, object] = {} - - def on_result(result: CheckResult) -> None: - sink.check(operation, status=result.status, message=result.message, details=result.details) - - sink.stage(operation, "run_checks") - results, fatal = run_doctor_checks( - config, - repo_root=app_paths.distribution_root, - connection=connection, - skip_ssh=_bool_param(params, "skip_ssh"), - skip_bonjour=_bool_param(params, "skip_bonjour"), - skip_smb=_bool_param(params, "skip_smb"), - on_result=on_result, - debug_fields=debug_fields, - ) - payload = { - "fatal": fatal, - "results": [_jsonable(result) for result in results], - "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", - } - if fatal: - payload["error"] = build_doctor_error(results, debug_fields) - return OperationResult(not fatal, payload) + _sync_compat_bindings() + return _doctor.doctor_operation(params, sink) + + +_selected_record_host = _readiness.selected_record_host +_selected_record_properties = _readiness.selected_record_properties +_snapshot_payload = _readiness.snapshot_payload +_wait_for_ssh_port = _configure.wait_for_ssh_port +_require_supported_payload = _deploy.require_supported_payload +_load_config_and_target = _deploy.load_config_and_target +_verify_payload_upload = _deploy.verify_payload_upload +_verify_runtime = _deploy.verify_runtime +_request_reboot_and_wait = _deploy.request_reboot_and_wait +_request_ssh_reboot = _deploy.request_ssh_reboot +_observe_reboot_cycle = _maintenance.observe_reboot_cycle +_RepairContext = _maintenance.RepairContext +_StreamLogCapture = _maintenance.StreamLogCapture OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py new file mode 100644 index 00000000..b015146d --- /dev/null +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.configure import configure_operation +from timecapsulesmb.app.ops.deploy import deploy_operation +from timecapsulesmb.app.ops.doctor import doctor_operation +from timecapsulesmb.app.ops.maintenance import ( + activate_operation, + fsck_operation, + repair_xattrs_operation, + uninstall_operation, +) +from timecapsulesmb.app.ops.readiness import discover_operation, paths_operation, validate_install_operation +from timecapsulesmb.services.app import OperationResult + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py new file mode 100644 index 00000000..d8ec9600 --- /dev/null +++ b/src/timecapsulesmb/app/ops/configure.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import uuid + +from timecapsulesmb.app.contracts import configure_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.readiness import selected_record_host, selected_record_properties +from timecapsulesmb.core.config import ( + DEFAULTS, + parse_bool, + parse_env_file, + write_env_file, +) +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.compat import render_compatibility_message +from timecapsulesmb.device.probe import probe_connection_state +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + jsonable, + require_string_param, + string_param, +) +from timecapsulesmb.services.configure import build_configure_env_values +from timecapsulesmb.transport.ssh import SshConnection + +from timecapsulesmb.cli.runtime import ssh_target_link_local_resolution_error + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", "") + password = require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host", code="validation_failed") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error, code="config_error") + + values = build_configure_env_values( + existing, + host=host, + password=password, + ssh_opts=ssh_opts, + configure_id=configure_id, + internal_share_use_disk_root=bool_param( + params, + "internal_share_use_disk_root", + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ), + any_protocol=bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ), + ) + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not wait_for_ssh_port(host, timeout_seconds=int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError( + probe.error or "The provided AirPort SSH target and password did not work.", + code="auth_failed", + ) + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + + selected_props = selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + write_env_file(env_path, values) + return OperationResult(True, configure_payload( + config_path=str(env_path), + host=host, + configure_id=configure_id, + ssh_authenticated=True, + device_syap=observed_syap, + device_model=observed_model, + compatibility=jsonable(compatibility) if compatibility is not None else None, + )) + + +def wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + from timecapsulesmb.cli.flows import wait_for_tcp_port_state + + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + verbose=False, + service_name="SSH port", + ) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py new file mode 100644 index 00000000..7c7a7a13 --- /dev/null +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +from contextlib import ExitStack +from pathlib import Path +import tempfile + +from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.cli.runtime import load_env_config, resolve_validated_managed_target +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, airport_family_display_name_from_identity +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + remote_request_shutdown_reboot, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + verify_managed_runtime, +) +from timecapsulesmb.device.compat import ( + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import wait_for_ssh_state_conn +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + build_dry_run_payload_home, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.integrations.acp import ACPError, reboot as acp_reboot +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + confirm_param, + int_param, +) +from timecapsulesmb.services.deploy import ( + DEPLOY_REBOOT_NO_DOWN_MESSAGE, + DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError + + +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +def require_supported_payload(target, *, allow_unsupported: bool) -> object: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") + return compatibility + + +def load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, object]: + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = bool_param(params, "nbns_enabled", True) + dry_run = bool_param(params, "dry_run") + no_reboot = bool_param(params, "no_reboot") + confirm_deploy = confirm_param(params, "confirm_deploy") + confirm_reboot = confirm_param(params, "confirm_reboot") + confirm_netbsd4_activation = confirm_param(params, "confirm_netbsd4_activation") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = bool_param(params, "allow_unsupported") + debug_logging = bool_param(params, "debug_logging") + + if not dry_run and not confirm_deploy: + raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") + + config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures), code="validation_failed") + + sink.stage(operation, "check_compatibility") + compatibility = require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + if not dry_run: + if is_netbsd4 and not confirm_netbsd4_activation: + raise AppOperationError( + "NetBSD 4 deploy requires explicit activation confirmation.", + code="confirmation_required", + ) + if not is_netbsd4 and not no_reboot and not confirm_reboot: + device_name = airport_family_display_name_from_identity( + model=target.probe_state.probe_result.airport_model if target.probe_state else None, + syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, + ) + raise AppOperationError( + f"Deploy requires confirmation to reboot the {device_name}.", + code="confirmation_required", + ) + + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + no_mast_volumes_message( + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ), + code="remote_error", + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError( + no_writable_mast_volumes_message(len(mast_discovery.volumes)), + code="remote_error", + ) + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deploy_plan_payload( + deployment_plan_to_jsonable(plan), + payload_family=payload_family, + netbsd4=is_netbsd4, + )) + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + netbsd4=True, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + payload_family=payload_family, + )) + + if no_reboot: + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + rebooted=False, + payload_family=payload_family, + )) + + request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + rebooted=True, + payload_family=payload_family, + )) + + +def verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError(payload_verification_error(payload_home, verification), code="remote_error") + + +def verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError( + f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), + code="remote_error", + ) + + +def request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + request_ssh_reboot(operation, sink, connection, shutdown=False) + else: + request_ssh_reboot(operation, sink, connection, shutdown=True) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + sink.log(operation, "Device is back online.") + + +def request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: + try: + if shutdown: + remote_request_shutdown_reboot(connection) + else: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py new file mode 100644 index 00000000..184be906 --- /dev/null +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from timecapsulesmb.app.contracts import doctor_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli.doctor import build_doctor_error +from timecapsulesmb.cli.runtime import load_env_config, resolve_env_connection +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.services.app import OperationResult, bool_param, config_path + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + app_paths = resolve_app_paths(config_path=config_path(params)) + connection = None + if not bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=bool_param(params, "skip_ssh"), + skip_bonjour=bool_param(params, "skip_bonjour"), + skip_smb=bool_param(params, "skip_smb"), + on_result=on_result, + debug_fields=debug_fields, + ) + error = build_doctor_error(results, debug_fields) if fatal else None + return OperationResult(not fatal, doctor_payload(fatal=fatal, results=results, error=error)) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py new file mode 100644 index 00000000..9799f3b2 --- /dev/null +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import argparse +import shlex +import sys +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +from timecapsulesmb.app.contracts import ( + activation_plan_payload, + activation_result_payload, + fsck_result_payload, + repair_xattrs_payload, + uninstall_plan_payload, + uninstall_result_payload, +) +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.deploy import ( + load_config_and_target, + request_reboot_and_wait, + require_supported_payload, + verify_runtime, +) +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli +from timecapsulesmb.cli.fsck import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + select_fsck_target, + _target_from_volume, +) +from timecapsulesmb.cli.runtime import load_env_config, load_optional_env_config, resolve_env_connection +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.deploy.dry_run import uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import remote_uninstall_payload, run_remote_actions +from timecapsulesmb.deploy.planner import ( + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall +from timecapsulesmb.device.compat import is_netbsd4_payload_family +from timecapsulesmb.device.probe import probe_managed_runtime_conn, wait_for_ssh_state_conn +from timecapsulesmb.device.storage import ( + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + mounted_mast_volumes_conn, + read_mast_volumes_conn, +) +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + confirm_param, + jsonable, + optional_int_param, + string_param, +) +from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE +from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.transport.ssh import SshConnection, run_ssh + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + confirm_activation = confirm_param(params, "confirm_netbsd4_activation") + dry_run = bool_param(params, "dry_run") + _, target = load_config_and_target(operation, params, sink, profile="activate", include_probe=True) + compatibility = require_supported_payload(target, allow_unsupported=False) + if not is_netbsd4_payload_family(compatibility.payload_family): + raise AppOperationError( + "activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", + code="unsupported_device", + ) + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, activation_plan_payload(jsonable(plan))) + if not confirm_activation: + raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, activation_result_payload(already_active=True)) + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, activation_result_payload( + already_active=False, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + )) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = bool_param(params, "dry_run") + no_reboot = bool_param(params, "no_reboot") + confirm_uninstall = confirm_param(params, "confirm_uninstall") + confirm_reboot = confirm_param(params, "confirm_reboot") + if not dry_run and not confirm_uninstall: + raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") + if not dry_run and not no_reboot and not confirm_reboot: + raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_payload(uninstall_plan_to_jsonable(plan))) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, uninstall_result_payload(rebooted=False, verified=False)) + request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") + return OperationResult(True, uninstall_result_payload(rebooted=True, verified=True)) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + confirm_fsck = confirm_param(params, "confirm_fsck") + no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") + if not confirm_fsck: + raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + tuple(_target_from_volume(volume) for volume in mounted_volumes), + string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + returncode=proc.returncode, + )) + if no_wait: + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + waited=False, + )) + observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + waited=True, + )) + + +def observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + + +class RepairContext: + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self.sink.stage(self.operation, stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +class StreamLogCapture: + def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: + self.operation = operation + self.sink = sink + self.level = level + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self.sink.log(self.operation, message, level=self.level) + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + dry_run = bool_param(params, "dry_run") + confirm_repair = confirm_param(params, "confirm_repair") + if not dry_run and not confirm_repair: + raise AppOperationError( + "repair-xattrs requires dry_run or explicit confirmation.", + code="confirmation_required", + ) + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) + sink.stage(operation, "validate_params") + config = load_optional_env_config(env_path=config_path(params)) + args = argparse.Namespace( + path=Path(str(params["path"])) if params.get("path") else None, + dry_run=dry_run, + yes=confirm_repair, + recursive=bool_param(params, "recursive", True), + max_depth=optional_int_param(params, "max_depth"), + include_hidden=bool_param(params, "include_hidden"), + include_time_machine=bool_param(params, "include_time_machine"), + fix_permissions=bool_param(params, "fix_permissions"), + verbose=bool_param(params, "verbose"), + ) + context = RepairContext(operation, sink) + stdout_capture = StreamLogCapture(operation, sink, level="info") + stderr_capture = StreamLogCapture(operation, sink, level="warning") + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = repair_xattrs_cli.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message, code="operation_failed") from exc + finally: + stdout_capture.flush() + stderr_capture.flush() + return OperationResult(result.returncode == 0, repair_xattrs_payload({ + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "summary": jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error, + })) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py new file mode 100644 index 00000000..05e10b38 --- /dev/null +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from timecapsulesmb.app.contracts import discover_payload, install_validation_payload, paths_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.services.app import ( + OperationResult, + config_path, + float_param, +) + + +def selected_record_properties(params: dict[str, object]) -> dict[str, str]: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return {} + properties = selected.get("properties") + if not isinstance(properties, dict): + return {} + return {str(key): str(value) for key, value in properties.items()} + + +def selected_record_host(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + record = BonjourResolvedService( + name=str(selected.get("name") or ""), + hostname=str(selected.get("hostname") or ""), + service_type=str(selected.get("service_type") or ""), + port=int(selected.get("port") or 0), + ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), + ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), + properties=selected_record_properties(params), + fullname=str(selected.get("fullname") or ""), + ) + return discovered_record_root_host(record) or "" + + +def snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float_param(params, "timeout", DEFAULT_BROWSE_TIMEOUT_SEC) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, discover_payload(snapshot_payload(snapshot))) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_payload(paths_to_jsonable(app_paths))) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, install_validation_payload(ok=ok, checks=install_checks_to_jsonable(checks))) diff --git a/src/timecapsulesmb/app/recovery.py b/src/timecapsulesmb/app/recovery.py new file mode 100644 index 00000000..e32e7240 --- /dev/null +++ b/src/timecapsulesmb/app/recovery.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RecoveryInfo: + title: str + message: str + actions: tuple[str, ...] + retryable: bool + suggested_operation: str | None = None + docs_anchor: str | None = None + + def to_jsonable(self) -> dict[str, object]: + payload: dict[str, object] = { + "title": self.title, + "message": self.message, + "actions": list(self.actions), + "retryable": self.retryable, + "suggested_operation": self.suggested_operation, + } + if self.docs_anchor: + payload["docs_anchor"] = self.docs_anchor + return payload + + +_DEFAULTS: dict[str, RecoveryInfo] = { + "invalid_request": RecoveryInfo( + "Invalid request", + "The helper request was malformed or had invalid parameter types.", + ("Check the request JSON shape.", "Send params as a JSON object."), + retryable=True, + ), + "unknown_operation": RecoveryInfo( + "Unknown operation", + "The helper does not recognize the requested operation.", + ("Use one of the helper operations exposed by this app version.",), + retryable=False, + ), + "validation_failed": RecoveryInfo( + "Request validation failed", + "One or more operation parameters were missing or invalid.", + ("Review the highlighted fields.", "Retry with valid values."), + retryable=True, + ), + "config_error": RecoveryInfo( + "Configuration error", + "The current .env configuration could not be read or used.", + ("Open the configuration step.", "Verify host, password, and SSH options."), + retryable=True, + suggested_operation="configure", + ), + "auth_failed": RecoveryInfo( + "Authentication failed", + "The Time Capsule rejected the supplied password or SSH credentials.", + ("Re-enter the AirPort admin password.", "Verify that SSH is enabled on the device."), + retryable=True, + suggested_operation="configure", + ), + "unsupported_device": RecoveryInfo( + "Unsupported device", + "The detected AirPort model or OS does not have a deployable payload in this build.", + ("Check the detected model and OS.", "Use the CLI only if you intentionally pass unsupported-device overrides."), + retryable=False, + ), + "confirmation_required": RecoveryInfo( + "Confirmation required", + "This operation changes the device and needs explicit confirmation.", + ("Review the plan.", "Confirm the operation in the app before retrying."), + retryable=True, + ), + "remote_error": RecoveryInfo( + "Remote operation failed", + "The helper could not complete the requested remote device operation.", + ("Check the operation log.", "Run doctor after the device is reachable."), + retryable=True, + suggested_operation="doctor", + ), + "operation_failed": RecoveryInfo( + "Operation failed", + "The helper hit an unexpected failure while running the operation.", + ("Check debug details.", "Retry after fixing the reported cause."), + retryable=True, + ), +} + + +_OPERATION_CODE_RECOVERY: dict[tuple[str, str], RecoveryInfo] = { + ("configure", "auth_failed"): RecoveryInfo( + "AirPort password rejected", + "ACP or SSH authentication failed while configuring the device.", + ("Re-enter the AirPort admin password.", "Confirm the selected device is the intended Time Capsule."), + retryable=True, + suggested_operation="configure", + ), + ("configure", "unsupported_device"): RecoveryInfo( + "Unsupported Time Capsule", + "The SSH probe succeeded, but the detected hardware or OS cannot use a bundled payload.", + ("Review the detected model and OS.", "Use a supported Gen 4 or Gen 5 Time Capsule."), + retryable=False, + ), + ("deploy", "confirmation_required"): RecoveryInfo( + "Deploy confirmation required", + "Deploy needs confirmation before uploading payload files, rebooting, or activating NetBSD4.", + ("Review the deploy plan.", "Confirm deploy and any required reboot or activation prompt."), + retryable=True, + ), + ("deploy", "validation_failed"): RecoveryInfo( + "Deployment validation failed", + "The bundled payload artifacts or deployment inputs are invalid.", + ("Open Readiness.", "Fix missing artifacts or invalid fields before retrying."), + retryable=True, + suggested_operation="validate-install", + ), + ("deploy", "unsupported_device"): RecoveryInfo( + "No supported deploy payload", + "The detected device does not match a bundled payload family.", + ("Check the device model and OS.", "Do not deploy from the GUI until a supported payload is available."), + retryable=False, + ), + ("activate", "confirmation_required"): RecoveryInfo( + "Activation confirmation required", + "NetBSD4 activation starts the deployed runtime and must be confirmed.", + ("Review the NetBSD4 activation guidance.", "Confirm activation before retrying."), + retryable=True, + ), + ("uninstall", "confirmation_required"): RecoveryInfo( + "Uninstall confirmation required", + "Uninstall removes managed files and may reboot the device.", + ("Review the uninstall plan.", "Confirm uninstall and reboot before retrying."), + retryable=True, + ), + ("fsck", "confirmation_required"): RecoveryInfo( + "fsck confirmation required", + "fsck stops file sharing, unmounts the selected HFS disk, and may reboot the device.", + ("Review the selected volume.", "Confirm fsck before retrying."), + retryable=True, + ), + ("fsck", "validation_failed"): RecoveryInfo( + "Volume selection failed", + "The helper could not choose a mounted HFS volume for fsck.", + ("Select a specific HFS volume.", "Refresh mounted volumes and retry."), + retryable=True, + ), + ("repair-xattrs", "confirmation_required"): RecoveryInfo( + "Repair confirmation required", + "repair-xattrs needs dry-run mode or explicit confirmation before changing local file metadata.", + ("Run a dry run first.", "Confirm repair before retrying."), + retryable=True, + ), + ("repair-xattrs", "validation_failed"): RecoveryInfo( + "repair-xattrs cannot run", + "repair-xattrs must run on macOS against a valid mounted SMB share path.", + ("Choose a mounted share path.", "Run this from macOS."), + retryable=True, + ), +} + + +_STAGE_RECOVERY: dict[tuple[str, str, str], RecoveryInfo] = { + ("configure", "remote_error", "acp_enable_ssh"): RecoveryInfo( + "ACP SSH enablement failed", + "The helper could not enable SSH through AirPort ACP.", + ("Verify the AirPort admin password.", "Power-cycle the device if AirPort Utility also cannot manage it."), + retryable=True, + suggested_operation="configure", + ), + ("configure", "remote_error", "wait_for_ssh_after_acp"): RecoveryInfo( + "SSH did not open", + "ACP accepted the request, but the SSH port did not become reachable in time.", + ("Wait for the device to finish rebooting.", "Retry configure with a longer SSH wait timeout."), + retryable=True, + suggested_operation="configure", + ), + ("deploy", "remote_error", "read_mast"): RecoveryInfo( + "No HFS volumes found", + "The device did not report a deployable HFS disk through MaSt.", + ("Wake the disk by opening it in Finder.", "Check the disk is installed and formatted HFS.", "Retry deploy."), + retryable=True, + suggested_operation="deploy", + ), + ("deploy", "remote_error", "select_payload_home"): RecoveryInfo( + "No writable payload volume", + "MaSt found HFS volumes, but none accepted the managed payload directory.", + ("Wake or remount the disk.", "Check available free space.", "Retry deploy."), + retryable=True, + suggested_operation="deploy", + ), + ("deploy", "remote_error", "verify_payload_upload"): RecoveryInfo( + "Payload verification failed", + "The uploaded managed payload could not be verified on the HFS disk.", + ("Wake the disk and retry.", "Check the operation log for the failing path."), + retryable=True, + suggested_operation="deploy", + ), + ("deploy", "remote_error", "verify_payload_upload_after_sync"): RecoveryInfo( + "Payload verification failed after sync", + "The managed payload was not stable after flushing disk writes.", + ("Retry deploy.", "Check the disk for write or corruption issues."), + retryable=True, + suggested_operation="deploy", + ), + ("deploy", "remote_error", "wait_for_reboot_down"): RecoveryInfo( + "Reboot did not start", + "The reboot request was sent, but SSH did not go down.", + ("Power-cycle the Time Capsule.", "Retry deploy after it is reachable."), + retryable=True, + suggested_operation="doctor", + ), + ("deploy", "remote_error", "wait_for_reboot_up"): RecoveryInfo( + "Reboot did not finish", + "The device went down but SSH did not return before the timeout.", + ("Wait a few more minutes.", "Power-cycle the device if needed.", "Run doctor once SSH returns."), + retryable=True, + suggested_operation="doctor", + ), + ("deploy", "remote_error", "verify_runtime_reboot"): RecoveryInfo( + "Runtime not ready", + "The device rebooted, but the managed Samba runtime did not become healthy.", + ("Run doctor for details.", "Check boot logs from the CLI if doctor still fails."), + retryable=True, + suggested_operation="doctor", + ), + ("deploy", "remote_error", "verify_runtime_activation"): RecoveryInfo( + "Activated runtime not ready", + "The NetBSD4 runtime was started but did not become healthy.", + ("Retry activation.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="doctor", + ), + ("uninstall", "remote_error", "verify_post_uninstall"): RecoveryInfo( + "Post-uninstall verification failed", + "Managed TimeCapsuleSMB files were still present after reboot.", + ("Retry uninstall.", "Run doctor if the device is reachable."), + retryable=True, + suggested_operation="uninstall", + ), + ("fsck", "validation_failed", "select_fsck_volume"): RecoveryInfo( + "Volume selection failed", + "The helper could not choose exactly one HFS volume for fsck.", + ("Select the target volume explicitly.", "Refresh mounted volumes and retry."), + retryable=True, + suggested_operation="fsck", + ), + ("repair-xattrs", "validation_failed", "platform_check"): RecoveryInfo( + "repair-xattrs requires macOS", + "repair-xattrs can only run on macOS because it uses xattr and chflags on a mounted SMB share.", + ("Run the app on macOS.", "Use dry run or repair from a mounted share path."), + retryable=False, + suggested_operation="repair-xattrs", + ), + ("repair-xattrs", "validation_failed", "validate_params"): RecoveryInfo( + "Invalid repair options", + "One or more repair-xattrs options were invalid.", + ("Review the repair options.", "Retry with valid values."), + retryable=True, + suggested_operation="repair-xattrs", + ), + ("repair-xattrs", "validation_failed", "resolve_scan_root"): RecoveryInfo( + "Path cannot be scanned", + "The selected path is not usable for repair-xattrs.", + ("Choose a mounted SMB share path.", "Confirm the share is accessible in Finder."), + retryable=True, + suggested_operation="repair-xattrs", + ), + ("repair-xattrs", "validation_failed", "scan_findings"): RecoveryInfo( + "Path cannot be scanned", + "repair-xattrs could not read the selected mounted share path.", + ("Choose a mounted SMB share path.", "Confirm the share is accessible in Finder."), + retryable=True, + suggested_operation="repair-xattrs", + ), +} + + +def recovery_for( + operation: str, + code: str, + *, + stage: str | None = None, +) -> dict[str, object]: + if stage: + policy = _STAGE_RECOVERY.get((operation, code, stage)) + if policy is not None: + return policy.to_jsonable() + policy = _OPERATION_CODE_RECOVERY.get((operation, code)) or _DEFAULTS.get(code) or _DEFAULTS["operation_failed"] + return policy.to_jsonable() diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index d434ab14..ca796394 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -4,22 +4,10 @@ from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.app.operations import ( - OPERATIONS, - AppOperationError, - OperationResult, - activate_operation, - configure_operation, - deploy_operation, - discover_operation, - doctor_operation, - fsck_operation, - paths_operation, - repair_xattrs_operation, - uninstall_operation, - validate_install_operation, -) +from timecapsulesmb.app.operations import OPERATIONS +from timecapsulesmb.app.recovery import recovery_for from timecapsulesmb.core.config import ConfigError +from timecapsulesmb.services.app import AppOperationError, OperationResult from timecapsulesmb.transport.errors import TransportError @@ -41,25 +29,58 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: operation = _request_operation(request) params = _request_params(request) if not operation: - sink.error("api", "missing required field: operation", code="invalid_request") + sink.error( + "api", + "missing required field: operation", + code="invalid_request", + recovery=recovery_for("api", "invalid_request"), + ) return 1 if not isinstance(params, dict): - sink.error(operation, "params must be a JSON object", code="invalid_request") + sink.error( + operation, + "params must be a JSON object", + code="invalid_request", + recovery=recovery_for(operation, "invalid_request"), + ) return 1 handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) if handler is None: - sink.error(operation, f"unknown operation: {operation}", code="unknown_operation", debug={"known_operations": sorted(OPERATIONS)}) + sink.error( + operation, + f"unknown operation: {operation}", + code="unknown_operation", + debug={"known_operations": sorted(OPERATIONS)}, + recovery=recovery_for(operation, "unknown_operation"), + ) return 1 try: result = handler(params, sink) except AppOperationError as exc: - sink.error(operation, str(exc), code=exc.code, debug=redact(exc.debug) if exc.debug is not None else None) + recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) + sink.error( + operation, + str(exc), + code=exc.code, + debug=redact(exc.debug) if exc.debug is not None else None, + recovery=recovery, + ) return 1 except ConfigError as exc: - sink.error(operation, str(exc), code="config_error") + sink.error( + operation, + str(exc), + code="config_error", + recovery=recovery_for(operation, "config_error", stage=sink.current_stage(operation)), + ) return 1 except TransportError as exc: - sink.error(operation, str(exc), code="remote_error") + sink.error( + operation, + str(exc), + code="remote_error", + recovery=recovery_for(operation, "remote_error", stage=sink.current_stage(operation)), + ) return 1 except (SystemExit, KeyboardInterrupt): raise @@ -69,6 +90,7 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: f"{type(exc).__name__}: {exc}", code="operation_failed", debug={"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))}, + recovery=recovery_for(operation, "operation_failed", stage=sink.current_stage(operation)), ) return 1 sink.result(operation, ok=result.ok, payload=result.payload) diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py new file mode 100644 index 00000000..263fc478 --- /dev/null +++ b/src/timecapsulesmb/app/stage_policy.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +LOCAL_READ = "local_read" +LOCAL_WRITE = "local_write" +REMOTE_READ = "remote_read" +REMOTE_WRITE = "remote_write" +DESTRUCTIVE = "destructive" +REBOOT = "reboot" + +RISK_VALUES = frozenset({ + LOCAL_READ, + LOCAL_WRITE, + REMOTE_READ, + REMOTE_WRITE, + DESTRUCTIVE, + REBOOT, +}) + + +@dataclass(frozen=True) +class StagePolicy: + risk: str + cancellable: bool + description: str + + def to_jsonable(self) -> dict[str, object]: + return { + "risk": self.risk, + "cancellable": self.cancellable, + "description": self.description, + } + + +_POLICIES: dict[tuple[str, str], StagePolicy] = { + ("discover", "bonjour_discovery"): StagePolicy(LOCAL_READ, True, "Browse for AirPort Bonjour services."), + ("paths", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve configuration, state, and distribution paths."), + ("paths", "summarize_artifacts"): StagePolicy(LOCAL_READ, True, "Summarize bundled artifact paths."), + ("validate-install", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve app installation paths."), + ("validate-install", "validate_install"): StagePolicy(LOCAL_READ, True, "Validate local helper and artifact prerequisites."), + ("configure", "load_existing_config"): StagePolicy(LOCAL_READ, True, "Read the existing .env configuration."), + ("configure", "ssh_probe"): StagePolicy(REMOTE_READ, True, "Probe SSH reachability and device compatibility."), + ("configure", "acp_enable_ssh"): StagePolicy(REMOTE_WRITE, False, "Request SSH enablement through AirPort ACP."), + ("configure", "wait_for_ssh_after_acp"): StagePolicy(REMOTE_READ, True, "Wait for SSH to open after ACP enablement."), + ("configure", "ssh_probe_after_acp"): StagePolicy(REMOTE_READ, True, "Probe SSH again after ACP enablement."), + ("configure", "write_env"): StagePolicy(LOCAL_WRITE, False, "Write the app .env configuration."), + ("deploy", "load_config"): StagePolicy(LOCAL_READ, True, "Read deployment configuration."), + ("deploy", "resolve_managed_target"): StagePolicy(REMOTE_READ, True, "Resolve and probe the managed Time Capsule target."), + ("deploy", "validate_artifacts"): StagePolicy(LOCAL_READ, True, "Validate bundled payload artifacts."), + ("deploy", "check_compatibility"): StagePolicy(REMOTE_READ, True, "Check detected device compatibility."), + ("deploy", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("deploy", "select_payload_home"): StagePolicy(REMOTE_READ, True, "Select a writable HFS payload location."), + ("deploy", "build_deployment_plan"): StagePolicy(LOCAL_READ, True, "Build the deployment action plan."), + ("deploy", "pre_upload_actions"): StagePolicy(REMOTE_WRITE, False, "Prepare remote directories and stop conflicting processes."), + ("deploy", "prepare_deployment_files"): StagePolicy(LOCAL_WRITE, True, "Generate temporary deployment config files."), + ("deploy", "upload_payload"): StagePolicy(REMOTE_WRITE, False, "Upload managed Samba payload files."), + ("deploy", "post_upload_actions"): StagePolicy(REMOTE_WRITE, False, "Install flash hooks and payload permissions."), + ("deploy", "verify_payload_upload"): StagePolicy(REMOTE_READ, True, "Verify uploaded payload files."), + ("deploy", "flush_payload_upload"): StagePolicy(REMOTE_WRITE, False, "Flush remote filesystem writes."), + ("deploy", "verify_payload_upload_after_sync"): StagePolicy(REMOTE_READ, True, "Verify uploaded payload files after sync."), + ("deploy", "netbsd4_activation"): StagePolicy(REMOTE_WRITE, False, "Start the deployed NetBSD4 runtime."), + ("deploy", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), + ("deploy", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), + ("deploy", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), + ("deploy", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after reboot."), + ("deploy", "verify_runtime_reboot"): StagePolicy(REMOTE_READ, True, "Wait for the managed runtime after reboot."), + ("doctor", "load_config"): StagePolicy(LOCAL_READ, True, "Read diagnostic configuration."), + ("doctor", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("doctor", "run_checks"): StagePolicy(REMOTE_READ, True, "Run local and remote diagnostic checks."), + ("activate", "load_config"): StagePolicy(LOCAL_READ, True, "Read activation configuration."), + ("activate", "resolve_managed_target"): StagePolicy(REMOTE_READ, True, "Resolve and probe the NetBSD4 target."), + ("activate", "build_activation_plan"): StagePolicy(LOCAL_READ, True, "Build the NetBSD4 activation action plan."), + ("activate", "probe_runtime"): StagePolicy(REMOTE_READ, True, "Check whether the NetBSD4 runtime is already ready."), + ("activate", "run_activation"): StagePolicy(REMOTE_WRITE, False, "Run NetBSD4 activation commands."), + ("activate", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), + ("uninstall", "load_config"): StagePolicy(LOCAL_READ, True, "Read uninstall configuration."), + ("uninstall", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("uninstall", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("uninstall", "mount_mast_volumes"): StagePolicy(REMOTE_WRITE, False, "Mount HFS volumes before uninstall."), + ("uninstall", "build_uninstall_plan"): StagePolicy(LOCAL_READ, True, "Build the uninstall action plan."), + ("uninstall", "uninstall_payload"): StagePolicy(DESTRUCTIVE, False, "Remove managed payload files and flash hooks."), + ("uninstall", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), + ("uninstall", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), + ("uninstall", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after reboot."), + ("uninstall", "verify_post_uninstall"): StagePolicy(REMOTE_READ, True, "Verify managed files are absent after reboot."), + ("fsck", "load_config"): StagePolicy(LOCAL_READ, True, "Read fsck configuration."), + ("fsck", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("fsck", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("fsck", "mount_hfs_volumes"): StagePolicy(REMOTE_WRITE, False, "Mount HFS volumes before fsck."), + ("fsck", "select_fsck_volume"): StagePolicy(REMOTE_READ, True, "Select the HFS volume to repair."), + ("fsck", "run_fsck"): StagePolicy(DESTRUCTIVE, False, "Unmount the selected disk and run fsck_hfs."), + ("fsck", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after fsck reboot."), + ("fsck", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after fsck reboot."), + ("repair-xattrs", "platform_check"): StagePolicy(LOCAL_READ, True, "Verify repair-xattrs is running on macOS."), + ("repair-xattrs", "validate_params"): StagePolicy(LOCAL_READ, True, "Validate repair-xattrs request parameters."), + ("repair-xattrs", "resolve_scan_root"): StagePolicy(LOCAL_READ, True, "Resolve the mounted SMB share scan root."), + ("repair-xattrs", "scan_findings"): StagePolicy(LOCAL_READ, True, "Scan local mounted SMB files for xattr problems."), + ("repair-xattrs", "report_findings"): StagePolicy(LOCAL_READ, True, "Render xattr findings and repair candidates."), + ("repair-xattrs", "confirm_repair"): StagePolicy(LOCAL_READ, True, "Confirm local metadata repairs."), + ("repair-xattrs", "repair_findings"): StagePolicy(DESTRUCTIVE, False, "Repair local file metadata on the mounted SMB share."), +} + + +def stage_policy(operation: str, stage: str) -> StagePolicy | None: + return _POLICIES.get((operation, stage)) diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 1e52a652..8be80836 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -15,16 +15,11 @@ require_supported_device_compatibility, ) from timecapsulesmb.core.config import ( - DEFAULTS, MANAGED_PAYLOAD_DIR_NAME, - AppConfig, airport_family_display_name_from_identity, - parse_bool, - shell_quote, ) from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP, NETBSD4_REBOOT_GUIDANCE from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts from timecapsulesmb.deploy.artifacts import validate_artifacts @@ -36,8 +31,6 @@ BINARY_NBNS_SOURCE, BINARY_SMBD_SOURCE, DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - DEFAULT_ATA_IDLE_SECONDS, - DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, GENERATED_FLASH_CONFIG_SOURCE, GENERATED_SMBPASSWD_SOURCE, GENERATED_USERNAME_MAP_SOURCE, @@ -55,66 +48,21 @@ from timecapsulesmb.device.storage import ( MAST_DISCOVERY_ATTEMPTS, MAST_DISCOVERY_DELAY_SECONDS, - PayloadHome, - PayloadVerificationResult, build_dry_run_payload_home, verify_payload_home_conn, ) +from timecapsulesmb.services.deploy import ( + DEPLOY_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) from timecapsulesmb.device.probe import read_interface_ipv4_addrs_conn from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.cli.util import color_green, color_red -REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) - - -def _no_mast_volumes_message(*, attempts: int, delay_seconds: int) -> str: - return ( - f"No deployable HFS disk was found after {attempts} MaSt queries " - f"spaced {delay_seconds} seconds apart." - ) - - -def _no_writable_mast_volumes_message(volume_count: int) -> str: - return f"MaSt found {volume_count} deployable HFS volume(s), but deploy could not write to any of them." - - -def _render_flash_config_assignment(key: str, value: str | int) -> str: - if isinstance(value, int): - return f"{key}={value}" - return f"{key}={shell_quote(value)}" - - -def render_flash_runtime_config( - config: AppConfig, - payload_home: PayloadHome, - *, - nbns_enabled: bool, - debug_logging: bool, - ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, - diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, -) -> str: - internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) - any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) - - values: list[tuple[str, str | int]] = [ - ("TC_CONFIG_VERSION", 2), - ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), - ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), - ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), - ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), - ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), - ("ATA_IDLE_SECONDS", ata_idle_seconds), - ("NBNS_ENABLED", 1 if nbns_enabled else 0), - ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), - ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), - ] - return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" - - def _target_family_display_name(target) -> str: probe = target.probe_state.probe_result if target.probe_state is not None else None return airport_family_display_name_from_identity( @@ -123,10 +71,6 @@ def _target_family_display_name(target) -> str: ) -def _payload_verification_error(payload_home: PayloadHome, result: PayloadVerificationResult) -> str: - return f"managed payload verification failed at {payload_home.payload_dir}: {result.detail}" - - def _non_negative_int(value: str) -> int: try: parsed = int(value) @@ -212,7 +156,7 @@ def main(argv: Optional[list[str]] = None) -> int: mast_volumes = mast_discovery.volumes if not mast_volumes: raise SystemExit( - _no_mast_volumes_message( + no_mast_volumes_message( attempts=MAST_DISCOVERY_ATTEMPTS, delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, ) @@ -224,7 +168,7 @@ def main(argv: Optional[list[str]] = None) -> int: wait_seconds=apple_mount_wait_seconds, ) if selection.payload_home is None: - raise SystemExit(_no_writable_mast_volumes_message(len(mast_volumes))) + raise SystemExit(no_writable_mast_volumes_message(len(mast_volumes))) payload_home = selection.payload_home command_context.set_stage("build_deployment_plan") plan = build_deployment_plan( @@ -318,7 +262,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.add_debug_fields(payload_upload_verification=payload_verification.detail) if not payload_verification.ok: - raise SystemExit(_payload_verification_error(payload_home, payload_verification)) + raise SystemExit(payload_verification_error(payload_home, payload_verification)) command_context.set_stage("flush_payload_upload") if not args.json: @@ -336,7 +280,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.add_debug_fields(payload_post_sync_verification=payload_verification.detail) if not payload_verification.ok: - raise SystemExit(_payload_verification_error(payload_home, payload_verification)) + raise SystemExit(payload_verification_error(payload_home, payload_verification)) print(f"Deployed Samba payload to {plan.payload_dir}") print("Updated /mnt/Flash boot files.") diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 09bb6860..177b52ff 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -11,6 +11,7 @@ from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.doctor import doctor_status_counts from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.core.paths import resolve_app_paths @@ -283,7 +284,7 @@ def main(argv: Optional[list[str]] = None) -> int: debug_fields=doctor_debug, ) command_context.add_debug_fields(**doctor_debug) - status_counts = {status: sum(1 for result in results if result.status == status) for status in ("PASS", "WARN", "FAIL", "INFO")} + status_counts = doctor_status_counts(results) command_context.update_fields( fatal=fatal, check_count=len(results), diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index c3aa3e0e..9957a258 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -12,11 +12,11 @@ from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.device.storage import MaStVolume +from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import run_ssh -FSCK_REBOOT_NO_DOWN_MESSAGE = "fsck requested reboot from the device, but SSH did not go down." FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" diff --git a/src/timecapsulesmb/cli/uninstall.py b/src/timecapsulesmb/cli/uninstall.py index 771d5cc7..3cf3b0a4 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -13,15 +13,10 @@ from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall from timecapsulesmb.device.storage import UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.maintenance import UNINSTALL_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE from timecapsulesmb.telemetry import TelemetryClient -REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." -) - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Remove the managed TimeCapsuleSMB payload from the configured device.") add_config_argument(parser) diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 1468a5ec..5194855e 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, is_dataclass +import math from pathlib import Path @@ -11,10 +12,12 @@ def __init__( *, code: str = "operation_failed", debug: object | None = None, + recovery: object | None = None, ) -> None: super().__init__(message) self.code = code self.debug = debug + self.recovery = recovery @dataclass(frozen=True) @@ -68,6 +71,36 @@ def int_param(params: dict[str, object], name: str, default: int) -> int: return parsed +def optional_int_param(params: dict[str, object], name: str) -> int | None: + value = params.get(name) + if value in (None, ""): + return None + if isinstance(value, bool): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") + return parsed + + +def float_param(params: dict[str, object], name: str, default: float) -> float: + value = params.get(name, default) + if isinstance(value, bool): + raise AppOperationError(f"{name} must be a number", code="validation_failed") + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be a number", code="validation_failed") from exc + if not math.isfinite(parsed): + raise AppOperationError(f"{name} must be finite", code="validation_failed") + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") + return parsed + + def string_param(params: dict[str, object], name: str, default: str = "") -> str: value = params.get(name, default) return "" if value is None else str(value) diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py new file mode 100644 index 00000000..65a086b2 --- /dev/null +++ b/src/timecapsulesmb/services/configure.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from timecapsulesmb.core.config import DEFAULTS, parse_bool, preserved_env_file_values + + +def build_configure_env_values( + existing: dict[str, str], + *, + host: str, + password: str, + ssh_opts: str, + configure_id: str, + internal_share_use_disk_root: bool | None = None, + any_protocol: bool | None = None, +) -> dict[str, str]: + values = preserved_env_file_values(existing) + values.update({ + "TC_HOST": host, + "TC_PASSWORD": password, + "TC_SSH_OPTS": ssh_opts, + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if ( + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])) + if internal_share_use_disk_root is None + else internal_share_use_disk_root + ) else "false", + "TC_ANY_PROTOCOL": "true" if ( + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])) + if any_protocol is None + else any_protocol + ) else "false", + "TC_CONFIGURE_ID": configure_id, + }) + return values diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py new file mode 100644 index 00000000..90bd9da2 --- /dev/null +++ b/src/timecapsulesmb/services/deploy.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, parse_bool, shell_quote +from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG +from timecapsulesmb.deploy.planner import DEFAULT_ATA_IDLE_SECONDS, DEFAULT_DISKD_USE_VOLUME_ATTEMPTS +from timecapsulesmb.device.storage import PayloadHome, PayloadVerificationResult + + +DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) + + +def no_mast_volumes_message(*, attempts: int, delay_seconds: int) -> str: + return ( + f"No deployable HFS disk was found after {attempts} MaSt queries " + f"spaced {delay_seconds} seconds apart." + ) + + +def no_writable_mast_volumes_message(volume_count: int) -> str: + return f"MaSt found {volume_count} deployable HFS volume(s), but deploy could not write to any of them." + + +def payload_verification_error(payload_home: PayloadHome, result: PayloadVerificationResult) -> str: + return f"managed payload verification failed at {payload_home.payload_dir}: {result.detail}" + + +def _render_flash_config_assignment(key: str, value: str | int) -> str: + if isinstance(value, int): + return f"{key}={value}" + return f"{key}={shell_quote(value)}" + + +def render_flash_runtime_config( + config: AppConfig, + payload_home: PayloadHome, + *, + nbns_enabled: bool, + debug_logging: bool, + ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, + diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, +) -> str: + internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) + any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) + + values: list[tuple[str, str | int]] = [ + ("TC_CONFIG_VERSION", 2), + ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), + ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), + ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), + ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), + ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), + ("ATA_IDLE_SECONDS", ata_idle_seconds), + ("NBNS_ENABLED", 1 if nbns_enabled else 0), + ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), + ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), + ] + return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" diff --git a/src/timecapsulesmb/services/doctor.py b/src/timecapsulesmb/services/doctor.py new file mode 100644 index 00000000..c8737d37 --- /dev/null +++ b/src/timecapsulesmb/services/doctor.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from timecapsulesmb.checks.models import CheckResult + + +def doctor_status_counts(results: list[CheckResult]) -> dict[str, int]: + return { + status: sum(1 for result in results if result.status == status) + for status in ("PASS", "WARN", "FAIL", "INFO") + } diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py new file mode 100644 index 00000000..cd260378 --- /dev/null +++ b/src/timecapsulesmb/services/maintenance.py @@ -0,0 +1,8 @@ +from __future__ import annotations + + +UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." +) +FSCK_REBOOT_NO_DOWN_MESSAGE = "fsck requested reboot from the device, but SSH did not go down." diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 5c754e10..f0d6a722 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -18,7 +18,7 @@ from timecapsulesmb.app.events import AppEvent, EventSink from timecapsulesmb import repair_xattrs as repair_xattrs_domain -from timecapsulesmb.app import helper, operations, service +from timecapsulesmb.app import contracts, helper, operations, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file @@ -148,6 +148,61 @@ def test_result_event_preserves_falsey_payloads(self) -> None: self.assertEqual(result["schema_version"], 1) self.assertTrue(result["request_id"]) + def test_stage_events_include_policy_metadata(self) -> None: + collector = CollectingSink() + + collector.sink.stage("paths", "resolve_paths") + collector.sink.stage("deploy", "upload_payload") + collector.sink.stage("uninstall", "uninstall_payload") + collector.sink.stage("deploy", "reboot") + + stages = collector.events_of_type("stage") + self.assertEqual(stages[0]["risk"], "local_read") + self.assertTrue(stages[0]["cancellable"]) + self.assertEqual(stages[1]["risk"], "remote_write") + self.assertEqual(stages[2]["risk"], "destructive") + self.assertEqual(stages[3]["risk"], "reboot") + self.assertIn("description", stages[3]) + + def test_contract_builders_keep_stable_representative_shapes(self) -> None: + deploy_plan = contracts.deploy_plan_payload( + {"host": "root@10.0.0.2", "reboot_required": True}, + payload_family="netbsd6_samba4", + netbsd4=False, + ) + self.assertEqual(deploy_plan, { + "host": "root@10.0.0.2", + "reboot_required": True, + "requires_reboot": True, + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "summary": "deployment dry-run plan generated.", + "schema_version": 1, + }) + + doctor = contracts.doctor_payload( + fatal=True, + results=[ + CheckResult("PASS", "ok"), + CheckResult("WARN", "slow"), + CheckResult("FAIL", "bad"), + ], + error="Doctor failures:\nFAIL bad", + ) + self.assertEqual(doctor["counts"], {"PASS": 1, "WARN": 1, "FAIL": 1, "INFO": 0}) + self.assertEqual(doctor["summary"], "doctor found one or more fatal problems.") + self.assertEqual(doctor["schema_version"], 1) + + repair = contracts.repair_xattrs_payload({ + "returncode": 0, + "root": "/Volumes/Data", + "finding_count": 2, + "repairable_count": 1, + "summary": {"scanned": 3}, + }) + self.assertEqual(repair["summary"], {"scanned": 3}) + self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + def test_request_id_propagates_to_every_event(self) -> None: collector = CollectingSink() @@ -176,6 +231,8 @@ def test_missing_operation_emits_invalid_request_error(self) -> None: error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["operation"], "api") self.assertEqual(error["code"], "invalid_request") + self.assertEqual(error["recovery"]["title"], "Invalid request") + self.assertTrue(error["recovery"]["retryable"]) def test_unknown_operation_emits_error_without_result(self) -> None: collector = CollectingSink() @@ -185,6 +242,7 @@ def test_unknown_operation_emits_error_without_result(self) -> None: self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], "unknown_operation") + self.assertEqual(error["recovery"]["title"], "Unknown operation") def test_non_object_params_emits_invalid_request_error(self) -> None: collector = CollectingSink() @@ -214,6 +272,7 @@ def fail(_params, _sink, exc=exception): self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], code) + self.assertIn("recovery", error) def test_dispatcher_includes_traceback_for_unexpected_errors(self) -> None: collector = CollectingSink() @@ -253,6 +312,38 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: result = collector.events_of_type("result")[0] self.assertEqual(result["payload"]["resolved"][0]["name"], "TC") self.assertEqual(result["payload"]["resolved"][0]["ipv4"], ["10.0.0.2"]) + self.assertEqual(result["payload"]["schema_version"], 1) + self.assertEqual(result["payload"]["counts"], {"instances": 1, "resolved": 1}) + self.assertEqual(result["payload"]["summary"], "discovered 1 resolved AirPort service(s).") + + def test_discover_rejects_invalid_timeout_values(self) -> None: + for timeout in ("bad", "nan", -1, True): + with self.subTest(timeout=timeout): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.operations.discover_snapshot") as discover: + rc = service.run_api_request( + {"operation": "discover", "params": {"timeout": timeout}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "Request validation failed") + discover.assert_not_called() + + def test_discover_accepts_numeric_timeout_string(self) -> None: + collector = CollectingSink() + snapshot = BonjourDiscoverySnapshot(instances=[], resolved=[]) + + with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot) as discover: + rc = service.run_api_request( + {"operation": "discover", "params": {"timeout": "0.25"}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + discover.assert_called_once_with(timeout=0.25) def test_configure_writes_env_without_leaking_password_to_events(self) -> None: collector = CollectingSink() @@ -331,6 +422,7 @@ def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: self.assertEqual(rc, 1) self.assertFalse(config_path.exists()) self.assertEqual(collector.events_of_type("error")[0]["code"], "auth_failed") + self.assertEqual(collector.events_of_type("error")[0]["recovery"]["suggested_operation"], "configure") self.assertNotIn("badpw", json.dumps(collector.events)) def test_configure_reports_unsupported_device(self) -> None: @@ -424,6 +516,9 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> result = collector.events_of_type("result")[0] self.assertEqual(result["payload"]["host"], "root@10.0.0.2") self.assertEqual(result["payload"]["reboot_required"], True) + self.assertEqual(result["payload"]["requires_reboot"], True) + self.assertEqual(result["payload"]["payload_family"], "netbsd6_samba4") + self.assertEqual(result["payload"]["schema_version"], 1) def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None: collector = CollectingSink() @@ -560,7 +655,9 @@ def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: ) self.assertEqual(rc, 1) - self.assertEqual(collector.events_of_type("error")[0]["code"], "remote_error") + error = collector.events_of_type("error")[0] + self.assertEqual(error["code"], "remote_error") + self.assertEqual(error["recovery"]["title"], "No HFS volumes found") def test_activate_requires_explicit_confirmation(self) -> None: collector = CollectingSink() @@ -598,7 +695,9 @@ def test_activate_accepts_yes_alias_for_confirmation(self) -> None: self.assertEqual(rc, 0) result = self.assert_single_terminal_event(collector, "result") - self.assertEqual(result["payload"], {"already_active": True}) + self.assertEqual(result["payload"]["already_active"], True) + self.assertEqual(result["payload"]["schema_version"], 1) + self.assertEqual(result["payload"]["summary"], "NetBSD4 payload was already active.") remote_actions.assert_not_called() def test_uninstall_requires_confirmation_before_remote_removal(self) -> None: @@ -644,6 +743,8 @@ def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: self.assertEqual(rc, 0) result = self.assert_single_terminal_event(collector, "result") self.assertIn("remote_actions", result["payload"]) + self.assertEqual(result["payload"]["requires_reboot"], True) + self.assertEqual(result["payload"]["schema_version"], 1) uninstall.assert_not_called() def test_fsck_requires_confirmation_before_remote_connection(self) -> None: @@ -718,6 +819,62 @@ def fake_runner(*_args, **_kwargs): self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + def test_repair_xattrs_rejects_invalid_max_depth_before_runner(self) -> None: + for max_depth in ("bad", -1, True): + with self.subTest(max_depth=max_depth): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": { + "path": "/Volumes/Data", + "dry_run": True, + "max_depth": max_depth, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "Invalid repair options") + runner.assert_not_called() + + def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": { + "path": "/Volumes/Data", + "dry_run": True, + "max_depth": "2", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + args = runner.call_args.args[0] + self.assertEqual(args.max_depth, 2) + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() @@ -733,6 +890,7 @@ def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], "confirmation_required") + self.assertEqual(error["recovery"]["title"], "Repair confirmation required") runner.assert_not_called() def test_helper_reads_request_and_writes_ndjson(self) -> None: From a10d8153d4afac903d8a4987f50f2d6dfceb1282 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 23:04:26 -0700 Subject: [PATCH 05/13] Persist debug logging configuration across configure and deploy --- .../TimeCapsuleSMBApp/ContentView.swift | 21 +++++-- .../PendingConfirmation.swift | 5 +- .../PendingConfirmationTests.swift | 3 +- src/timecapsulesmb/app/ops/configure.py | 5 ++ src/timecapsulesmb/cli/configure.py | 7 +++ src/timecapsulesmb/core/config.py | 5 ++ src/timecapsulesmb/services/configure.py | 6 ++ src/timecapsulesmb/services/deploy.py | 6 +- tests/test_app_api.py | 26 ++++++++ tests/test_cli.py | 61 +++++++++++++++++++ tests/test_config.py | 8 +++ tests/test_storage_runtime.py | 13 ++++ 12 files changed, 157 insertions(+), 9 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index f8e6eddc..d84cbd08 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -10,6 +10,8 @@ public struct ContentView: View { @State private var nbnsEnabled = true @State private var noReboot = false @State private var dryRun = true + @State private var configureDebugLogging = false + @State private var deployDebugLogging = false @State private var pendingConfirmation: PendingConfirmation? public init() {} @@ -88,13 +90,18 @@ public struct ContentView: View { CommandPanel(title: "Discover And Connect") { TextField("Host", text: $host) SecureField("Password", text: $password) + Toggle("Enable Debug Logging", isOn: $configureDebugLogging) HStack { runButton("Discover", icon: "network", operation: "discover") Button { - backend.run(operation: "configure", params: [ + var params: [String: JSONValue] = [ "host": .string(host), "password": .string(password) - ]) + ] + if configureDebugLogging { + params["debug_logging"] = .bool(true) + } + backend.run(operation: "configure", params: params) } label: { Label("Configure", systemImage: "lock.open") } @@ -106,15 +113,21 @@ public struct ContentView: View { Toggle("Enable NBNS", isOn: $nbnsEnabled) Toggle("No Reboot", isOn: $noReboot) Toggle("Dry Run", isOn: $dryRun) + Toggle("Force Debug Logging", isOn: $deployDebugLogging) Button { if dryRun { backend.run(operation: "deploy", params: [ "dry_run": .bool(true), "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled) + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(deployDebugLogging) ]) } else { - pendingConfirmation = .deploy(noReboot: noReboot, nbnsEnabled: nbnsEnabled) + pendingConfirmation = .deploy( + noReboot: noReboot, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging + ) } } label: { Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 09bbb35f..3ce7286d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -8,7 +8,7 @@ struct PendingConfirmation: Identifiable { let operation: String let params: [String: JSONValue] - static func deploy(noReboot: Bool, nbnsEnabled: Bool) -> PendingConfirmation { + static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool) -> PendingConfirmation { PendingConfirmation( title: noReboot ? "Deploy Without Reboot?" : "Deploy And Reboot?", message: noReboot @@ -22,7 +22,8 @@ struct PendingConfirmation: Identifiable { "confirm_reboot": .bool(!noReboot), "confirm_netbsd4_activation": .bool(true), "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled) + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging) ] ) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 2861050b..ab8bb8f7 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -3,7 +3,7 @@ import XCTest final class PendingConfirmationTests: XCTestCase { func testDeployConfirmationCarriesDeployAndRebootConsent() { - let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true) + let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true) XCTAssertEqual(confirmation.operation, "deploy") XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) @@ -12,6 +12,7 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.params["confirm_netbsd4_activation"], .bool(true)) XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) + XCTAssertEqual(confirmation.params["debug_logging"], .bool(true)) } func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index d8ec9600..5e9b7b54 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -65,6 +65,11 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation "any_protocol", parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), ), + debug_logging=bool_param( + params, + "debug_logging", + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), + ), ) sink.stage(operation, "ssh_probe") diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index ea381c15..f4b10037 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -297,6 +297,7 @@ def main(argv: Optional[list[str]] = None) -> int: add_config_argument(parser) parser.add_argument("--internal-share-use-disk-root", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--any-protocol", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args(argv) ensure_install_id() @@ -357,6 +358,12 @@ def main(argv: Optional[list[str]] = None) -> int: values["TC_ANY_PROTOCOL"] = ( "true" if args.any_protocol or existing_any_protocol else "false" ) + existing_debug_logging = parse_bool( + existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + ) + values["TC_DEBUG_LOGGING"] = ( + "true" if args.debug_logging or existing_debug_logging else "false" + ) command_context.set_stage("bonjour_discovery") try: discovered_record = discover_default_record(existing) diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 8f3d612b..037a1a97 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -75,6 +75,7 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_SSH_OPTS": "-o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -o KexAlgorithms=+diffie-hellman-group14-sha1 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", "TC_INTERNAL_SHARE_USE_DISK_ROOT": "false", "TC_ANY_PROTOCOL": "false", + "TC_DEBUG_LOGGING": "false", } ENV_FILE_KEYS = [ @@ -83,6 +84,7 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_SSH_OPTS", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", "TC_CONFIGURE_ID", ] ENV_FILE_OMIT_KEYS = frozenset({ @@ -509,6 +511,7 @@ def validate_mdns_device_model_matches_syap(syap: str, device_model: str) -> Opt "TC_MDNS_DEVICE_MODEL": validate_mdns_device_model, "TC_INTERNAL_SHARE_USE_DISK_ROOT": validate_bool, "TC_ANY_PROTOCOL": validate_bool, + "TC_DEBUG_LOGGING": validate_bool, } @@ -524,11 +527,13 @@ class ConfigProfile: CONFIGURE_VALIDATED_KEYS = ( "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", ) MANAGED_VALIDATED_KEYS = ( "TC_HOST", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", ) MANAGED_REQUIRED_FILE_KEYS = ( "TC_HOST", diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py index 65a086b2..db652613 100644 --- a/src/timecapsulesmb/services/configure.py +++ b/src/timecapsulesmb/services/configure.py @@ -12,6 +12,7 @@ def build_configure_env_values( configure_id: str, internal_share_use_disk_root: bool | None = None, any_protocol: bool | None = None, + debug_logging: bool | None = None, ) -> dict[str, str]: values = preserved_env_file_values(existing) values.update({ @@ -28,6 +29,11 @@ def build_configure_env_values( if any_protocol is None else any_protocol ) else "false", + "TC_DEBUG_LOGGING": "true" if ( + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])) + if debug_logging is None + else debug_logging + ) else "false", "TC_CONFIGURE_ID": configure_id, }) return values diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py index 90bd9da2..129b3ec6 100644 --- a/src/timecapsulesmb/services/deploy.py +++ b/src/timecapsulesmb/services/deploy.py @@ -45,6 +45,8 @@ def render_flash_runtime_config( ) -> str: internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) + configured_debug_logging = config.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + effective_debug_logging = debug_logging or parse_bool(configured_debug_logging) values: list[tuple[str, str | int]] = [ ("TC_CONFIG_VERSION", 2), @@ -55,7 +57,7 @@ def render_flash_runtime_config( ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), ("ATA_IDLE_SECONDS", ata_idle_seconds), ("NBNS_ENABLED", 1 if nbns_enabled else 0), - ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), - ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), + ("SMBD_DEBUG_LOGGING", 1 if effective_debug_logging else 0), + ("MDNS_DEBUG_LOGGING", 1 if effective_debug_logging else 0), ] return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" diff --git a/tests/test_app_api.py b/tests/test_app_api.py index f0d6a722..ede92e80 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -365,6 +365,7 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: self.assertEqual(rc, 0) self.assertIn("TC_HOST=root@10.0.0.2", config_path.read_text()) self.assertIn("TC_PASSWORD=goodpw", config_path.read_text()) + self.assertIn("TC_DEBUG_LOGGING=false", config_path.read_text()) serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) @@ -376,6 +377,7 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s "TC_HOST=root@10.0.0.1\n" "TC_PASSWORD=oldpw\n" "TC_CUSTOM_SETTING='keep me'\n" + "TC_DEBUG_LOGGING=true\n" "TC_SAMBA_USER=old-admin\n" "TC_PAYLOAD_DIR_NAME=old-payload\n" ) @@ -398,9 +400,33 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s self.assertEqual(values["TC_HOST"], "root@10.0.0.2") self.assertEqual(values["TC_PASSWORD"], "newpw") self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") + self.assertEqual(values["TC_DEBUG_LOGGING"], "true") self.assertNotIn("TC_SAMBA_USER", values) self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) + def test_configure_debug_logging_param_writes_true(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "debug_logging": True, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_DEBUG_LOGGING"], "true") + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_cli.py b/tests/test_cli.py index 57d7e642..e234c0d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1467,6 +1467,7 @@ def test_configure_writes_values_from_prompts(self) -> None: self.assertNotIn("TC_NET_IFACE", fake_values) self.assertEqual(fake_values["TC_INTERNAL_SHARE_USE_DISK_ROOT"], "false") self.assertEqual(fake_values["TC_ANY_PROTOCOL"], "false") + self.assertEqual(fake_values["TC_DEBUG_LOGGING"], "false") uuid.UUID(fake_values["TC_CONFIGURE_ID"]) telemetry_values = result.mocks.telemetry_factory.call_args.args[0].values self.assertEqual(telemetry_values["TC_CONFIGURE_ID"], fake_values["TC_CONFIGURE_ID"]) @@ -1545,6 +1546,28 @@ def test_configure_hidden_any_protocol_arg_writes_true(self) -> None: self.assertEqual(result.rc, 0) self.assertEqual(result.values["TC_ANY_PROTOCOL"], "true") + def test_configure_hidden_debug_logging_arg_writes_true(self) -> None: + result = self.run_configure_cli( + ["--debug-logging"], + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + + def test_configure_preserves_existing_debug_logging_when_arg_is_omitted(self) -> None: + result = self.run_configure_cli( + existing_values={"TC_DEBUG_LOGGING": "true"}, + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + def test_configure_airport_extreme_keeps_hidden_internal_share_root_default(self) -> None: def fake_prompt(label, default, _secret): if label == "Device SSH target": @@ -1576,6 +1599,7 @@ def fake_prompt(label, default, _secret): self.assertNotIn("TC_MDNS_DEVICE_MODEL", result.values) self.assertEqual(result.values["TC_INTERNAL_SHARE_USE_DISK_ROOT"], "false") self.assertEqual(result.values["TC_ANY_PROTOCOL"], "false") + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "false") def test_configure_ensures_install_id_before_telemetry(self) -> None: prompt_values = iter([ @@ -4433,6 +4457,7 @@ def fake_upload(_plan, *, connection, source_resolver): self.assertIn("NBNS_ENABLED=1\n", flash_config) self.assertIn("ANY_PROTOCOL=0\n", flash_config) self.assertIn("SMBD_DEBUG_LOGGING=1\n", flash_config) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", flash_config) self.assertNotIn("SMB_SAMBA_USER", flash_config) self.assertNotIn("MDNS_DEVICE_MODEL", flash_config) self.assertNotIn("AIRPORT_SYAP", flash_config) @@ -4459,6 +4484,42 @@ def fake_upload(_plan, *, connection, source_resolver): finished = self.telemetry_payload("deploy_finished") self.assertFalse(finished["nbns_enabled"]) + def test_deploy_uses_configured_debug_logging_without_deploy_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--no-reboot"], + values=self.make_valid_env(TC_DEBUG_LOGGING="true"), + patch_actions=True, + patch_upload=True, + upload_side_effect=fake_upload, + ) + + self.assertEqual(result.rc, 0) + self.assertIn("SMBD_DEBUG_LOGGING=1\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", captured["flash_config"]) + + def test_deploy_leaves_debug_logging_disabled_without_config_or_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--no-reboot"], + values=self.make_valid_env(TC_DEBUG_LOGGING="false"), + patch_actions=True, + patch_upload=True, + upload_side_effect=fake_upload, + ) + + self.assertEqual(result.rc, 0) + self.assertIn("SMBD_DEBUG_LOGGING=0\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=0\n", captured["flash_config"]) + def test_deploy_rejects_removed_install_nbns_flag(self) -> None: stderr = io.StringIO() with redirect_stderr(stderr): diff --git a/tests/test_config.py b/tests/test_config.py index 74c6c9e8..f310863c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -147,6 +147,7 @@ def test_render_env_text_contains_config_keys(self) -> None: self.assertNotIn("TC_PAYLOAD_DIR_NAME", rendered) self.assertIn("TC_INTERNAL_SHARE_USE_DISK_ROOT=false", rendered) self.assertIn("TC_ANY_PROTOCOL=false", rendered) + self.assertIn("TC_DEBUG_LOGGING=false", rendered) self.assertIn("TC_CONFIGURE_ID=12345678-1234-1234-1234-123456789012", rendered) def test_render_env_text_preserves_custom_settings_but_omits_deprecated_keys(self) -> None: @@ -509,6 +510,12 @@ def test_validate_app_config_uses_profiles(self) -> None: errors = validate_app_config(config, profile="deploy") self.assertEqual(errors[0].kind, "invalid_value") self.assertEqual(errors[0].key, "TC_ANY_PROTOCOL") + values["TC_ANY_PROTOCOL"] = "false" + values["TC_DEBUG_LOGGING"] = "not-bool" + config = AppConfig.from_values(values, file_values=values) + errors = validate_app_config(config, profile="deploy") + self.assertEqual(errors[0].kind, "invalid_value") + self.assertEqual(errors[0].key, "TC_DEBUG_LOGGING") def test_flash_profile_ignores_deploy_only_settings(self) -> None: values = dict(DEFAULTS) @@ -521,6 +528,7 @@ def test_flash_profile_ignores_deploy_only_settings(self) -> None: values["TC_PAYLOAD_DIR_NAME"] = "/bad" values["TC_INTERNAL_SHARE_USE_DISK_ROOT"] = "not-bool" values["TC_ANY_PROTOCOL"] = "not-bool" + values["TC_DEBUG_LOGGING"] = "not-bool" config = AppConfig.from_values(values, file_values=values) self.assertEqual(validate_app_config(config, profile="flash"), []) diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index 2b221d96..cc6198aa 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -896,6 +896,19 @@ def test_flash_runtime_config_contains_runtime_settings_and_no_share_name(self) self.assertNotIn("MDNS_HOST_LABEL", rendered) self.assertNotIn("TC_SHARE_NAME", rendered) + def test_flash_runtime_config_uses_saved_debug_logging(self) -> None: + config = AppConfig.from_values({"TC_DEBUG_LOGGING": "true"}) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + ) + + self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", rendered) + def test_common_runtime_identity_normalizers_match_python(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) From d1566023f29c7f82b7e362adbac1ba8381e6a8a8 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 00:01:25 -0700 Subject: [PATCH 06/13] Add more consistent arg options --- .../TimeCapsuleSMBApp/ContentView.swift | 67 +++- .../PendingConfirmation.swift | 34 +- .../PendingConfirmationTests.swift | 12 +- src/timecapsulesmb/app/contracts.py | 51 ++- src/timecapsulesmb/app/operations.py | 11 +- src/timecapsulesmb/app/ops/deploy.py | 80 ++++- src/timecapsulesmb/app/ops/doctor.py | 5 +- src/timecapsulesmb/app/ops/maintenance.py | 141 ++++---- src/timecapsulesmb/checks/doctor.py | 3 + src/timecapsulesmb/checks/doctor_state.py | 1 + src/timecapsulesmb/checks/doctor_steps.py | 3 + src/timecapsulesmb/cli/activate.py | 13 +- src/timecapsulesmb/cli/configure.py | 12 +- src/timecapsulesmb/cli/deploy.py | 31 +- src/timecapsulesmb/cli/doctor.py | 5 +- src/timecapsulesmb/cli/flash.py | 10 +- src/timecapsulesmb/cli/flows.py | 60 +++- src/timecapsulesmb/cli/fsck.py | 96 ++---- src/timecapsulesmb/cli/repair_xattrs.py | 60 ++++ src/timecapsulesmb/cli/runtime.py | 47 +++ src/timecapsulesmb/cli/set_ssh.py | 86 ++++- src/timecapsulesmb/cli/uninstall.py | 17 +- src/timecapsulesmb/core/config.py | 4 + src/timecapsulesmb/deploy/dry_run.py | 6 + src/timecapsulesmb/services/app.py | 36 +- src/timecapsulesmb/services/maintenance.py | 152 +++++++++ tests/test_app_api.py | 220 +++++++++++- tests/test_cli.py | 320 +++++++++++++++++- 28 files changed, 1349 insertions(+), 234 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index d84cbd08..48fa7c2d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -12,6 +12,9 @@ public struct ContentView: View { @State private var dryRun = true @State private var configureDebugLogging = false @State private var deployDebugLogging = false + @State private var mountWait = "30" + @State private var bonjourTimeout = "6" + @State private var noWait = false @State private var pendingConfirmation: PendingConfirmation? public init() {} @@ -90,9 +93,12 @@ public struct ContentView: View { CommandPanel(title: "Discover And Connect") { TextField("Host", text: $host) SecureField("Password", text: $password) + TextField("Bonjour timeout seconds", text: $bonjourTimeout) Toggle("Enable Debug Logging", isOn: $configureDebugLogging) HStack { - runButton("Discover", icon: "network", operation: "discover") + runButton("Discover", icon: "network", operation: "discover", params: [ + "timeout": numberValue(bonjourTimeout, default: 6) + ]) Button { var params: [String: JSONValue] = [ "host": .string(host), @@ -112,21 +118,27 @@ public struct ContentView: View { CommandPanel(title: "Deploy") { Toggle("Enable NBNS", isOn: $nbnsEnabled) Toggle("No Reboot", isOn: $noReboot) + Toggle("No Wait", isOn: $noWait) Toggle("Dry Run", isOn: $dryRun) Toggle("Force Debug Logging", isOn: $deployDebugLogging) + TextField("Mount wait seconds", text: $mountWait) Button { if dryRun { backend.run(operation: "deploy", params: [ "dry_run": .bool(true), "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(deployDebugLogging) + "debug_logging": .bool(deployDebugLogging), + "mount_wait": numberValue(mountWait, default: 30) ]) } else { pendingConfirmation = .deploy( noReboot: noReboot, nbnsEnabled: nbnsEnabled, - debugLogging: deployDebugLogging + debugLogging: deployDebugLogging, + mountWait: numberDouble(mountWait, default: 30), + noWait: noWait ) } } label: { @@ -136,13 +148,18 @@ public struct ContentView: View { } case .doctor: CommandPanel(title: "Doctor") { - runButton("Run Doctor", icon: "stethoscope", operation: "doctor") + TextField("Bonjour timeout seconds", text: $bonjourTimeout) + runButton("Run Doctor", icon: "stethoscope", operation: "doctor", params: [ + "bonjour_timeout": numberValue(bonjourTimeout, default: 6) + ]) } case .maintenance: CommandPanel(title: "Maintenance") { TextField("Repair xattrs path", text: $repairPath) TextField("fsck volume, optional", text: $volume) + TextField("Mount wait seconds", text: $mountWait) Toggle("No Reboot", isOn: $noReboot) + Toggle("No Wait", isOn: $noWait) HStack { Button { pendingConfirmation = .activate() @@ -150,21 +167,48 @@ public struct ContentView: View { Label("Activate", systemImage: "power") } .disabled(backend.isRunning) - runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: ["dry_run": .bool(true)]) + runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": numberValue(mountWait, default: 30) + ]) Button { - pendingConfirmation = .uninstall(noReboot: noReboot) + pendingConfirmation = .uninstall( + noReboot: noReboot, + mountWait: numberDouble(mountWait, default: 30), + noWait: noWait + ) } label: { Label("Uninstall", systemImage: "xmark.bin.fill") } .disabled(backend.isRunning) } HStack { + runButton("List fsck Volumes", icon: "list.bullet.rectangle", operation: "fsck", params: [ + "list_volumes": .bool(true), + "mount_wait": numberValue(mountWait, default: 30) + ]) + runButton("Plan fsck", icon: "doc.text.magnifyingglass", operation: "fsck", params: [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": numberValue(mountWait, default: 30), + "volume": .string(volume) + ]) Button { - pendingConfirmation = .fsck(volume: volume, noReboot: noReboot) + pendingConfirmation = .fsck( + volume: volume, + noReboot: noReboot, + mountWait: numberDouble(mountWait, default: 30), + noWait: noWait + ) } label: { Label("Run fsck", systemImage: "externaldrive.badge.checkmark") } .disabled(backend.isRunning) + } + HStack { Button { backend.run(operation: "repair-xattrs", params: [ "path": .string(repairPath), @@ -205,6 +249,15 @@ public struct ContentView: View { } .disabled(backend.isRunning) } + + private func numberDouble(_ text: String, default defaultValue: Double) -> Double { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) ?? defaultValue + } + + private func numberValue(_ text: String, default defaultValue: Double) -> JSONValue { + .number(numberDouble(text, default: defaultValue)) + } } private enum Screen: String, CaseIterable, Identifiable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 3ce7286d..7a777460 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -8,12 +8,14 @@ struct PendingConfirmation: Identifiable { let operation: String let params: [String: JSONValue] - static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool) -> PendingConfirmation { + static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Deploy Without Reboot?" : "Deploy And Reboot?", + title: noReboot ? "Deploy Without Reboot?" : (noWait ? "Deploy And Skip Waiting?" : "Deploy And Reboot?"), message: noReboot ? "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device." - : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately.", + : (noWait + ? "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device." + : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."), actionTitle: noReboot ? "Deploy" : "Deploy And Allow Reboot", operation: "deploy", params: [ @@ -23,7 +25,9 @@ struct PendingConfirmation: Identifiable { "confirm_netbsd4_activation": .bool(true), "no_reboot": .bool(noReboot), "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(debugLogging) + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait), + "no_wait": .bool(noWait) ] ) } @@ -38,35 +42,43 @@ struct PendingConfirmation: Identifiable { ) } - static func fsck(volume: String, noReboot: Bool) -> PendingConfirmation { + static func fsck(volume: String, noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Run Disk Repair Without Reboot?" : "Run Disk Repair And Reboot?", + title: noReboot ? "Run Disk Repair Without Reboot?" : (noWait ? "Run Disk Repair And Skip Waiting?" : "Run Disk Repair And Reboot?"), message: noReboot ? "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward." - : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot.", + : (noWait + ? "This will run fsck on the selected Time Capsule disk and return after requesting reboot." + : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."), actionTitle: "Run fsck", operation: "fsck", params: [ "confirm_fsck": .bool(true), "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), "volume": .string(volume) ] ) } - static func uninstall(noReboot: Bool) -> PendingConfirmation { + static func uninstall(noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Uninstall Without Reboot?" : "Uninstall And Reboot?", + title: noReboot ? "Uninstall Without Reboot?" : (noWait ? "Uninstall And Skip Waiting?" : "Uninstall And Reboot?"), message: noReboot ? "This will remove the managed TimeCapsuleSMB payload without rebooting the device." - : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot.", + : (noWait + ? "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting." + : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."), actionTitle: "Uninstall", operation: "uninstall", params: [ "dry_run": .bool(false), "confirm_uninstall": .bool(true), "confirm_reboot": .bool(!noReboot), - "no_reboot": .bool(noReboot) + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) ] ) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index ab8bb8f7..ec07cb43 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -3,7 +3,7 @@ import XCTest final class PendingConfirmationTests: XCTestCase { func testDeployConfirmationCarriesDeployAndRebootConsent() { - let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true) + let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true, mountWait: 45, noWait: true) XCTAssertEqual(confirmation.operation, "deploy") XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) @@ -13,25 +13,31 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) XCTAssertEqual(confirmation.params["debug_logging"], .bool(true)) + XCTAssertEqual(confirmation.params["mount_wait"], .number(45)) + XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) } func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { - let confirmation = PendingConfirmation.uninstall(noReboot: true) + let confirmation = PendingConfirmation.uninstall(noReboot: true, mountWait: 12, noWait: true) XCTAssertEqual(confirmation.operation, "uninstall") XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) XCTAssertEqual(confirmation.params["confirm_uninstall"], .bool(true)) XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(false)) XCTAssertEqual(confirmation.params["no_reboot"], .bool(true)) + XCTAssertEqual(confirmation.params["mount_wait"], .number(12)) + XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) } func testMaintenanceConfirmationsCarryExplicitOperationConsent() { - let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true) + let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true, mountWait: 18, noWait: true) let repair = PendingConfirmation.repairXattrs(path: "/Volumes/Data") XCTAssertEqual(fsck.operation, "fsck") XCTAssertEqual(fsck.params["confirm_fsck"], .bool(true)) XCTAssertEqual(fsck.params["no_reboot"], .bool(true)) + XCTAssertEqual(fsck.params["mount_wait"], .number(18)) + XCTAssertEqual(fsck.params["no_wait"], .bool(true)) XCTAssertEqual(fsck.params["volume"], .string("Data")) XCTAssertEqual(repair.operation, "repair-xattrs") diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index ba8b4221..f70c319d 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -103,6 +103,9 @@ def deploy_result_payload( *, payload_dir: str, rebooted: bool | None = None, + reboot_requested: bool | None = None, + waited: bool | None = None, + verified: bool | None = None, netbsd4: bool = False, message: str | None = None, payload_family: str | None = None, @@ -111,11 +114,17 @@ def deploy_result_payload( "payload_dir": payload_dir, "netbsd4": netbsd4, "payload_family": payload_family, - "requires_reboot": False if netbsd4 else bool(rebooted), + "requires_reboot": False if netbsd4 else bool(rebooted or reboot_requested), "summary": "deployment completed.", } if rebooted is not None: payload["rebooted"] = rebooted + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + if verified is not None: + payload["verified"] = verified if message is not None: payload["message"] = message payload["summary"] = message @@ -158,12 +167,40 @@ def uninstall_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: }) -def uninstall_result_payload(*, rebooted: bool, verified: bool) -> dict[str, object]: - return _with_schema({ +def uninstall_result_payload( + *, + rebooted: bool, + verified: bool, + reboot_requested: bool | None = None, + waited: bool | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { "rebooted": rebooted, "verified": verified, - "requires_reboot": rebooted, + "requires_reboot": bool(rebooted or reboot_requested), "summary": "uninstall completed." if verified else "uninstall completed without post-reboot verification.", + } + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + return _with_schema(payload) + + +def fsck_volume_list_payload(raw: Mapping[str, object]) -> dict[str, object]: + targets = raw.get("targets") + target_count = len(targets) if isinstance(targets, list) else 0 + return _with_schema({ + **raw, + "counts": {"targets": target_count}, + "summary": f"found {target_count} mounted HFS volume(s).", + }) + + +def fsck_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + return _with_schema({ + **raw, + "summary": "fsck dry-run plan generated.", }) @@ -172,7 +209,9 @@ def fsck_result_payload( device: str, mountpoint: str, returncode: int | None = None, + reboot_requested: bool | None = None, waited: bool | None = None, + verified: bool | None = None, ) -> dict[str, object]: payload: dict[str, object] = { "device": device, @@ -181,8 +220,12 @@ def fsck_result_payload( } if returncode is not None: payload["returncode"] = returncode + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested if waited is not None: payload["waited"] = waited + if verified is not None: + payload["verified"] = verified return _with_schema(payload) diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py index 95b346ce..ab90ec31 100644 --- a/src/timecapsulesmb/app/operations.py +++ b/src/timecapsulesmb/app/operations.py @@ -48,6 +48,9 @@ resolve_env_connection = _maintenance.resolve_env_connection remote_uninstall_payload = _maintenance.remote_uninstall_payload +read_mast_volumes_conn = _maintenance.read_mast_volumes_conn +mounted_mast_volumes_conn = _maintenance.mounted_mast_volumes_conn +run_ssh = _maintenance.run_ssh probe_managed_runtime_conn = _maintenance.probe_managed_runtime_conn load_optional_env_config = _maintenance.load_optional_env_config repair_xattrs_cli = _maintenance.repair_xattrs_cli @@ -80,6 +83,10 @@ def _sync_compat_bindings() -> None: _maintenance.load_env_config = load_env_config _maintenance.resolve_env_connection = resolve_env_connection _maintenance.remote_uninstall_payload = remote_uninstall_payload + _maintenance.read_mast_volumes_conn = read_mast_volumes_conn + _maintenance.mounted_mast_volumes_conn = mounted_mast_volumes_conn + _maintenance.run_ssh = run_ssh + _maintenance.wait_for_ssh_state_conn = wait_for_ssh_state_conn _maintenance.run_remote_actions = run_remote_actions _maintenance.probe_managed_runtime_conn = probe_managed_runtime_conn _maintenance.load_optional_env_config = load_optional_env_config @@ -153,8 +160,8 @@ def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationRes _request_reboot_and_wait = _deploy.request_reboot_and_wait _request_ssh_reboot = _deploy.request_ssh_reboot _observe_reboot_cycle = _maintenance.observe_reboot_cycle -_RepairContext = _maintenance.RepairContext -_StreamLogCapture = _maintenance.StreamLogCapture +_RepairContext = _maintenance.RepairExecutionContext +_StreamLogCapture = _maintenance.LineLogCapture OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 7c7a7a13..7757082d 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -121,6 +121,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes nbns_enabled = bool_param(params, "nbns_enabled", True) dry_run = bool_param(params, "dry_run") no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") confirm_deploy = confirm_param(params, "confirm_deploy") confirm_reboot = confirm_param(params, "confirm_reboot") confirm_netbsd4_activation = confirm_param(params, "confirm_netbsd4_activation") @@ -260,6 +261,9 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, netbsd4=True, + reboot_requested=False, + waited=False, + verified=True, message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", payload_family=payload_family, )) @@ -268,6 +272,25 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, rebooted=False, + reboot_requested=False, + waited=False, + verified=False, + payload_family=payload_family, + )) + + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + require_request_success=True, + ) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + reboot_requested=True, + waited=False, + verified=False, payload_family=payload_family, )) @@ -282,6 +305,9 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, rebooted=True, + reboot_requested=True, + waited=True, + verified=True, payload_family=payload_family, )) @@ -334,16 +360,7 @@ def request_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> None: - sink.stage(operation, "reboot") - if strategy == "acp_then_ssh": - try: - acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) - sink.log(operation, "ACP reboot requested.") - except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") - request_ssh_reboot(operation, sink, connection, shutdown=False) - else: - request_ssh_reboot(operation, sink, connection, shutdown=True) + request_reboot(operation, sink, connection, strategy=strategy) sink.stage(operation, "wait_for_reboot_down") sink.log(operation, "Waiting for the device to go down...") @@ -356,7 +373,46 @@ def request_reboot_and_wait( sink.log(operation, "Device is back online.") -def request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: +def request_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + require_request_success: bool = False, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + request_ssh_reboot( + operation, + sink, + connection, + shutdown=False, + require_request_success=require_request_success, + ) + else: + request_ssh_reboot( + operation, + sink, + connection, + shutdown=True, + require_request_success=require_request_success, + ) + + +def request_ssh_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + shutdown: bool, + require_request_success: bool = False, +) -> None: try: if shutdown: remote_request_shutdown_reboot(connection) @@ -366,6 +422,8 @@ def request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnectio sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") return except SshError as exc: + if require_request_success: + raise AppOperationError(f"SSH reboot request failed: {exc}", code="remote_error") from exc sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") return sink.log(operation, "SSH reboot requested.") diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py index 184be906..0996e41c 100644 --- a/src/timecapsulesmb/app/ops/doctor.py +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -7,11 +7,13 @@ from timecapsulesmb.cli.doctor import build_doctor_error from timecapsulesmb.cli.runtime import load_env_config, resolve_env_connection from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.services.app import OperationResult, bool_param, config_path +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC +from timecapsulesmb.services.app import OperationResult, bool_param, config_path, float_param def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "doctor" + bonjour_timeout = float_param(params, "bonjour_timeout", DEFAULT_BROWSE_TIMEOUT_SEC) sink.stage(operation, "load_config") config = load_env_config(env_path=config_path(params)) app_paths = resolve_app_paths(config_path=config_path(params)) @@ -32,6 +34,7 @@ def on_result(result: CheckResult) -> None: skip_ssh=bool_param(params, "skip_ssh"), skip_bonjour=bool_param(params, "skip_bonjour"), skip_smb=bool_param(params, "skip_smb"), + bonjour_timeout=bonjour_timeout, on_result=on_result, debug_fields=debug_fields, ) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 9799f3b2..a5966a29 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -9,7 +9,9 @@ from timecapsulesmb.app.contracts import ( activation_plan_payload, activation_result_payload, + fsck_plan_payload, fsck_result_payload, + fsck_volume_list_payload, repair_xattrs_payload, uninstall_plan_payload, uninstall_result_payload, @@ -17,6 +19,7 @@ from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.deploy import ( load_config_and_target, + request_reboot, request_reboot_and_wait, require_supported_payload, verify_runtime, @@ -25,14 +28,12 @@ from timecapsulesmb.cli.fsck import ( FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, build_remote_fsck_script, - select_fsck_target, - _target_from_volume, ) from timecapsulesmb.cli.runtime import load_env_config, load_optional_env_config, resolve_env_connection from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP -from timecapsulesmb.deploy.dry_run import uninstall_plan_to_jsonable +from timecapsulesmb.deploy.dry_run import activation_plan_to_jsonable, uninstall_plan_to_jsonable from timecapsulesmb.deploy.executor import remote_uninstall_payload, run_remote_actions from timecapsulesmb.deploy.planner import ( DEFAULT_APPLE_MOUNT_WAIT_SECONDS, @@ -53,12 +54,24 @@ bool_param, config_path, confirm_param, + int_param, jsonable, optional_int_param, string_param, ) from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE -from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.services.maintenance import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + LineLogCapture, + RepairExecutionContext, + format_fsck_plan, + format_fsck_targets, + fsck_plan_to_jsonable, + fsck_target_from_volume, + fsck_target_to_jsonable, + select_fsck_target, +) from timecapsulesmb.transport.ssh import SshConnection, run_ssh @@ -76,7 +89,7 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR sink.stage(operation, "build_activation_plan") plan = build_netbsd4_activation_plan() if dry_run: - return OperationResult(True, activation_plan_payload(jsonable(plan))) + return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) if not confirm_activation: raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") connection = target.connection @@ -96,6 +109,8 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation operation = "uninstall" dry_run = bool_param(params, "dry_run") no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) confirm_uninstall = confirm_param(params, "confirm_uninstall") confirm_reboot = confirm_param(params, "confirm_reboot") if not dry_run and not confirm_uninstall: @@ -116,7 +131,7 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation mounted_volumes = mounted_mast_volumes_conn( connection, mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=mount_wait, ) volume_roots = [volume.volume_root for volume in mounted_volumes] payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] @@ -127,7 +142,26 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation sink.stage(operation, "uninstall_payload") remote_uninstall_payload(connection, plan) if no_reboot: - return OperationResult(True, uninstall_result_payload(rebooted=False, verified=False)) + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=False, + waited=False, + )) + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="acp_then_ssh", + require_request_success=True, + ) + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=True, + waited=False, + )) request_reboot_and_wait( operation, sink, @@ -141,15 +175,25 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation sink.log(operation, line) if not verification: raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") - return OperationResult(True, uninstall_result_payload(rebooted=True, verified=True)) + return OperationResult(True, uninstall_result_payload( + rebooted=True, + verified=True, + reboot_requested=True, + waited=True, + )) def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "fsck" + dry_run = bool_param(params, "dry_run") + list_volumes = bool_param(params, "list_volumes") confirm_fsck = confirm_param(params, "confirm_fsck") no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") - if not confirm_fsck: + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + if dry_run and list_volumes: + raise AppOperationError("dry_run and list_volumes are mutually exclusive.", code="validation_failed") + if not dry_run and not list_volumes and not confirm_fsck: raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") sink.stage(operation, "load_config") config = load_env_config(env_path=config_path(params)) @@ -161,17 +205,33 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul mounted_volumes = mounted_mast_volumes_conn( connection, mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=mount_wait, ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if list_volumes: + sink.stage(operation, "list_fsck_volumes") + sink.log(operation, format_fsck_targets(targets)) + return OperationResult(True, fsck_volume_list_payload({ + "targets": [fsck_target_to_jsonable(target) for target in targets], + })) + sink.stage(operation, "select_fsck_volume") try: target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), + targets, string_param(params, "volume") or None, prompt=False, ) except RuntimeError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc + if dry_run: + sink.log(operation, format_fsck_plan(target, reboot=not no_reboot, wait=not no_wait)) + return OperationResult(True, fsck_plan_payload(fsck_plan_to_jsonable( + target, + reboot=not no_reboot, + wait=not no_wait, + ))) + sink.stage(operation, "run_fsck") script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) proc = run_ssh( @@ -188,12 +248,17 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul device=target.device, mountpoint=target.mountpoint, returncode=proc.returncode, + reboot_requested=False, + waited=False, + verified=False, )) if no_wait: return OperationResult(True, fsck_result_payload( device=target.device, mountpoint=target.mountpoint, + reboot_requested=True, waited=False, + verified=False, )) observe_reboot_cycle( operation, @@ -206,7 +271,9 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul return OperationResult(True, fsck_result_payload( device=target.device, mountpoint=target.mountpoint, + reboot_requested=True, waited=True, + verified=True, )) @@ -227,52 +294,6 @@ def observe_reboot_cycle( raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") -class RepairContext: - def __init__(self, operation: str, sink: EventSink) -> None: - self.operation = operation - self.sink = sink - self.result = "failure" - self.error: str | None = None - - def set_stage(self, stage: str) -> None: - self.sink.stage(self.operation, stage) - - def update_fields(self, **_fields: object) -> None: - pass - - def succeed(self) -> None: - self.result = "success" - - def fail_with_error(self, message: str) -> None: - self.result = "failure" - self.error = message - - -class StreamLogCapture: - def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: - self.operation = operation - self.sink = sink - self.level = level - self._buffer = "" - - def write(self, text: str) -> int: - self._buffer += text - while "\n" in self._buffer: - line, self._buffer = self._buffer.split("\n", 1) - self._emit(line) - return len(text) - - def flush(self) -> None: - if self._buffer: - self._emit(self._buffer) - self._buffer = "" - - def _emit(self, line: str) -> None: - message = line.rstrip("\r") - if message: - self.sink.log(self.operation, message, level=self.level) - - def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" dry_run = bool_param(params, "dry_run") @@ -301,9 +322,9 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera fix_permissions=bool_param(params, "fix_permissions"), verbose=bool_param(params, "verbose"), ) - context = RepairContext(operation, sink) - stdout_capture = StreamLogCapture(operation, sink, level="info") - stderr_capture = StreamLogCapture(operation, sink, level="warning") + context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) + stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) try: with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): result = repair_xattrs_cli.run_repair_structured( diff --git a/src/timecapsulesmb/checks/doctor.py b/src/timecapsulesmb/checks/doctor.py index 57204046..30098fd9 100644 --- a/src/timecapsulesmb/checks/doctor.py +++ b/src/timecapsulesmb/checks/doctor.py @@ -32,6 +32,7 @@ from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import AppConfig from timecapsulesmb.device.probe import ProbedDeviceState, RemoteInterfaceProbeResult +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.transport.ssh import SshConnection @@ -45,6 +46,7 @@ def run_doctor_checks( skip_ssh: bool = False, skip_bonjour: bool = False, skip_smb: bool = False, + bonjour_timeout: float = DEFAULT_BROWSE_TIMEOUT_SEC, on_result: Optional[Callable[[CheckResult], None]] = None, debug_fields: dict[str, object] | None = None, ) -> tuple[list[CheckResult], bool]: @@ -52,6 +54,7 @@ def run_doctor_checks( skip_ssh=skip_ssh, skip_bonjour=skip_bonjour, skip_smb=skip_smb, + bonjour_timeout=bonjour_timeout, ) inputs = DoctorInputs( config=config, diff --git a/src/timecapsulesmb/checks/doctor_state.py b/src/timecapsulesmb/checks/doctor_state.py index fd74f1fd..bcf786c7 100644 --- a/src/timecapsulesmb/checks/doctor_state.py +++ b/src/timecapsulesmb/checks/doctor_state.py @@ -27,6 +27,7 @@ class DoctorOptions: skip_ssh: bool skip_bonjour: bool skip_smb: bool + bonjour_timeout: float @dataclass(frozen=True) diff --git a/src/timecapsulesmb/checks/doctor_steps.py b/src/timecapsulesmb/checks/doctor_steps.py index 577b4bba..132cfeaa 100644 --- a/src/timecapsulesmb/checks/doctor_steps.py +++ b/src/timecapsulesmb/checks/doctor_steps.py @@ -267,6 +267,7 @@ def _add_bonjour_results( *, proxied_ssh: bool, skip_bonjour: bool, + bonjour_timeout: float, add_result: Callable[[CheckResult], None], ) -> DoctorBonjourResult: bonjour_instance: str | None = None @@ -301,6 +302,7 @@ def _add_bonjour_results( zeroconf_debug=None, ) smb_snapshot, discovery_error, bonjour_zeroconf_debug = discover_smb_services_detailed( + timeout=bonjour_timeout, include_related=True, target_ip=bonjour_expected.target_ip, ) @@ -777,6 +779,7 @@ def _doctor_check_bonjour(inputs: DoctorInputs, target: DoctorTarget, naming: Ru naming.identity, proxied_ssh=target.proxied_ssh, skip_bonjour=inputs.options.skip_bonjour, + bonjour_timeout=inputs.options.bonjour_timeout, add_result=sink.add, ) diff --git a/src/timecapsulesmb/cli/activate.py b/src/timecapsulesmb/cli/activate.py index 738e853c..35212857 100644 --- a/src/timecapsulesmb/cli/activate.py +++ b/src/timecapsulesmb/cli/activate.py @@ -8,11 +8,12 @@ from timecapsulesmb.cli.runtime import ( add_config_argument, load_env_config, + print_json, require_netbsd4_device_compatibility, ) from timecapsulesmb.core.config import airport_exact_display_name_from_identity from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.deploy.dry_run import format_activation_plan +from timecapsulesmb.deploy.dry_run import activation_plan_to_jsonable, format_activation_plan from timecapsulesmb.deploy.executor import run_remote_actions from timecapsulesmb.deploy.planner import build_netbsd4_activation_plan from timecapsulesmb.device.probe import probe_managed_runtime_conn @@ -37,8 +38,12 @@ def main(argv: Optional[list[str]] = None) -> int: add_config_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before restarting the deployed Samba services") parser.add_argument("--dry-run", action="store_true", help="Print activation actions without making changes") + parser.add_argument("--json", action="store_true", help="Output the dry-run activation plan as JSON") args = parser.parse_args(argv) + if args.json and not args.dry_run: + parser.error("--json currently requires --dry-run") + ensure_install_id() config = load_env_config(env_path=args.config) telemetry = TelemetryClient.from_config(config) @@ -51,6 +56,7 @@ def main(argv: Optional[list[str]] = None) -> int: require_netbsd4_device_compatibility( command_context, command_name="activate", + json_output=args.json, unsupported_message="activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", ) @@ -60,7 +66,10 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.update_fields(activation_action_count=len(plan.actions)) if args.dry_run: - print(format_activation_plan(plan, device_name=device_name)) + if args.json: + print_json(activation_plan_to_jsonable(plan)) + else: + print(format_activation_plan(plan, device_name=device_name)) command_context.succeed() return 0 diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index f4b10037..4de0ab56 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -26,6 +26,7 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import wait_for_tcp_port_state from timecapsulesmb.cli.runtime import ( + add_bonjour_timeout_argument, add_config_argument, confirm as confirm_prompt, ssh_target_link_local_resolution_error, @@ -44,10 +45,8 @@ from timecapsulesmb.discovery.bonjour import ( BonjourResolvedService, AIRPORT_SERVICE, - DEFAULT_BROWSE_TIMEOUT_SEC, discover_resolved_records, discovered_record_root_host, - record_has_service, ) from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshConnection @@ -108,9 +107,9 @@ def choose_device(records): return records[idx - 1] -def discover_default_record(existing: dict[str, str]) -> Optional[BonjourResolvedService]: +def discover_default_record(existing: dict[str, str], *, timeout: float) -> Optional[BonjourResolvedService]: print("Attempting to discover Time Capsule/Airport Extreme devices on the local network via mDNS...", flush=True) - records = discover_resolved_records(AIRPORT_SERVICE, timeout=DEFAULT_BROWSE_TIMEOUT_SEC) + records = discover_resolved_records(AIRPORT_SERVICE, timeout=timeout) if not records: print("No Time Capsule/Airport Extreme devices discovered. Falling back to manual SSH target entry.\n", flush=True) return None @@ -298,6 +297,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--internal-share-use-disk-root", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--any-protocol", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -327,7 +327,7 @@ def main(argv: Optional[list[str]] = None) -> int: args=args, configure_id=configure_id, ) as command_context: - command_context.update_fields(configure_id=configure_id) + command_context.update_fields(configure_id=configure_id, bonjour_timeout=args.bonjour_timeout) command_context.set_stage("dependency_check") missing_module = missing_required_python_module(REQUIRED_PYTHON_MODULES) if missing_module is not None: @@ -366,7 +366,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.set_stage("bonjour_discovery") try: - discovered_record = discover_default_record(existing) + discovered_record = discover_default_record(existing, timeout=args.bonjour_timeout) except Exception as exc: error_text = exception_summary(exc) print(f"Warning: mDNS discovery failed: {error_text}") diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 8be80836..7cde0175 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -7,8 +7,10 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import request_deploy_reboot_and_wait, verify_managed_runtime_flow +from timecapsulesmb.cli.flows import request_deploy_reboot, request_deploy_reboot_and_wait, verify_managed_runtime_flow from timecapsulesmb.cli.runtime import ( + add_mount_wait_argument, + add_no_wait_argument, add_config_argument, load_env_config, print_json, @@ -30,7 +32,6 @@ BINARY_MDNS_SOURCE, BINARY_NBNS_SOURCE, BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, GENERATED_FLASH_CONFIG_SOURCE, GENERATED_SMBPASSWD_SOURCE, GENERATED_USERNAME_MAP_SOURCE, @@ -71,32 +72,17 @@ def _target_family_display_name(target) -> str: ) -def _non_negative_int(value: str) -> int: - try: - parsed = int(value) - except ValueError as e: - raise argparse.ArgumentTypeError("must be an integer") from e - if parsed < 0: - raise argparse.ArgumentTypeError("must be 0 or greater") - return parsed - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Deploy the checked-in Samba 4 payload to an AirPort storage device.") add_config_argument(parser) + add_mount_wait_argument(parser) + add_no_wait_argument(parser) parser.add_argument("--no-reboot", action="store_true", help="Do not reboot after deployment") parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") parser.add_argument("--dry-run", action="store_true", help="Print actions without making changes") parser.add_argument("--json", action="store_true", help="Output the dry-run deployment plan as JSON") parser.add_argument("--allow-unsupported", action="store_true", help="Proceed even if the detected device is not currently supported") parser.add_argument("--no-nbns", action="store_true", help="Disable the bundled NBNS responder on the next boot") - parser.add_argument( - "--mount-wait", - type=_non_negative_int, - default=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - metavar="SECONDS", - help=f"Seconds for deployment-time diskd.useVolume mount guards to wait before their manual fallback (default: {DEFAULT_APPLE_MOUNT_WAIT_SECONDS})", - ) parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args(argv) @@ -323,6 +309,13 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.cancel_with_error("Cancelled by user at reboot confirmation prompt.") return 0 + if args.no_wait: + request_deploy_reboot(connection, command_context, require_request_success=True) + print("Reboot requested; not waiting for the device to go down or come back.") + print(color_green("Deploy Finished.")) + command_context.succeed() + return 0 + if not request_deploy_reboot_and_wait( connection, command_context, diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 177b52ff..2208fc02 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -8,7 +8,7 @@ from timecapsulesmb.checks.doctor import run_doctor_checks from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json +from timecapsulesmb.cli.runtime import add_bonjour_timeout_argument, add_config_argument, load_env_config, print_json from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.services.doctor import doctor_status_counts @@ -251,6 +251,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--skip-bonjour", action="store_true", help="Skip Bonjour browse/resolve checks") parser.add_argument("--skip-smb", action="store_true", help="Skip authenticated SMB listing check") parser.add_argument("--json", action="store_true", help="Output doctor results as JSON") + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -262,6 +263,7 @@ def main(argv: Optional[list[str]] = None) -> int: skip_ssh=args.skip_ssh, skip_bonjour=args.skip_bonjour, skip_smb=args.skip_smb, + bonjour_timeout=args.bonjour_timeout, json_output=args.json, ) if not args.skip_ssh and config.has_value("TC_HOST"): @@ -280,6 +282,7 @@ def main(argv: Optional[list[str]] = None) -> int: skip_ssh=args.skip_ssh, skip_bonjour=args.skip_bonjour, skip_smb=args.skip_smb, + bonjour_timeout=args.bonjour_timeout, on_result=None if args.json else print_result, debug_fields=doctor_debug, ) diff --git a/src/timecapsulesmb/cli/flash.py b/src/timecapsulesmb/cli/flash.py index bf2b843e..02392450 100644 --- a/src/timecapsulesmb/cli/flash.py +++ b/src/timecapsulesmb/cli/flash.py @@ -19,6 +19,7 @@ from timecapsulesmb.cli.runtime import ( LogCallback, add_config_argument, + add_no_wait_argument, emit_progress, load_env_config, prefixed_logger, @@ -549,6 +550,7 @@ def _build_parser() -> argparse.ArgumentParser: mode_group.add_argument("--download-only", action="store_true", help="Download and validate Apple firmware without writing") parser.add_argument("--yes", action="store_true", help="Do not prompt before --patch or --restore writes") parser.add_argument("--reboot", action="store_true", help="Reboot after a validated --restore write") + add_no_wait_argument(parser) parser.add_argument("--poweroff", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--json", action="store_true", help="Output the flash analysis and plan as JSON") parser.add_argument("--backup-dir", type=Path, default=None, help="Directory where this run's firmware backup should be saved") @@ -580,6 +582,8 @@ def _parse_args(argv: Optional[list[str]]) -> tuple[argparse.Namespace, str]: parser.error("flash --patch cannot use --reboot; power cycle manually after the validated write") if args.reboot and operation != "restore": parser.error("--reboot is only valid with --restore") + if args.no_wait and not (operation == "restore" and args.reboot): + parser.error("--no-wait is only valid with --restore --reboot") if args.poweroff: parser.error("--poweroff is not supported; power cycle manually after a validated patch write") if args.json and operation in WRITE_OPERATIONS: @@ -972,7 +976,11 @@ def _finish_write( command_context.succeed() return 0 - request_ssh_reboot(target.connection, command_context, log=log) + request_ssh_reboot(target.connection, command_context, log=log, require_request_success=args.no_wait) + if args.no_wait: + print("Reboot requested; not waiting for the device to go down or come back.", flush=True) + command_context.succeed() + return 0 if not observe_reboot_cycle( target.connection, command_context, diff --git a/src/timecapsulesmb/cli/flows.py b/src/timecapsulesmb/cli/flows.py index 40a5d458..9e0cdf57 100644 --- a/src/timecapsulesmb/cli/flows.py +++ b/src/timecapsulesmb/cli/flows.py @@ -82,9 +82,7 @@ def request_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_acp_then_ssh(connection, command_context) + request_reboot(connection, command_context) return observe_reboot_cycle( connection, @@ -95,6 +93,17 @@ def request_reboot_and_wait( ) +def request_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + require_request_success: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_acp_then_ssh(connection, command_context, require_request_success=require_request_success) + + def request_deploy_reboot_and_wait( connection: SshConnection, command_context: CommandContext, @@ -103,9 +112,7 @@ def request_deploy_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_via_ssh_shutdown(connection, command_context) + request_deploy_reboot(connection, command_context) return observe_reboot_cycle( connection, @@ -116,23 +123,53 @@ def request_deploy_reboot_and_wait( ) +def request_deploy_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + require_request_success: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_via_ssh_shutdown( + connection, + command_context, + require_request_success=require_request_success, + ) + + def request_ssh_reboot( connection: SshConnection, command_context: CommandContext, *, log: LogCallback = None, + require_request_success: bool = False, ) -> None: command_context.set_stage("reboot") command_context.update_fields(reboot_was_attempted=True) command_context.add_debug_fields(reboot_request_strategy="ssh") - _request_reboot_via_ssh(connection, command_context, log=log) + _request_reboot_via_ssh( + connection, + command_context, + log=log, + require_request_success=require_request_success, + ) -def _request_reboot_acp_then_ssh(connection: SshConnection, command_context: CommandContext) -> None: +def _request_reboot_acp_then_ssh( + connection: SshConnection, + command_context: CommandContext, + *, + require_request_success: bool = False, +) -> None: command_context.add_debug_fields(reboot_request_strategy="acp_then_ssh") if _request_reboot_via_acp(connection, command_context): return - _request_reboot_via_ssh(connection, command_context) + _request_reboot_via_ssh( + connection, + command_context, + require_request_success=require_request_success, + ) def _request_reboot_via_acp(connection: SshConnection, command_context: CommandContext) -> bool: @@ -161,6 +198,7 @@ def _request_reboot_via_ssh_shutdown( command_context: CommandContext, *, log: LogCallback = None, + require_request_success: bool = False, ) -> None: command_context.add_debug_fields(reboot_request_strategy="ssh_shutdown_then_reboot") _request_reboot_via_ssh( @@ -169,6 +207,7 @@ def _request_reboot_via_ssh_shutdown( log=log, request_reboot=remote_request_shutdown_reboot, progress_message="SSH: /sbin/shutdown -r now (fallback /sbin/reboot)", + require_request_success=require_request_success, ) @@ -179,6 +218,7 @@ def _request_reboot_via_ssh( log: LogCallback = None, request_reboot: Callable[[SshConnection], None] | None = None, progress_message: str = "SSH: /sbin/reboot", + require_request_success: bool = False, ) -> None: command_context.add_debug_fields(ssh_reboot_attempted=True) emit_progress(log, progress_message) @@ -199,6 +239,8 @@ def _request_reboot_via_ssh( ssh_reboot_succeeded=False, ssh_reboot_error=system_exit_message(exc), ) + if require_request_success: + raise print("SSH reboot request failed; checking whether the device is rebooting anyway...") return diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index 9957a258..c49bbf54 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -2,75 +2,25 @@ import argparse import shlex -from dataclasses import dataclass from typing import Optional from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import observe_reboot_cycle -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config -from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.device.storage import MaStVolume -from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.services.maintenance import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + format_fsck_plan, + format_fsck_targets, + fsck_target_from_volume, + select_fsck_target, +) from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import run_ssh FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 -NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" -MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" - - -@dataclass(frozen=True) -class FsckTarget: - device: str - mountpoint: str - name: str - builtin: bool - - -def _target_from_volume(volume: MaStVolume) -> FsckTarget: - return FsckTarget( - device=volume.device_path, - mountpoint=volume.volume_root, - name=volume.name, - builtin=volume.builtin, - ) - - -def _normalize_volume_selector(selector: str) -> str: - selector = selector.strip() - if selector.startswith("/dev/"): - return selector.removeprefix("/dev/") - return selector - - -def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, prompt: bool = True) -> FsckTarget: - if not targets: - raise RuntimeError(NO_MOUNTED_HFS_VOLUMES_MESSAGE) - if selector: - selected_device = _normalize_volume_selector(selector) - for target in targets: - if target.device == selector or target.device.removeprefix("/dev/") == selected_device: - return target - raise RuntimeError(f"HFS volume not found: {selector}") - if len(targets) == 1: - return targets[0] - if not prompt: - raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) - - print("Mounted HFS volumes:") - for index, target in enumerate(targets, start=1): - kind = "internal" if target.builtin else "external" - print(f" {index}. {target.device} on {target.mountpoint} ({target.name}, {kind})") - while True: - answer = input("Select a volume to fsck by number: ").strip() - if answer.isdigit(): - index = int(answer) - if 1 <= index <= len(targets): - return targets[index - 1] - print("Please enter a valid volume number.") def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: @@ -98,13 +48,20 @@ def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> s def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Run fsck_hfs on a mounted HFS volume and reboot by default.") add_config_argument(parser) + add_mount_wait_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before running fsck") + parser.add_argument("--dry-run", action="store_true", help="Print the selected fsck target and actions without making changes") + parser.add_argument("--list-volumes", action="store_true", help="List mounted HFS volumes that can be selected for fsck") parser.add_argument("--no-reboot", action="store_true", help="Run fsck only; do not reboot afterward") - parser.add_argument("--no-wait", action="store_true", help="Do not wait for SSH to go down and come back after reboot") + add_no_wait_argument(parser) parser.add_argument("--volume", help="HFS volume device to repair, for example dk2 or /dev/dk2") args = parser.parse_args(argv) - print("Running fsck...") + if args.dry_run and args.list_volumes: + parser.error("--dry-run and --list-volumes are mutually exclusive") + + if not args.dry_run and not args.list_volumes: + print("Running fsck...") ensure_install_id() config = load_env_config(env_path=args.config) @@ -123,15 +80,22 @@ def main(argv: Optional[list[str]] = None) -> int: mounted_volumes = command_context.mount_mast_volumes( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=args.mount_wait, mount_stage="mount_hfs_volumes", ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if args.list_volumes: + command_context.set_stage("list_fsck_volumes") + print(format_fsck_targets(targets)) + command_context.succeed() + return 0 + command_context.set_stage("select_fsck_volume") try: target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), + targets, args.volume, - prompt=not args.yes, + prompt=not args.yes and not args.dry_run, ) except RuntimeError as exc: raise SystemExit(str(exc)) from exc @@ -139,6 +103,11 @@ def main(argv: Optional[list[str]] = None) -> int: print(f"Target host: {connection.host}") print(f"Mounted HFS volume: {target.device} on {target.mountpoint}") + if args.dry_run: + print(format_fsck_plan(target, reboot=not args.no_reboot, wait=not args.no_wait)) + command_context.succeed() + return 0 + if not args.yes: command_context.set_stage("confirm_fsck") device_name = command_context.optional_airport_display_name(timeout_seconds=0.1) @@ -169,6 +138,7 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.update_fields(reboot_was_attempted=True) if args.no_wait: + print("Reboot requested; not waiting for the device to go down or come back.") command_context.succeed() return 0 diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index 2b60abd5..734df12b 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -2,13 +2,17 @@ import argparse import sys +from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional +from timecapsulesmb.app.contracts import repair_xattrs_payload +from timecapsulesmb.app.events import EventSink from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.runtime import add_config_argument, confirm as confirm_prompt, load_optional_env_config from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.repair_xattrs import ( ACTION_CLEAR_ARCH_FLAG, @@ -42,6 +46,8 @@ xattr_status, xattrs_readable, ) +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.maintenance import LineLogCapture, RepairExecutionContext from timecapsulesmb.telemetry import TelemetryClient @@ -261,6 +267,45 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config return run_repair_structured(args, command_context, config, emit_log=print).returncode +def _repair_result_payload(result: RepairRunResult, context: RepairExecutionContext | CommandContext) -> dict[str, object]: + return repair_xattrs_payload({ + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "summary": jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error if isinstance(context, RepairExecutionContext) else None, + }) + + +def run_repair_json(args: argparse.Namespace, config: AppConfig, sink: EventSink) -> int: + operation = "repair-xattrs" + context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) + stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + sink.error(operation, message, code="operation_failed") + sink.result(operation, ok=False, payload={"error": message}) + return 1 + finally: + stdout_capture.flush() + stderr_capture.flush() + payload = _repair_result_payload(result, context) + sink.result(operation, ok=result.returncode == 0, payload=payload) + return result.returncode + + def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Repair files whose SMB xattr metadata is broken by clearing the macOS arch flag.") add_config_argument(parser) @@ -274,15 +319,30 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--include-time-machine", action="store_true", help="Include Time Machine and bundle-like paths normally skipped") parser.add_argument("--fix-permissions", action="store_true", help="Also repair missing write permissions on scanned files/directories") parser.add_argument("--verbose", action="store_true", help="Print detailed diagnostics for detected issues") + parser.add_argument("--json", action="store_true", help="Emit app-event NDJSON instead of human-readable output") args = parser.parse_args(argv) if args.dry_run and args.yes: parser.error("--dry-run and --yes are mutually exclusive") + if args.json and not args.dry_run and not args.yes: + parser.error("--json repair requires --yes when not using --dry-run") if args.max_depth is not None and args.max_depth < 0: parser.error("--max-depth must be non-negative") ensure_install_id() config = load_optional_env_config(env_path=args.config) + if args.json: + sink = EventSink(lambda event: print(event.to_json_line(), end="")) + operation = "repair-xattrs" + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + message = "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share." + sink.error(operation, message, code="validation_failed") + sink.result(operation, ok=False, payload={"error": message}) + return 1 + sink.stage(operation, "validate_params") + return run_repair_json(args, config, sink) + telemetry = TelemetryClient.from_config(config) with CommandContext(telemetry, "repair-xattrs", "repair_xattrs_started", "repair_xattrs_finished", config=config, args=args) as command_context: command_context.set_stage("platform_check") diff --git a/src/timecapsulesmb/cli/runtime.py b/src/timecapsulesmb/cli/runtime.py index be49d262..702c519d 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -2,6 +2,7 @@ import argparse import json +import math from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional @@ -28,6 +29,8 @@ probe_remote_interface_conn, read_interface_ipv4_addrs_conn, ) +from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -54,6 +57,50 @@ def add_config_argument(parser: argparse.ArgumentParser) -> None: ) +def non_negative_int_arg(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 0: + raise argparse.ArgumentTypeError("must be 0 or greater") + return parsed + + +def non_negative_float_arg(value: str) -> float: + try: + parsed = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a number") from exc + if not math.isfinite(parsed) or parsed < 0: + raise argparse.ArgumentTypeError("must be 0 or greater") + return parsed + + +def add_mount_wait_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--mount-wait", + type=non_negative_int_arg, + default=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + metavar="SECONDS", + help=f"Seconds for diskd.useVolume mount guards to wait before their manual fallback (default: {DEFAULT_APPLE_MOUNT_WAIT_SECONDS})", + ) + + +def add_no_wait_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--no-wait", action="store_true", help="Do not wait for the device to go down and come back after reboot") + + +def add_bonjour_timeout_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--bonjour-timeout", + type=non_negative_float_arg, + default=DEFAULT_BROWSE_TIMEOUT_SEC, + metavar="SECONDS", + help=f"Bonjour browse time in seconds (default: {DEFAULT_BROWSE_TIMEOUT_SEC:g})", + ) + + def config_path_from_args(args: argparse.Namespace) -> Path | None: return getattr(args, "config", None) diff --git a/src/timecapsulesmb/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index f6ba21b7..a1314885 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -5,7 +5,7 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import wait_for_device_up, wait_for_tcp_port_state -from timecapsulesmb.cli.runtime import LogCallback, add_config_argument, confirm, emit_progress, load_env_config +from timecapsulesmb.cli.runtime import LogCallback, add_config_argument, add_no_wait_argument, confirm, emit_progress, load_env_config from timecapsulesmb.cli.util import color_red from timecapsulesmb.core.config import ConfigError from timecapsulesmb.core.net import extract_host @@ -67,31 +67,58 @@ def disable_ssh_over_ssh( def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Use the configured device target from .env to enable SSH via ACP or disable SSH over SSH.") add_config_argument(parser) + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument("--enable", action="store_true", help="Enable SSH via ACP if it is not already reachable") + mode_group.add_argument("--disable", action="store_true", help="Disable SSH over SSH if it is currently reachable") + mode_group.add_argument("--status", action="store_true", help="Report whether SSH is reachable without changing device state") + parser.add_argument("--yes", action="store_true", help="Skip the legacy prompt when SSH is already enabled") + add_no_wait_argument(parser) args = parser.parse_args(argv) + if args.status and args.no_wait: + parser.error("--no-wait is not valid with --status") + ensure_install_id() config = load_env_config(env_path=args.config, defaults={}) telemetry = TelemetryClient.from_config(config) with CommandContext(telemetry, "set-ssh", "set_ssh_started", "set_ssh_finished", config=config, args=args) as command_context: command_context.set_stage("load_config") try: - command_context.require_valid_config(profile="set_ssh") + command_context.require_valid_config(profile="set_ssh_status" if args.status else "set_ssh") except ConfigError as exc: message = str(exc) or f"Missing {config.path} settings. Run '.venv/bin/tcapsule configure' first." command_context.update_fields(set_ssh_action="missing_config") print(message) command_context.fail_with_error(message) return 1 - connection = command_context.resolve_env_connection() - acp_host = extract_host(connection.host) - password = connection.password + connection = None if args.status else command_context.resolve_env_connection() + target_host = config.require("TC_HOST") if args.status else connection.host + acp_host = extract_host(target_host) + password = "" if connection is None else connection.password - print(f"Using configured target from {config.path}: {connection.host}") + print(f"Using configured target from {config.path}: {target_host}") print(f"Probing SSH on {acp_host}:22 ...") command_context.set_stage("probe_ssh") ssh_open = tcp_open(acp_host, 22) command_context.update_fields(ssh_initially_reachable=ssh_open) - if not ssh_open: + + if args.status: + command_context.update_fields(set_ssh_action="status", ssh_final_reachable=ssh_open) + print("SSH enabled." if ssh_open else "SSH disabled.") + command_context.succeed() + return 0 + + assert connection is not None + should_enable = args.enable or (not args.disable and not ssh_open) + should_disable = args.disable or (not args.enable and ssh_open) + + if should_enable: + if ssh_open: + command_context.update_fields(set_ssh_action="enable_noop", ssh_final_reachable=True) + print("SSH already enabled.") + command_context.succeed() + return 0 + command_context.update_fields(set_ssh_action="enable_ssh") print("SSH not reachable. Attempting to enable via ACP...") try: @@ -105,23 +132,43 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.fail_with_error(message) return 1 + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH enable requested; not waiting for SSH to open.") + command_context.succeed() + return 0 + command_context.set_stage("wait_for_ssh_enabled") if not wait_for_tcp_port_state(acp_host, 22, expected_state=True, service_name="SSH port"): command_context.update_fields(ssh_final_reachable=False) command_context.fail_with_error("SSH did not open after enabling via ACP.") return 1 command_context.update_fields(ssh_final_reachable=True) - else: + + print("SSH is configured. You can connect as 'root' using the AirPort admin password.") + command_context.succeed() + return 0 + + if should_disable: + if not ssh_open: + command_context.update_fields(set_ssh_action="disable_noop", ssh_final_reachable=False) + print("SSH already disabled.") + command_context.succeed() + return 0 + command_context.set_stage("prompt_disable_ssh") - should_disable = confirm( - "SSH already enabled. Disable?", - default=False, - eof_default=False, - interrupt_default=False, - ) + if not args.disable and not args.yes: + should_disable = confirm( + "SSH already enabled. Disable?", + default=False, + eof_default=False, + interrupt_default=False, + ) if not should_disable: command_context.update_fields(set_ssh_action="leave_enabled", ssh_final_reachable=True) print("Leaving SSH enabled.") + command_context.succeed() + return 0 if should_disable: command_context.update_fields(set_ssh_action="disable_ssh") @@ -136,6 +183,12 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.fail_with_error(message) return 1 + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") + command_context.succeed() + return 0 + print("Device is starting reboot now, waiting for it to shut down...") command_context.set_stage("wait_for_ssh_down") if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): @@ -175,7 +228,6 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 - print("SSH is configured. You can connect as 'root' using the AirPort admin password.") - command_context.succeed() - return 0 + command_context.fail_with_error("No set-ssh action selected.") + return 1 return 1 diff --git a/src/timecapsulesmb/cli/uninstall.py b/src/timecapsulesmb/cli/uninstall.py index 3cf3b0a4..db8ec11e 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -4,12 +4,12 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import request_reboot_and_wait -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json +from timecapsulesmb.cli.flows import request_reboot, request_reboot_and_wait +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config, print_json from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.deploy.dry_run import format_uninstall_plan, uninstall_plan_to_jsonable from timecapsulesmb.deploy.executor import remote_uninstall_payload -from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS, build_uninstall_plan +from timecapsulesmb.deploy.planner import build_uninstall_plan from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall from timecapsulesmb.device.storage import UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER from timecapsulesmb.identity import ensure_install_id @@ -20,6 +20,8 @@ def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Remove the managed TimeCapsuleSMB payload from the configured device.") add_config_argument(parser) + add_mount_wait_argument(parser) + add_no_wait_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") parser.add_argument("--no-reboot", action="store_true", help="Remove files but do not reboot the device") parser.add_argument("--dry-run", action="store_true", help="Print actions without making changes") @@ -54,7 +56,7 @@ def main(argv: Optional[list[str]] = None) -> int: else: mounted_volumes = command_context.mount_mast_volumes( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=args.mount_wait, ) volume_roots = [volume.volume_root for volume in mounted_volumes] payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] @@ -100,6 +102,13 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 + if args.no_wait: + request_reboot(connection, command_context, require_request_success=True) + print("Reboot requested; not waiting for the device to go down or come back.") + print("Post-uninstall verification skipped.") + command_context.succeed() + return 0 + if not request_reboot_and_wait( connection, command_context, diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 037a1a97..ec4941f6 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -575,6 +575,10 @@ class ConfigProfile: required_file_values=("TC_HOST", "TC_PASSWORD"), validated_keys=("TC_HOST",), ), + "set_ssh_status": ConfigProfile( + required_file_values=("TC_HOST",), + validated_keys=("TC_HOST",), + ), "flash": ConfigProfile( required_file_values=FLASH_REQUIRED_FILE_KEYS, validated_keys=FLASH_VALIDATED_KEYS, diff --git a/src/timecapsulesmb/deploy/dry_run.py b/src/timecapsulesmb/deploy/dry_run.py index 5689c380..e91c4bfa 100644 --- a/src/timecapsulesmb/deploy/dry_run.py +++ b/src/timecapsulesmb/deploy/dry_run.py @@ -88,6 +88,12 @@ def deployment_plan_to_jsonable(plan: DeploymentPlan) -> dict[str, object]: return data +def activation_plan_to_jsonable(plan: ActivationPlan) -> dict[str, object]: + data = asdict(plan) + data["actions"] = remote_actions_to_jsonable(plan.actions) + return data + + def format_activation_plan(plan: ActivationPlan, *, device_name: str = "AirPort storage device") -> str: lines: list[str] = [] lines.append("Dry run: NetBSD4 activation plan") diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 5194855e..c9e75bd0 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -62,30 +62,46 @@ def confirm_param(params: dict[str, object], name: str) -> bool: def int_param(params: dict[str, object], name: str, default: int) -> int: value = params.get(name, default) - try: + if isinstance(value, bool): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") parsed = int(value) - except (TypeError, ValueError) as exc: - raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + else: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc if parsed < 0: raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") return parsed -def optional_int_param(params: dict[str, object], name: str) -> int | None: - value = params.get(name) - if value in (None, ""): - return None +def _parse_optional_int_value(value: object, name: str) -> int: if isinstance(value, bool): raise AppOperationError(f"{name} must be an integer", code="validation_failed") - try: + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") parsed = int(value) - except (TypeError, ValueError) as exc: - raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + else: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc if parsed < 0: raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") return parsed +def optional_int_param(params: dict[str, object], name: str) -> int | None: + value = params.get(name) + if value in (None, ""): + return None + return _parse_optional_int_value(value, name) + + def float_param(params: dict[str, object], name: str, default: float) -> float: value = params.get(name, default) if isinstance(value, bool): diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py index cd260378..67889e5e 100644 --- a/src/timecapsulesmb/services/maintenance.py +++ b/src/timecapsulesmb/services/maintenance.py @@ -1,8 +1,160 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Callable + +from timecapsulesmb.device.storage import MaStVolume + UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( "Reboot was requested but the device did not go down.\n" "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." ) FSCK_REBOOT_NO_DOWN_MESSAGE = "fsck requested reboot from the device, but SSH did not go down." + +NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" +MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" + + +@dataclass(frozen=True) +class FsckTarget: + device: str + mountpoint: str + name: str + builtin: bool + + +def fsck_target_from_volume(volume: MaStVolume) -> FsckTarget: + return FsckTarget( + device=volume.device_path, + mountpoint=volume.volume_root, + name=volume.name, + builtin=volume.builtin, + ) + + +def normalize_volume_selector(selector: str) -> str: + selector = selector.strip() + if selector.startswith("/dev/"): + return selector.removeprefix("/dev/") + return selector + + +def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, prompt: bool = True) -> FsckTarget: + if not targets: + raise RuntimeError(NO_MOUNTED_HFS_VOLUMES_MESSAGE) + if selector: + selected_device = normalize_volume_selector(selector) + for target in targets: + if target.device == selector or target.device.removeprefix("/dev/") == selected_device: + return target + raise RuntimeError(f"HFS volume not found: {selector}") + if len(targets) == 1: + return targets[0] + if not prompt: + raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) + + print(format_fsck_targets(targets)) + while True: + answer = input("Select a volume to fsck by number: ").strip() + if answer.isdigit(): + index = int(answer) + if 1 <= index <= len(targets): + return targets[index - 1] + print("Please enter a valid volume number.") + + +def fsck_target_to_jsonable(target: FsckTarget) -> dict[str, object]: + return { + "device": target.device, + "mountpoint": target.mountpoint, + "name": target.name, + "builtin": target.builtin, + } + + +def format_fsck_targets(targets: tuple[FsckTarget, ...]) -> str: + lines = ["Mounted HFS volumes:"] + if not targets: + lines.append(" none") + return "\n".join(lines) + for index, target in enumerate(targets, start=1): + kind = "internal" if target.builtin else "external" + lines.append(f" {index}. {target.device} on {target.mountpoint} ({target.name}, {kind})") + return "\n".join(lines) + + +def fsck_plan_to_jsonable(target: FsckTarget, *, reboot: bool, wait: bool) -> dict[str, object]: + return { + "target": fsck_target_to_jsonable(target), + "device": target.device, + "mountpoint": target.mountpoint, + "reboot_required": reboot, + "wait_after_reboot": bool(reboot and wait), + } + + +def format_fsck_plan(target: FsckTarget, *, reboot: bool, wait: bool) -> str: + lines = [ + "Dry run: fsck plan", + "", + "Target:", + f" device: {target.device}", + f" mountpoint: {target.mountpoint}", + f" name: {target.name}", + f" type: {'internal' if target.builtin else 'external'}", + "", + "Actions:", + " stop managed file sharing processes", + f" unmount: {target.mountpoint}", + f" run: /sbin/fsck_hfs -fy {target.device}", + "", + "Reboot:", + f" {'yes' if reboot else 'no'}", + ] + if reboot: + lines.append(f" follow-up: {'wait for SSH down, then SSH up' if wait else 'do not wait'}") + return "\n".join(lines) + + +class RepairExecutionContext: + def __init__(self, stage_callback: Callable[[str], None]) -> None: + self._stage_callback = stage_callback + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self._stage_callback(stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +class LineLogCapture: + def __init__(self, emit_line: Callable[[str], None]) -> None: + self._emit_line = emit_line + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self._emit_line(message) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index ede92e80..1a40d9de 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -24,9 +24,10 @@ from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState +from timecapsulesmb.device.storage import MaStVolume from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError -from timecapsulesmb.transport.errors import TransportError +from timecapsulesmb.transport.errors import SshError, TransportError from timecapsulesmb.transport.ssh import SshConnection @@ -496,6 +497,22 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertEqual(checks[0]["status"], "PASS") self.assertEqual(checks[0]["details"], {"port": 445}) + def test_doctor_passes_bonjour_timeout_to_checks(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", return_value=([], False)) as checks: + rc = service.run_api_request( + {"operation": "doctor", "params": {"bonjour_timeout": "2.75"}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(checks.call_args.kwargs["bonjour_timeout"], 2.75) + def test_doctor_fatal_returns_nonzero_result_without_error_event(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -652,6 +669,100 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: wait.assert_not_called() self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) + def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): + with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot") as reboot: + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + reboot.assert_called_once() + wait.assert_not_called() + verify_runtime.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["reboot_requested"], True) + self.assertEqual(payload["waited"], False) + self.assertEqual(payload["verified"], False) + + def test_deploy_no_wait_reports_reboot_request_failure(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): + with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot: + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + reboot.assert_called_once() + wait.assert_not_called() + verify_runtime.assert_not_called() + errors = collector.events_of_type("error") + self.assertEqual(errors[0]["code"], "remote_error") + self.assertIn("ssh command failed with rc=255", errors[0]["message"]) + self.assertEqual(collector.events_of_type("result"), []) + def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -773,6 +884,43 @@ def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: self.assertEqual(result["payload"]["schema_version"], 1) uninstall.assert_not_called() + def test_uninstall_no_wait_uses_mount_wait_and_skips_post_reboot_verification(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [SimpleNamespace(volume_root="/Volumes/dk2")] + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.maintenance.verify_post_uninstall") as verify: + rc = service.run_api_request( + { + "operation": "uninstall", + "params": { + "confirm_uninstall": True, + "confirm_reboot": True, + "mount_wait": 13, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(mounted_mock.call_args.kwargs["wait_seconds"], 13) + reboot.assert_called_once() + wait.assert_not_called() + verify.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["reboot_requested"], True) + self.assertEqual(payload["waited"], False) + self.assertEqual(payload["verified"], False) + def test_fsck_requires_confirmation_before_remote_connection(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -785,6 +933,76 @@ def test_fsck_requires_confirmation_before_remote_connection(self) -> None: self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") resolve_connection.assert_not_called() + def test_fsck_rejects_non_integer_mount_wait_before_remote_connection(self) -> None: + for value in (12.5, True): + with self.subTest(value=value): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "fsck", + "params": {"list_volumes": True, "mount_wait": value}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + load_config.assert_not_called() + error = collector.events_of_type("error")[0] + self.assertEqual(error["code"], "validation_failed") + self.assertIn("mount_wait must be an integer", error["message"]) + + def test_fsck_list_volumes_returns_targets_without_confirmation_or_remote_fsck(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "uuid", True, "hfs")] + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.operations.run_ssh") as run_ssh: + rc = service.run_api_request( + { + "operation": "fsck", + "params": {"list_volumes": True, "mount_wait": 14}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(mounted_mock.call_args.kwargs["wait_seconds"], 14) + run_ssh.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["counts"], {"targets": 1}) + self.assertEqual(payload["targets"][0]["device"], "/dev/dk2") + + def test_fsck_dry_run_returns_plan_without_remote_fsck(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "uuid", True, "hfs")] + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted): + with mock.patch("timecapsulesmb.app.operations.run_ssh") as run_ssh: + rc = service.run_api_request( + { + "operation": "fsck", + "params": {"dry_run": True, "no_wait": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + run_ssh.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["device"], "/dev/dk2") + self.assertEqual(payload["wait_after_reboot"], False) + def test_repair_xattrs_uses_structured_runner(self) -> None: collector = CollectingSink() summary = repair_xattrs_domain.RepairSummary(scanned=1, scanned_files=1, unreadable=1, repairable=1) diff --git a/tests/test_cli.py b/tests/test_cli.py index e234c0d0..99492269 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import errno +import argparse import io import json import plistlib @@ -1241,6 +1242,37 @@ def test_repair_xattrs_non_macos_emits_platform_check_telemetry(self) -> None: self.assertEqual(finished["host_platform"], "linux") self.assertIn("stage=platform_check", finished["error"]) + def test_repair_xattrs_json_emits_ndjson_result(self) -> None: + output = io.StringIO() + result = repair_xattrs.RepairRunResult( + returncode=0, + root=Path("/Volumes/Data"), + findings=[mock.Mock()], + candidates=[mock.Mock()], + summary=repair_xattrs.RepairSummary(scanned=1, repairable=1), + report="detected issues", + ) + with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.cli.repair_xattrs.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.cli.repair_xattrs.run_repair_structured", return_value=result): + with redirect_stdout(output): + rc = repair_xattrs.main(["--path", "/Volumes/Data", "--dry-run", "--json"]) + + self.assertEqual(rc, 0) + events = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertEqual(events[0]["type"], "stage") + self.assertEqual(events[-1]["type"], "result") + self.assertEqual(events[-1]["payload"]["finding_count"], 1) + self.assertEqual(events[-1]["payload"]["repairable_count"], 1) + + def test_repair_xattrs_json_repair_requires_yes(self) -> None: + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + repair_xattrs.main(["--path", "/Volumes/Data", "--json"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("--json repair requires --yes", stderr.getvalue()) + def test_bootstrap_prints_full_next_steps(self) -> None: output = io.StringIO() with mock.patch("pathlib.Path.exists", return_value=True): @@ -1557,6 +1589,18 @@ def test_configure_hidden_debug_logging_arg_writes_true(self) -> None: self.assertEqual(result.rc, 0) self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + def test_configure_bonjour_timeout_reaches_discovery(self) -> None: + result = self.run_configure_cli( + ["--bonjour-timeout", "1.25"], + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + result.mocks.discover_resolved_records.assert_called_once() + self.assertEqual(result.mocks.discover_resolved_records.call_args.kwargs["timeout"], 1.25) + def test_configure_preserves_existing_debug_logging_when_arg_is_omitted(self) -> None: result = self.run_configure_cli( existing_values={"TC_DEBUG_LOGGING": "true"}, @@ -4125,6 +4169,64 @@ def test_set_ssh_enable_flow_succeeds(self) -> None: self.assertEqual(finished["ssh_initially_reachable"], False) self.assertEqual(finished["ssh_final_reachable"], True) + def test_set_ssh_status_requires_only_host(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--status"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH enabled.", output.getvalue()) + enable_mock.assert_not_called() + disable_mock.assert_not_called() + finished = self.telemetry_payload("set_ssh_finished") + self.assertEqual(finished["set_ssh_action"], "status") + + def test_set_ssh_explicit_enable_is_noop_when_already_enabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--enable"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH already enabled.", output.getvalue()) + enable_mock.assert_not_called() + + def test_set_ssh_explicit_disable_is_noop_when_already_disabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=False): + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--disable"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH already disabled.", output.getvalue()) + disable_mock.assert_not_called() + + def test_set_ssh_no_wait_skips_enable_verification(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=False): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state") as wait_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--enable", "--no-wait"]) + + self.assertEqual(rc, 0) + enable_mock.assert_called_once() + wait_mock.assert_not_called() + self.assertIn("not waiting for SSH to open", output.getvalue()) + def test_set_ssh_enable_exception_emits_failure_stage(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4279,6 +4381,21 @@ def test_set_ssh_disable_flow_confirms_ssh_disabled(self) -> None: self.assertEqual(finished["ssh_final_reachable"], False) self.assertEqual(finished["ssh_disable_persisted"], True) + def test_set_ssh_yes_disables_legacy_enabled_state_without_prompt(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("builtins.input", side_effect=AssertionError("--yes should skip prompt")) as input_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", side_effect=[True, True]): + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_device_up", return_value=True): + with redirect_stdout(output): + rc = set_ssh.main(["--yes"]) + self.assertEqual(rc, 0) + input_mock.assert_not_called() + disable_mock.assert_called_once() + def test_doctor_json_outputs_structured_results(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4291,6 +4408,16 @@ def test_doctor_json_outputs_structured_results(self) -> None: self.assertEqual(payload["fatal"], False) self.assertEqual(payload["results"][0]["status"], "PASS") + def test_doctor_bonjour_timeout_reaches_checks(self) -> None: + output = io.StringIO() + fake_result = doctor.CheckResult("PASS", "ok") + with mock.patch("timecapsulesmb.cli.doctor.load_env_config", return_value=self.make_app_config({})): + with mock.patch("timecapsulesmb.cli.doctor.run_doctor_checks", return_value=([fake_result], False)) as checks_mock: + with redirect_stdout(output): + rc = doctor.main(["--bonjour-timeout", "2.5"]) + self.assertEqual(rc, 0) + self.assertEqual(checks_mock.call_args.kwargs["bonjour_timeout"], 2.5) + def test_doctor_ensures_install_id_before_telemetry(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4520,6 +4647,39 @@ def fake_upload(_plan, *, connection, source_resolver): self.assertIn("SMBD_DEBUG_LOGGING=0\n", captured["flash_config"]) self.assertIn("MDNS_DEBUG_LOGGING=0\n", captured["flash_config"]) + def test_deploy_no_wait_requests_reboot_without_observation_or_runtime_verify(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + patch_actions=True, + patch_upload=True, + wait_side_effect=AssertionError("deploy --no-wait should not observe SSH state"), + verify_runtime=self.managed_runtime_probe(False), + ) + + self.assertEqual(result.rc, 0) + result.mocks.remote_request_shutdown_reboot.assert_called_once() + result.mocks.wait_for_ssh_state_conn.assert_not_called() + result.mocks.verify_managed_runtime.assert_not_called() + self.assertIn("not waiting for the device", result.text) + + def test_deploy_no_wait_fails_when_reboot_request_fails(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + patch_actions=True, + patch_upload=True, + reboot_side_effect=SshError("ssh command failed with rc=255"), + wait_side_effect=AssertionError("deploy --no-wait should not observe SSH state after request failure"), + verify_runtime=self.managed_runtime_probe(False), + raises=SystemExit, + ) + + self.assertIn("ssh command failed with rc=255", str(result.exception)) + result.mocks.remote_request_shutdown_reboot.assert_called_once() + result.mocks.wait_for_ssh_state_conn.assert_not_called() + result.mocks.verify_managed_runtime.assert_not_called() + finished = self.telemetry_payload("deploy_finished") + self.assertEqual(finished["result"], "failure") + def test_deploy_rejects_removed_install_nbns_flag(self) -> None: stderr = io.StringIO() with redirect_stderr(stderr): @@ -4528,6 +4688,20 @@ def test_deploy_rejects_removed_install_nbns_flag(self) -> None: self.assertEqual(raised.exception.code, 2) self.assertIn("unrecognized arguments: --install-nbns", stderr.getvalue()) + def test_negative_shared_timeouts_are_rejected_by_parsers(self) -> None: + cases = ( + (deploy.main, ["--mount-wait", "-1", "--dry-run"], "must be 0 or greater"), + (doctor.main, ["--bonjour-timeout", "-0.1"], "must be 0 or greater"), + ) + for entrypoint, argv, message in cases: + with self.subTest(argv=argv): + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + entrypoint(argv) + self.assertEqual(raised.exception.code, 2) + self.assertIn(message, stderr.getvalue()) + def test_deploy_exits_when_mast_volumes_are_not_writable(self) -> None: volumes = (self._mast_volume("dk2"),) result = self.run_deploy_cli( @@ -6382,8 +6556,50 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) self.assertNotIn("verify Samba startup", text) finished = command_context.finish.call_args.kwargs self.assertEqual(finished["result"], "success") - self.assertEqual(finished["reboot_was_attempted"], True) - self.assertEqual(finished["device_came_back_after_reboot"], True) + + def test_flash_restore_reboot_no_wait_skips_reboot_observation(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext() + target = SimpleNamespace(connection=SshConnection("root@10.0.0.2", "pw", "-o foo")) + args = argparse.Namespace(reboot=True, no_wait=True) + + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.cli.flash.observe_reboot_cycle") as observe_mock: + with redirect_stdout(output): + rc = cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + self.assertEqual(rc, 0) + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertIn("not waiting for the device", output.getvalue()) + + def test_flash_restore_reboot_no_wait_fails_when_reboot_request_fails(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext() + target = SimpleNamespace(connection=SshConnection("root@10.0.0.2", "pw", "-o foo")) + args = argparse.Namespace(reboot=True, no_wait=True) + + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot_mock: + with mock.patch("timecapsulesmb.cli.flash.observe_reboot_cycle") as observe_mock: + with self.assertRaises(SshError): + with redirect_stdout(output): + cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertNotIn("not waiting for the device", output.getvalue()) def test_flash_restore_noops_when_active_bank_already_matches_apple(self) -> None: output = io.StringIO() @@ -7046,6 +7262,26 @@ def test_activate_returns_nonzero_when_verification_fails(self) -> None: self.assertEqual(rc, 1) self.assertIn("NetBSD4 activation failed.", output.getvalue()) + def test_activate_dry_run_json_outputs_activation_plan(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with mock.patch("timecapsulesmb.cli.activate.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.context.CommandContext.require_compatibility", return_value=self.make_supported_netbsd4_compatibility()): + with redirect_stdout(output): + rc = activate.main(["--dry-run", "--json"]) + self.assertEqual(rc, 0) + payload = json.loads(output.getvalue()) + self.assertIn("actions", payload) + self.assertTrue(all("kind" in action for action in payload["actions"])) + + def test_activate_json_requires_dry_run(self) -> None: + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + activate.main(["--json"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("--json currently requires --dry-run", stderr.getvalue()) + def test_uninstall_dry_run_prints_target_host(self) -> None: output = io.StringIO() values = { @@ -7194,6 +7430,49 @@ def test_uninstall_yes_reboots_and_verifies(self) -> None: self.assertEqual(finished["device_came_back_after_reboot"], True) self.assertEqual(finished["post_uninstall_verified"], True) + def test_uninstall_mount_wait_and_no_wait_skip_reboot_observation_and_verify(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.load_env_config", return_value=self.make_app_config(values))) + mast_mocks = self._patch_mast_volume_flow(stack, "uninstall") + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) + reboot_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.remote_request_reboot")) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn")) + verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) + with redirect_stdout(output): + rc = uninstall.main(["--yes", "--mount-wait", "17", "--no-wait"]) + + self.assertEqual(rc, 0) + self.assertEqual(mast_mocks.mounted_mast_volumes_conn.call_args.kwargs["wait_seconds"], 17) + reboot_mock.assert_called_once() + wait_mock.assert_not_called() + verify_mock.assert_not_called() + self.assertIn("Post-uninstall verification skipped.", output.getvalue()) + + def test_uninstall_no_wait_fails_when_reboot_request_fails(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.load_env_config", return_value=self.make_app_config(values))) + self._patch_mast_volume_flow(stack, "uninstall") + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) + reboot_mock = stack.enter_context( + mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) + ) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn")) + verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) + with self.assertRaises(SystemExit) as raised: + with redirect_stdout(output): + uninstall.main(["--yes", "--no-wait"]) + + self.assertIn("ssh command failed with rc=255", str(raised.exception)) + reboot_mock.assert_called_once() + wait_mock.assert_not_called() + verify_mock.assert_not_called() + finished = self.telemetry_payload("uninstall_finished") + self.assertEqual(finished["result"], "failure") + def test_uninstall_reboot_request_timeout_continues_when_device_reboots(self) -> None: output = io.StringIO() values = self.make_valid_env() @@ -7436,6 +7715,43 @@ def test_fsck_no_wait_skips_ssh_waits(self) -> None: self.assertEqual(rc, 0) observe_mock.assert_not_called() + def test_fsck_list_volumes_mounts_with_custom_wait_without_remote_fsck(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.load_env_config", return_value=self.make_app_config(values))) + mast_mocks = self._patch_mast_volume_flow( + stack, + "fsck", + mounted_volumes=(self._mast_volume("dk2"), self._mast_volume("dk5", builtin=False)), + ) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--list-volumes", "--mount-wait", "11"]) + + self.assertEqual(rc, 0) + self.assertEqual(mast_mocks.mounted_mast_volumes_conn.call_args.kwargs["wait_seconds"], 11) + run_ssh_mock.assert_not_called() + self.assertIn("Mounted HFS volumes:", output.getvalue()) + self.assertIn("/dev/dk5", output.getvalue()) + + def test_fsck_dry_run_selects_target_without_remote_fsck_or_prompt(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.load_env_config", return_value=self.make_app_config(values))) + self._patch_mast_volume_flow(stack, "fsck", mounted_volumes=(self._mast_volume("dk2"),)) + input_mock = stack.enter_context(mock.patch("builtins.input", side_effect=AssertionError("dry run should not prompt"))) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--dry-run"]) + + self.assertEqual(rc, 0) + input_mock.assert_not_called() + run_ssh_mock.assert_not_called() + self.assertIn("Dry run: fsck plan", output.getvalue()) + self.assertIn("/sbin/fsck_hfs -fy /dev/dk2", output.getvalue()) + def test_fsck_no_reboot_omits_reboot_and_waits(self) -> None: output = io.StringIO() values = { From b8fc311a9fd872056157de925780bc0e55ec3812 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 00:12:40 -0700 Subject: [PATCH 07/13] Helper hardening, localized UI strings, and centralized Swift operation params --- macos/TimeCapsuleSMB/Package.swift | 4 +- .../TimeCapsuleSMBApp/ContentView.swift | 196 ++++++++++-------- .../TimeCapsuleSMBApp/HelperRunner.swift | 2 +- .../TimeCapsuleSMBApp/Localization.swift | 7 + .../TimeCapsuleSMBApp/OperationParams.swift | 125 +++++++++++ .../PendingConfirmation.swift | 94 ++++----- .../Resources/en.lproj/Localizable.strings | 68 ++++++ .../HelperRunnerTests.swift | 24 +++ .../PendingConfirmationTests.swift | 14 ++ src/timecapsulesmb/app/helper.py | 15 +- src/timecapsulesmb/services/app.py | 3 + tests/test_app_api.py | 79 +++++++ 12 files changed, 486 insertions(+), 145 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift index a5ccd2a6..b29a7506 100644 --- a/macos/TimeCapsuleSMB/Package.swift +++ b/macos/TimeCapsuleSMB/Package.swift @@ -13,6 +13,7 @@ let xcodeLinkerSettings: [LinkerSetting] = xcodeFrameworkFlags.isEmpty ? [] : [. let package = Package( name: "TimeCapsuleSMBMac", + defaultLocalization: "en", platforms: [.macOS(.v13)], products: [ .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) @@ -20,7 +21,8 @@ let package = Package( targets: [ .target( name: "TimeCapsuleSMBApp", - path: "Sources/TimeCapsuleSMBApp" + path: "Sources/TimeCapsuleSMBApp", + resources: [.process("Resources")] ), .executableTarget( name: "TimeCapsuleSMBExecutable", diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 48fa7c2d..46de61c5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -37,13 +37,13 @@ public struct ContentView: View { Button { backend.clear() } label: { - Label("Clear", systemImage: "trash") + Label(L10n.string("toolbar.clear"), systemImage: "trash") } .disabled(backend.isRunning) Button { backend.cancel() } label: { - Label("Cancel", systemImage: "xmark.circle") + Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") } .disabled(!backend.isRunning) } @@ -59,7 +59,7 @@ public struct ContentView: View { backend.run(operation: confirmation.operation, params: confirmation.params) pendingConfirmation = nil } - Button("Cancel", role: .cancel) { + Button(L10n.string("action.cancel"), role: .cancel) { pendingConfirmation = nil } } message: { confirmation in @@ -82,56 +82,61 @@ public struct ContentView: View { private var form: some View { switch selection { case .readiness: - CommandPanel(title: "Readiness") { - TextField("Helper", text: $backend.helperPath) + CommandPanel(title: L10n.string("screen.readiness")) { + TextField(L10n.string("field.helper"), text: $backend.helperPath) HStack { - runButton("Paths", icon: "folder", operation: "paths") - runButton("Validate", icon: "checkmark.seal", operation: "validate-install") + runButton(L10n.string("button.paths"), icon: "folder", operation: "paths") + runButton(L10n.string("button.validate"), icon: "checkmark.seal", operation: "validate-install") } } case .connect: - CommandPanel(title: "Discover And Connect") { - TextField("Host", text: $host) - SecureField("Password", text: $password) - TextField("Bonjour timeout seconds", text: $bonjourTimeout) - Toggle("Enable Debug Logging", isOn: $configureDebugLogging) + CommandPanel(title: L10n.string("panel.connect")) { + TextField(L10n.string("field.host"), text: $host) + SecureField(L10n.string("field.password"), text: $password) + TextField(L10n.string("field.bonjour_timeout"), text: $bonjourTimeout) + Toggle(L10n.string("toggle.enable_debug_logging"), isOn: $configureDebugLogging) HStack { - runButton("Discover", icon: "network", operation: "discover", params: [ - "timeout": numberValue(bonjourTimeout, default: 6) - ]) + runButton( + L10n.string("button.discover"), + icon: "network", + operation: "discover", + params: OperationParams.discover(timeout: numberDouble(bonjourTimeout, default: 6)) + ) Button { - var params: [String: JSONValue] = [ - "host": .string(host), - "password": .string(password) - ] - if configureDebugLogging { - params["debug_logging"] = .bool(true) - } - backend.run(operation: "configure", params: params) + backend.run( + operation: "configure", + params: OperationParams.configure( + host: host, + password: password, + debugLogging: configureDebugLogging + ) + ) } label: { - Label("Configure", systemImage: "lock.open") + Label(L10n.string("button.configure"), systemImage: "lock.open") } .disabled(backend.isRunning || password.isEmpty) } } case .deploy: - CommandPanel(title: "Deploy") { - Toggle("Enable NBNS", isOn: $nbnsEnabled) - Toggle("No Reboot", isOn: $noReboot) - Toggle("No Wait", isOn: $noWait) - Toggle("Dry Run", isOn: $dryRun) - Toggle("Force Debug Logging", isOn: $deployDebugLogging) - TextField("Mount wait seconds", text: $mountWait) + CommandPanel(title: L10n.string("screen.deploy")) { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) + Toggle(L10n.string("toggle.dry_run"), isOn: $dryRun) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $deployDebugLogging) + TextField(L10n.string("field.mount_wait"), text: $mountWait) Button { if dryRun { - backend.run(operation: "deploy", params: [ - "dry_run": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(deployDebugLogging), - "mount_wait": numberValue(mountWait, default: 30) - ]) + backend.run( + operation: "deploy", + params: OperationParams.deployPlan( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging, + mountWait: numberDouble(mountWait, default: 30) + ) + ) } else { pendingConfirmation = .deploy( noReboot: noReboot, @@ -142,37 +147,47 @@ public struct ContentView: View { ) } } label: { - Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") + Label( + dryRun ? L10n.string("button.plan_deploy") : L10n.string("button.deploy"), + systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up" + ) } .disabled(backend.isRunning) } case .doctor: - CommandPanel(title: "Doctor") { - TextField("Bonjour timeout seconds", text: $bonjourTimeout) - runButton("Run Doctor", icon: "stethoscope", operation: "doctor", params: [ - "bonjour_timeout": numberValue(bonjourTimeout, default: 6) - ]) + CommandPanel(title: L10n.string("screen.doctor")) { + TextField(L10n.string("field.bonjour_timeout"), text: $bonjourTimeout) + runButton( + L10n.string("button.run_doctor"), + icon: "stethoscope", + operation: "doctor", + params: OperationParams.doctor(bonjourTimeout: numberDouble(bonjourTimeout, default: 6)) + ) } case .maintenance: - CommandPanel(title: "Maintenance") { - TextField("Repair xattrs path", text: $repairPath) - TextField("fsck volume, optional", text: $volume) - TextField("Mount wait seconds", text: $mountWait) - Toggle("No Reboot", isOn: $noReboot) - Toggle("No Wait", isOn: $noWait) + CommandPanel(title: L10n.string("screen.maintenance")) { + TextField(L10n.string("field.repair_xattrs_path"), text: $repairPath) + TextField(L10n.string("field.fsck_volume"), text: $volume) + TextField(L10n.string("field.mount_wait"), text: $mountWait) + Toggle(L10n.string("toggle.no_reboot"), isOn: $noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) HStack { Button { pendingConfirmation = .activate() } label: { - Label("Activate", systemImage: "power") + Label(L10n.string("button.activate"), systemImage: "power") } .disabled(backend.isRunning) - runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: [ - "dry_run": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": numberValue(mountWait, default: 30) - ]) + runButton( + L10n.string("button.uninstall_plan"), + icon: "xmark.bin", + operation: "uninstall", + params: OperationParams.uninstallPlan( + noReboot: noReboot, + noWait: noWait, + mountWait: numberDouble(mountWait, default: 30) + ) + ) Button { pendingConfirmation = .uninstall( noReboot: noReboot, @@ -180,22 +195,28 @@ public struct ContentView: View { noWait: noWait ) } label: { - Label("Uninstall", systemImage: "xmark.bin.fill") + Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") } .disabled(backend.isRunning) } HStack { - runButton("List fsck Volumes", icon: "list.bullet.rectangle", operation: "fsck", params: [ - "list_volumes": .bool(true), - "mount_wait": numberValue(mountWait, default: 30) - ]) - runButton("Plan fsck", icon: "doc.text.magnifyingglass", operation: "fsck", params: [ - "dry_run": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": numberValue(mountWait, default: 30), - "volume": .string(volume) - ]) + runButton( + L10n.string("button.list_fsck_volumes"), + icon: "list.bullet.rectangle", + operation: "fsck", + params: OperationParams.fsckList(mountWait: numberDouble(mountWait, default: 30)) + ) + runButton( + L10n.string("button.plan_fsck"), + icon: "doc.text.magnifyingglass", + operation: "fsck", + params: OperationParams.fsckPlan( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: numberDouble(mountWait, default: 30) + ) + ) Button { pendingConfirmation = .fsck( volume: volume, @@ -204,33 +225,33 @@ public struct ContentView: View { noWait: noWait ) } label: { - Label("Run fsck", systemImage: "externaldrive.badge.checkmark") + Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") } .disabled(backend.isRunning) } HStack { Button { - backend.run(operation: "repair-xattrs", params: [ - "path": .string(repairPath), - "dry_run": .bool(true) - ]) + backend.run( + operation: "repair-xattrs", + params: OperationParams.repairXattrsScan(path: repairPath) + ) } label: { - Label("Scan xattrs", systemImage: "wand.and.stars") + Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") } .disabled(backend.isRunning || repairPath.isEmpty) Button { pendingConfirmation = .repairXattrs(path: repairPath) } label: { - Label("Repair xattrs", systemImage: "wand.and.stars.inverse") + Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") } .disabled(backend.isRunning || repairPath.isEmpty) } } case .advanced: - CommandPanel(title: "Advanced") { - Text("Flash backup, patch, and restore remain CLI-only in this version.") + CommandPanel(title: L10n.string("screen.advanced")) { + Text(L10n.string("advanced.flash_cli_only")) .foregroundStyle(.secondary) - Text("Use `.venv/bin/tcapsule flash --help` for firmware operations.") + Text(L10n.string("advanced.flash_help")) .font(.system(.body, design: .monospaced)) } } @@ -255,9 +276,6 @@ public struct ContentView: View { return Double(trimmed) ?? defaultValue } - private func numberValue(_ text: String, default defaultValue: Double) -> JSONValue { - .number(numberDouble(text, default: defaultValue)) - } } private enum Screen: String, CaseIterable, Identifiable { @@ -272,12 +290,12 @@ private enum Screen: String, CaseIterable, Identifiable { var title: String { switch self { - case .readiness: return "Readiness" - case .connect: return "Connect" - case .deploy: return "Deploy" - case .doctor: return "Doctor" - case .maintenance: return "Maintenance" - case .advanced: return "Advanced" + case .readiness: return L10n.string("screen.readiness") + case .connect: return L10n.string("screen.connect") + case .deploy: return L10n.string("screen.deploy") + case .doctor: return L10n.string("screen.doctor") + case .maintenance: return L10n.string("screen.maintenance") + case .advanced: return L10n.string("screen.advanced") } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index cbef5bcf..b3112a28 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -134,7 +134,7 @@ public final class HelperRunner { output.append(data.prefix(limit - output.count)) } } - return String(data: output, encoding: .utf8) ?? "" + return String(decoding: output, as: UTF8.self) } private static func waitForExit(_ process: Process) async { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift new file mode 100644 index 00000000..54586039 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift @@ -0,0 +1,7 @@ +import Foundation + +enum L10n { + static func string(_ key: String) -> String { + NSLocalizedString(key, bundle: .module, comment: "") + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift new file mode 100644 index 00000000..d4f487fa --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -0,0 +1,125 @@ +import Foundation + +enum OperationParams { + static func discover(timeout: Double) -> [String: JSONValue] { + ["timeout": .number(timeout)] + } + + static func configure(host: String, password: String, debugLogging: Bool) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "host": .string(host), + "password": .string(password) + ] + if debugLogging { + params["debug_logging"] = .bool(true) + } + return params + } + + static func doctor(bonjourTimeout: Double) -> [String: JSONValue] { + ["bonjour_timeout": .number(bonjourTimeout)] + } + + static func deployPlan( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double + ) -> [String: JSONValue] { + [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ] + } + + static func deployConfirmed( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double + ) -> [String: JSONValue] { + [ + "dry_run": .bool(false), + "confirm_deploy": .bool(true), + "confirm_reboot": .bool(!noReboot), + "confirm_netbsd4_activation": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ] + } + + static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ] + } + + static func uninstallConfirmed(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(false), + "confirm_uninstall": .bool(true), + "confirm_reboot": .bool(!noReboot), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ] + } + + static func activateConfirmed() -> [String: JSONValue] { + ["confirm_netbsd4_activation": .bool(true)] + } + + static func fsckList(mountWait: Double) -> [String: JSONValue] { + [ + "list_volumes": .bool(true), + "mount_wait": .number(mountWait) + ] + } + + static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ] + } + + static func fsckConfirmed(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "confirm_fsck": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ] + } + + static func repairXattrsScan(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(true) + ] + } + + static func repairXattrsConfirmed(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(false), + "confirm_repair": .bool(true) + ] + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 7a777460..41f13f12 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -10,90 +10,78 @@ struct PendingConfirmation: Identifiable { static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Deploy Without Reboot?" : (noWait ? "Deploy And Skip Waiting?" : "Deploy And Reboot?"), + title: noReboot ? L10n.string("confirm.deploy.no_reboot.title") : (noWait ? L10n.string("confirm.deploy.no_wait.title") : L10n.string("confirm.deploy.reboot.title")), message: noReboot - ? "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device." + ? L10n.string("confirm.deploy.no_reboot.message") : (noWait - ? "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device." - : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."), - actionTitle: noReboot ? "Deploy" : "Deploy And Allow Reboot", + ? L10n.string("confirm.deploy.no_wait.message") + : L10n.string("confirm.deploy.reboot.message")), + actionTitle: noReboot ? L10n.string("action.deploy") : L10n.string("action.deploy_allow_reboot"), operation: "deploy", - params: [ - "dry_run": .bool(false), - "confirm_deploy": .bool(true), - "confirm_reboot": .bool(!noReboot), - "confirm_netbsd4_activation": .bool(true), - "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(debugLogging), - "mount_wait": .number(mountWait), - "no_wait": .bool(noWait) - ] + params: OperationParams.deployConfirmed( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: debugLogging, + mountWait: mountWait + ) ) } static func activate() -> PendingConfirmation { PendingConfirmation( - title: "Activate NetBSD 4 Runtime?", - message: "This will restart the deployed Samba runtime on an older NetBSD 4 device.", - actionTitle: "Activate", + title: L10n.string("confirm.activate.title"), + message: L10n.string("confirm.activate.message"), + actionTitle: L10n.string("action.activate"), operation: "activate", - params: ["confirm_netbsd4_activation": .bool(true)] + params: OperationParams.activateConfirmed() ) } static func fsck(volume: String, noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Run Disk Repair Without Reboot?" : (noWait ? "Run Disk Repair And Skip Waiting?" : "Run Disk Repair And Reboot?"), + title: noReboot ? L10n.string("confirm.fsck.no_reboot.title") : (noWait ? L10n.string("confirm.fsck.no_wait.title") : L10n.string("confirm.fsck.reboot.title")), message: noReboot - ? "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward." + ? L10n.string("confirm.fsck.no_reboot.message") : (noWait - ? "This will run fsck on the selected Time Capsule disk and return after requesting reboot." - : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."), - actionTitle: "Run fsck", + ? L10n.string("confirm.fsck.no_wait.message") + : L10n.string("confirm.fsck.reboot.message")), + actionTitle: L10n.string("action.run_fsck"), operation: "fsck", - params: [ - "confirm_fsck": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": .number(mountWait), - "volume": .string(volume) - ] + params: OperationParams.fsckConfirmed( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: mountWait + ) ) } static func uninstall(noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Uninstall Without Reboot?" : (noWait ? "Uninstall And Skip Waiting?" : "Uninstall And Reboot?"), + title: noReboot ? L10n.string("confirm.uninstall.no_reboot.title") : (noWait ? L10n.string("confirm.uninstall.no_wait.title") : L10n.string("confirm.uninstall.reboot.title")), message: noReboot - ? "This will remove the managed TimeCapsuleSMB payload without rebooting the device." + ? L10n.string("confirm.uninstall.no_reboot.message") : (noWait - ? "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting." - : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."), - actionTitle: "Uninstall", + ? L10n.string("confirm.uninstall.no_wait.message") + : L10n.string("confirm.uninstall.reboot.message")), + actionTitle: L10n.string("action.uninstall"), operation: "uninstall", - params: [ - "dry_run": .bool(false), - "confirm_uninstall": .bool(true), - "confirm_reboot": .bool(!noReboot), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": .number(mountWait) - ] + params: OperationParams.uninstallConfirmed( + noReboot: noReboot, + noWait: noWait, + mountWait: mountWait + ) ) } static func repairXattrs(path: String) -> PendingConfirmation { PendingConfirmation( - title: "Repair Extended Attributes?", - message: "This will repair extended attributes at the selected mounted SMB path.", - actionTitle: "Repair xattrs", + title: L10n.string("confirm.repair_xattrs.title"), + message: L10n.string("confirm.repair_xattrs.message"), + actionTitle: L10n.string("action.repair_xattrs"), operation: "repair-xattrs", - params: [ - "path": .string(path), - "dry_run": .bool(false), - "confirm_repair": .bool(true) - ] + params: OperationParams.repairXattrsConfirmed(path: path) ) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..f44ed3c3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -0,0 +1,68 @@ +"action.activate" = "Activate"; +"action.cancel" = "Cancel"; +"action.deploy" = "Deploy"; +"action.deploy_allow_reboot" = "Deploy And Allow Reboot"; +"action.repair_xattrs" = "Repair xattrs"; +"action.run_fsck" = "Run fsck"; +"action.uninstall" = "Uninstall"; +"advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; +"advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; +"button.activate" = "Activate"; +"button.configure" = "Configure"; +"button.deploy" = "Deploy"; +"button.discover" = "Discover"; +"button.list_fsck_volumes" = "List fsck Volumes"; +"button.paths" = "Paths"; +"button.plan_deploy" = "Plan Deploy"; +"button.plan_fsck" = "Plan fsck"; +"button.repair_xattrs" = "Repair xattrs"; +"button.run_doctor" = "Run Doctor"; +"button.run_fsck" = "Run fsck"; +"button.scan_xattrs" = "Scan xattrs"; +"button.uninstall" = "Uninstall"; +"button.uninstall_plan" = "Uninstall Plan"; +"button.validate" = "Validate"; +"confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; +"confirm.activate.title" = "Activate NetBSD 4 Runtime?"; +"confirm.deploy.no_reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device."; +"confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; +"confirm.deploy.no_wait.message" = "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device."; +"confirm.deploy.no_wait.title" = "Deploy And Skip Waiting?"; +"confirm.deploy.reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."; +"confirm.deploy.reboot.title" = "Deploy And Reboot?"; +"confirm.fsck.no_reboot.message" = "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward."; +"confirm.fsck.no_reboot.title" = "Run Disk Repair Without Reboot?"; +"confirm.fsck.no_wait.message" = "This will run fsck on the selected Time Capsule disk and return after requesting reboot."; +"confirm.fsck.no_wait.title" = "Run Disk Repair And Skip Waiting?"; +"confirm.fsck.reboot.message" = "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."; +"confirm.fsck.reboot.title" = "Run Disk Repair And Reboot?"; +"confirm.repair_xattrs.message" = "This will repair extended attributes at the selected mounted SMB path."; +"confirm.repair_xattrs.title" = "Repair Extended Attributes?"; +"confirm.uninstall.no_reboot.message" = "This will remove the managed TimeCapsuleSMB payload without rebooting the device."; +"confirm.uninstall.no_reboot.title" = "Uninstall Without Reboot?"; +"confirm.uninstall.no_wait.message" = "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting."; +"confirm.uninstall.no_wait.title" = "Uninstall And Skip Waiting?"; +"confirm.uninstall.reboot.message" = "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."; +"confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"field.bonjour_timeout" = "Bonjour timeout seconds"; +"field.fsck_volume" = "fsck volume, optional"; +"field.helper" = "Helper"; +"field.host" = "Host"; +"field.mount_wait" = "Mount wait seconds"; +"field.password" = "Password"; +"field.repair_xattrs_path" = "Repair xattrs path"; +"panel.connect" = "Discover And Connect"; +"screen.advanced" = "Advanced"; +"screen.connect" = "Connect"; +"screen.deploy" = "Deploy"; +"screen.doctor" = "Doctor"; +"screen.maintenance" = "Maintenance"; +"screen.readiness" = "Readiness"; +"toggle.dry_run" = "Dry Run"; +"toggle.enable_debug_logging" = "Enable Debug Logging"; +"toggle.enable_nbns" = "Enable NBNS"; +"toggle.force_debug_logging" = "Force Debug Logging"; +"toggle.no_reboot" = "No Reboot"; +"toggle.no_wait" = "No Wait"; +"toolbar.cancel" = "Cancel"; +"toolbar.clear" = "Clear"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index d595d2a0..8f15890c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -77,6 +77,30 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(recorder.events.last?.ok, true) } + func testRunnerDecodesTruncatedUTF8StderrWithReplacementCharacter() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + printf '\\303\\251' >&2 + """ + ) + let runner = HelperRunner( + locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default), + stderrLimit: 1 + ) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + recorder.append($0) + } + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr, "\u{FFFD}") + XCTAssertEqual(recorder.events.last?.code, "missing_terminal_event") + } + func testRunnerReportsMissingHelper() async { let locator = HelperLocator(environment: [:], currentDirectory: URL(fileURLWithPath: NSTemporaryDirectory()), bundle: .main, fileManager: .default) let runner = HelperRunner(locator: locator) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index ec07cb43..23b2da49 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -2,6 +2,20 @@ import XCTest @testable import TimeCapsuleSMBApp final class PendingConfirmationTests: XCTestCase { + func testLocalizedStringsLoadFromResourceBundle() { + XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") + XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + } + + func testUninstallPlanParamsCarryNoRebootSelection() { + let params = OperationParams.uninstallPlan(noReboot: true, noWait: true, mountWait: 9) + + XCTAssertEqual(params["dry_run"], .bool(true)) + XCTAssertEqual(params["no_reboot"], .bool(true)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertEqual(params["mount_wait"], .number(9)) + } + func testDeployConfirmationCarriesDeployAndRebootConsent() { let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true, mountWait: 45, noWait: true) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py index f35d0988..15178b9b 100644 --- a/src/timecapsulesmb/app/helper.py +++ b/src/timecapsulesmb/app/helper.py @@ -11,6 +11,9 @@ from timecapsulesmb.app.service import run_api_request +MAX_REQUEST_CHARS = 1024 * 1024 + + def _sink_for_stream(stream: TextIO) -> EventSink: def emit(event: AppEvent) -> None: stream.write(event.to_json_line()) @@ -29,7 +32,17 @@ def main(argv: Optional[list[str]] = None) -> int: args = parser.parse_args(argv) sink = _sink_for_stream(sys.stdout).with_request_id(str(uuid.uuid4())) - raw = sys.stdin.read() + raw = sys.stdin.read(MAX_REQUEST_CHARS + 1) + if len(raw) > MAX_REQUEST_CHARS: + sink.error( + "api", + f"request exceeds maximum size of {MAX_REQUEST_CHARS} characters", + code="invalid_request", + recovery=recovery_for("api", "invalid_request"), + ) + if args.pretty_error: + print("request too large", file=sys.stderr) + return 1 try: request = json.loads(raw) except json.JSONDecodeError as exc: diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index c9e75bd0..af004a66 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum import math from pathlib import Path @@ -29,6 +30,8 @@ class OperationResult: def jsonable(value: object) -> object: if is_dataclass(value): return jsonable(asdict(value)) + if isinstance(value, Enum): + return jsonable(value.value) if isinstance(value, Path): return str(value) if isinstance(value, dict): diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 1a40d9de..64424caa 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass +from enum import Enum import io import json import sys @@ -27,10 +29,20 @@ from timecapsulesmb.device.storage import MaStVolume from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.services.app import jsonable from timecapsulesmb.transport.errors import SshError, TransportError from timecapsulesmb.transport.ssh import SshConnection +class SampleMode(Enum): + FAST = "fast" + + +@dataclass(frozen=True) +class SamplePayload: + mode: SampleMode + + class CollectingSink: def __init__(self) -> None: self.events: list[dict[str, object]] = [] @@ -149,6 +161,9 @@ def test_result_event_preserves_falsey_payloads(self) -> None: self.assertEqual(result["schema_version"], 1) self.assertTrue(result["request_id"]) + def test_jsonable_serializes_enum_values_inside_dataclasses(self) -> None: + self.assertEqual(jsonable(SamplePayload(SampleMode.FAST)), {"mode": "fast"}) + def test_stage_events_include_policy_metadata(self) -> None: collector = CollectingSink() @@ -477,6 +492,32 @@ def test_configure_reports_unsupported_device(self) -> None: self.assertFalse(config_path.exists()) self.assertEqual(collector.events_of_type("error")[0]["code"], "unsupported_device") + def test_configure_rejects_boolean_ssh_wait_timeout(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.operations.enable_ssh") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "pw", + "ssh_wait_timeout": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_called_once() + self.assertFalse(config_path.exists()) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("ssh_wait_timeout must be an integer", error["message"]) + def test_doctor_streams_check_events(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -629,6 +670,27 @@ def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: self.assertEqual(error["code"], "confirmation_required") load_config.assert_not_called() + def test_deploy_rejects_boolean_mount_wait_before_remote_connection(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": True, + "mount_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + load_config.assert_not_called() + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("mount_wait must be an integer", error["message"]) + def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -1167,6 +1229,23 @@ def test_helper_rejects_invalid_json_without_leaking_pretty_error_details(self) self.assertEqual(event["code"], "invalid_request") self.assertNotIn("secret", error_output.getvalue()) + def test_helper_rejects_oversized_request_without_leaking_body(self) -> None: + output = io.StringIO() + error_output = io.StringIO() + secret = "secret" + oversized = secret + ("x" * (helper.MAX_REQUEST_CHARS + 1)) + with mock.patch.object(sys, "stdin", io.StringIO(oversized)): + with redirect_stdout(output): + with mock.patch.object(sys, "stderr", error_output): + rc = helper.main(["--pretty-error"]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["code"], "invalid_request") + self.assertIn("maximum size", event["message"]) + self.assertNotIn(secret, error_output.getvalue()) + def test_helper_rejects_top_level_non_object_json(self) -> None: output = io.StringIO() with mock.patch.object(sys, "stdin", io.StringIO('["paths"]')): From 3db812a843228d556d78a3e678669e4325582255 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 01:12:59 -0700 Subject: [PATCH 08/13] Validate repair-xattrs API paths and simplify set-ssh action flow --- src/timecapsulesmb/app/ops/maintenance.py | 5 +- src/timecapsulesmb/cli/set_ssh.py | 177 ++++++++++++---------- src/timecapsulesmb/services/app.py | 15 ++ tests/test_app_api.py | 31 ++++ tests/test_cli.py | 42 +++++ 5 files changed, 188 insertions(+), 82 deletions(-) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index a5966a29..5b405526 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -4,7 +4,6 @@ import shlex import sys from contextlib import redirect_stderr, redirect_stdout -from pathlib import Path from timecapsulesmb.app.contracts import ( activation_plan_payload, @@ -57,6 +56,7 @@ int_param, jsonable, optional_int_param, + required_path_param, string_param, ) from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE @@ -310,9 +310,10 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera code="validation_failed", ) sink.stage(operation, "validate_params") + path = required_path_param(params, "path") config = load_optional_env_config(env_path=config_path(params)) args = argparse.Namespace( - path=Path(str(params["path"])) if params.get("path") else None, + path=path, dry_run=dry_run, yes=confirm_repair, recursive=bool_param(params, "recursive", True), diff --git a/src/timecapsulesmb/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index a1314885..dd95086a 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from enum import Enum from typing import Optional from timecapsulesmb.cli.context import CommandContext @@ -28,6 +29,22 @@ def _looks_like_ssh_auth_failure(output: str) -> bool: return "permission denied" in lowered or "please try again" in lowered +class SetSshAction(Enum): + ENABLE = "enable_ssh" + ENABLE_NOOP = "enable_noop" + DISABLE = "disable_ssh" + DISABLE_NOOP = "disable_noop" + PROMPT_DISABLE = "prompt_disable_ssh" + + +def select_set_ssh_action(*, explicit_enable: bool, explicit_disable: bool, ssh_open: bool) -> SetSshAction: + if explicit_enable: + return SetSshAction.ENABLE_NOOP if ssh_open else SetSshAction.ENABLE + if explicit_disable: + return SetSshAction.DISABLE if ssh_open else SetSshAction.DISABLE_NOOP + return SetSshAction.PROMPT_DISABLE if ssh_open else SetSshAction.ENABLE + + def disable_ssh_over_ssh( connection: SshConnection, *, @@ -109,17 +126,20 @@ def main(argv: Optional[list[str]] = None) -> int: return 0 assert connection is not None - should_enable = args.enable or (not args.disable and not ssh_open) - should_disable = args.disable or (not args.enable and ssh_open) - - if should_enable: - if ssh_open: - command_context.update_fields(set_ssh_action="enable_noop", ssh_final_reachable=True) - print("SSH already enabled.") - command_context.succeed() - return 0 + action = select_set_ssh_action( + explicit_enable=args.enable, + explicit_disable=args.disable, + ssh_open=ssh_open, + ) + + if action is SetSshAction.ENABLE_NOOP: + command_context.update_fields(set_ssh_action=action.value, ssh_final_reachable=True) + print("SSH already enabled.") + command_context.succeed() + return 0 - command_context.update_fields(set_ssh_action="enable_ssh") + if action is SetSshAction.ENABLE: + command_context.update_fields(set_ssh_action=action.value) print("SSH not reachable. Attempting to enable via ACP...") try: command_context.set_stage("enable_ssh") @@ -149,85 +169,82 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 - if should_disable: - if not ssh_open: - command_context.update_fields(set_ssh_action="disable_noop", ssh_final_reachable=False) - print("SSH already disabled.") - command_context.succeed() - return 0 + if action is SetSshAction.DISABLE_NOOP: + command_context.update_fields(set_ssh_action=action.value, ssh_final_reachable=False) + print("SSH already disabled.") + command_context.succeed() + return 0 + if action is SetSshAction.PROMPT_DISABLE: command_context.set_stage("prompt_disable_ssh") - if not args.disable and not args.yes: - should_disable = confirm( + if not args.yes: + confirmed = confirm( "SSH already enabled. Disable?", default=False, eof_default=False, interrupt_default=False, ) - if not should_disable: + else: + confirmed = True + if not confirmed: command_context.update_fields(set_ssh_action="leave_enabled", ssh_final_reachable=True) print("Leaving SSH enabled.") command_context.succeed() return 0 + action = SetSshAction.DISABLE - if should_disable: - command_context.update_fields(set_ssh_action="disable_ssh") - try: - command_context.set_stage("disable_ssh") - disable_ssh_over_ssh(connection, reboot_device=True, log=print) - except Exception as e: - error_text = str(e) - message = f"Failed to disable SSH over SSH: {error_text}" - print(color_red("Failed to disable SSH over SSH:")) - print(error_text) - command_context.fail_with_error(message) - return 1 - - if args.no_wait: - command_context.update_fields(ssh_verification_skipped=True) - print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") - command_context.succeed() - return 0 - - print("Device is starting reboot now, waiting for it to shut down...") - command_context.set_stage("wait_for_ssh_down") - if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): - message = "SSH did not close after disable/reboot request; disable could not be verified." - command_context.update_fields( - ssh_final_reachable=True, - ssh_disable_persisted=False, - ssh_reboot_observed_down=False, - ) - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - print("Device is down now, verifying persistence after reboot...") - command_context.update_fields(ssh_reboot_observed_down=True) - command_context.set_stage("wait_for_device_up") - if not wait_for_device_up(acp_host): - message = "Device went down after disable request but did not come back within timeout." - command_context.update_fields(device_recovered=False) - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - command_context.update_fields(device_recovered=True) - print("Device successfully rebooted. Checking if SSH is still disabled...") - command_context.set_stage("verify_ssh_disabled") - if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, timeout_seconds=30, service_name="SSH port"): - command_context.update_fields(ssh_final_reachable=True, ssh_disable_persisted=False) - message = "SSH reopened after reboot. Disable did not persist." - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - else: - command_context.update_fields(ssh_final_reachable=False, ssh_disable_persisted=True) - print("SSH disabled (remains closed after reboot). Enable SSH again if this was not intended.") - command_context.succeed() - return 0 - - command_context.fail_with_error("No set-ssh action selected.") - return 1 - return 1 + command_context.update_fields(set_ssh_action=action.value) + try: + command_context.set_stage("disable_ssh") + disable_ssh_over_ssh(connection, reboot_device=True, log=print) + except Exception as e: + error_text = str(e) + message = f"Failed to disable SSH over SSH: {error_text}" + print(color_red("Failed to disable SSH over SSH:")) + print(error_text) + command_context.fail_with_error(message) + return 1 + + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") + command_context.succeed() + return 0 + + print("Device is starting reboot now, waiting for it to shut down...") + command_context.set_stage("wait_for_ssh_down") + if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): + message = "SSH did not close after disable/reboot request; disable could not be verified." + command_context.update_fields( + ssh_final_reachable=True, + ssh_disable_persisted=False, + ssh_reboot_observed_down=False, + ) + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + print("Device is down now, verifying persistence after reboot...") + command_context.update_fields(ssh_reboot_observed_down=True) + command_context.set_stage("wait_for_device_up") + if not wait_for_device_up(acp_host): + message = "Device went down after disable request but did not come back within timeout." + command_context.update_fields(device_recovered=False) + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + command_context.update_fields(device_recovered=True) + print("Device successfully rebooted. Checking if SSH is still disabled...") + command_context.set_stage("verify_ssh_disabled") + if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, timeout_seconds=30, service_name="SSH port"): + command_context.update_fields(ssh_final_reachable=True, ssh_disable_persisted=False) + message = "SSH reopened after reboot. Disable did not persist." + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + command_context.update_fields(ssh_final_reachable=False, ssh_disable_persisted=True) + print("SSH disabled (remains closed after reboot). Enable SSH again if this was not intended.") + command_context.succeed() + return 0 diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index af004a66..64723ebe 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -130,3 +130,18 @@ def require_string_param(params: dict[str, object], name: str) -> str: if not value: raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") return value + + +def required_path_param(params: dict[str, object], name: str) -> Path: + value = params.get(name) + if value is None: + raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") + if isinstance(value, Path): + path_text = str(value).strip() + elif isinstance(value, str): + path_text = value.strip() + else: + raise AppOperationError(f"{name} must be a path string", code="validation_failed") + if not path_text: + raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") + return Path(path_text) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 64424caa..4c08c1ef 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1125,6 +1125,37 @@ def fake_runner(*_args, **_kwargs): self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + def test_repair_xattrs_rejects_invalid_path_before_runner(self) -> None: + cases = [ + ({}, "missing required parameter: path"), + ({"path": ""}, "missing required parameter: path"), + ({"path": " "}, "missing required parameter: path"), + ({"path": True}, "path must be a path string"), + ] + for extra_params, message in cases: + with self.subTest(extra_params=extra_params): + collector = CollectingSink() + params = {"dry_run": True} + params.update(extra_params) + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["message"], message) + self.assertEqual(error["recovery"]["title"], "Invalid repair options") + load_config.assert_not_called() + runner.assert_not_called() + def test_repair_xattrs_rejects_invalid_max_depth_before_runner(self) -> None: for max_depth in ("bad", -1, True): with self.subTest(max_depth=max_depth): diff --git a/tests/test_cli.py b/tests/test_cli.py index 99492269..ddef0f30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4151,6 +4151,30 @@ def test_set_ssh_returns_error_when_env_missing(self) -> None: self.assertIn("stage=load_config", finished["error"]) self.assertNotIn("TC_PASSWORD", finished["error"]) + def test_set_ssh_action_selection_covers_cli_modes(self) -> None: + cases = [ + (False, False, False, set_ssh.SetSshAction.ENABLE), + (False, False, True, set_ssh.SetSshAction.PROMPT_DISABLE), + (True, False, False, set_ssh.SetSshAction.ENABLE), + (True, False, True, set_ssh.SetSshAction.ENABLE_NOOP), + (False, True, False, set_ssh.SetSshAction.DISABLE_NOOP), + (False, True, True, set_ssh.SetSshAction.DISABLE), + ] + for explicit_enable, explicit_disable, ssh_open, expected in cases: + with self.subTest( + explicit_enable=explicit_enable, + explicit_disable=explicit_disable, + ssh_open=ssh_open, + ): + self.assertIs( + set_ssh.select_set_ssh_action( + explicit_enable=explicit_enable, + explicit_disable=explicit_disable, + ssh_open=ssh_open, + ), + expected, + ) + def test_set_ssh_enable_flow_succeeds(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4287,6 +4311,24 @@ def test_set_ssh_disable_failure_is_reported_as_ssh_error(self) -> None: self.assertNotIn("AirPyrt", finished["error"]) self.assertNotIn(ANSI_RED, finished["error"]) + def test_set_ssh_legacy_enabled_state_can_leave_ssh_enabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("builtins.input", return_value="n"): + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main([]) + + self.assertEqual(rc, 0) + self.assertIn("Leaving SSH enabled.", output.getvalue()) + disable_mock.assert_not_called() + finished = self.telemetry_payload("set_ssh_finished") + self.assertEqual(finished["result"], "success") + self.assertEqual(finished["set_ssh_action"], "leave_enabled") + self.assertEqual(finished["ssh_final_reachable"], True) + def test_set_ssh_disable_fails_when_ssh_never_goes_down(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} From 7ff8e66e61dbe070290e5a699e2c7240c30a36c0 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 04:49:34 -0700 Subject: [PATCH 09/13] Localize Swift fallback event messages and summaries --- .../TimeCapsuleSMBApp/HelperRunner.swift | 4 ++-- .../TimeCapsuleSMBApp/Localization.swift | 4 ++++ .../Sources/TimeCapsuleSMBApp/Models.swift | 19 +++++++++++++++---- .../Resources/en.lproj/Localizable.strings | 10 ++++++++++ .../BackendEventTests.swift | 14 ++++++++++++++ .../HelperRunnerTests.swift | 2 ++ .../PendingConfirmationTests.swift | 2 ++ 7 files changed, 49 insertions(+), 6 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index b3112a28..0d108d85 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -93,14 +93,14 @@ public final class HelperRunner { eventSink(BackendEvent.error( operation: operation, code: "cancelled", - message: "Operation cancelled.", + message: L10n.string("helper.error.cancelled"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } else if !sawTerminalEvent { eventSink(BackendEvent.error( operation: operation, code: "missing_terminal_event", - message: "Helper exited without a result or error event.", + message: L10n.string("helper.error.missing_terminal_event"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift index 54586039..7ac25032 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift @@ -4,4 +4,8 @@ enum L10n { static func string(_ key: String) -> String { NSLocalizedString(key, bundle: .module, comment: "") } + + static func format(_ key: String, _ arguments: CVarArg...) -> String { + String(format: string(key), locale: Locale.current, arguments: arguments) + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index ec67bea4..e923af39 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -161,13 +161,24 @@ public struct BackendEvent: Decodable, Identifiable { public var summary: String { switch type { case "stage": - return stage.map { "\(operation): \($0)" } ?? operation + return stage.map { L10n.format("event.summary.stage", operation, $0) } ?? operation case "check": - return "\(status ?? "INFO") \(message ?? "")" + return L10n.format( + "event.summary.check", + status ?? L10n.string("event.summary.check.default_status"), + message ?? "" + ) case "result": - return "\(operation): \(ok == true ? "finished" : "failed")" + let result = ok == true + ? L10n.string("event.summary.result.finished") + : L10n.string("event.summary.result.failed") + return L10n.format("event.summary.result", operation, result) case "error": - return "\(operation): \(message ?? "error")" + return L10n.format( + "event.summary.error", + operation, + message ?? L10n.string("event.summary.error.default_message") + ) default: return message ?? stage ?? operation } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index f44ed3c3..143d0c85 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -44,6 +44,14 @@ "confirm.uninstall.no_wait.title" = "Uninstall And Skip Waiting?"; "confirm.uninstall.reboot.message" = "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."; "confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"event.summary.check" = "%@ %@"; +"event.summary.check.default_status" = "INFO"; +"event.summary.error" = "%@: %@"; +"event.summary.error.default_message" = "error"; +"event.summary.result" = "%@: %@"; +"event.summary.result.failed" = "failed"; +"event.summary.result.finished" = "finished"; +"event.summary.stage" = "%@: %@"; "field.bonjour_timeout" = "Bonjour timeout seconds"; "field.fsck_volume" = "fsck volume, optional"; "field.helper" = "Helper"; @@ -51,6 +59,8 @@ "field.mount_wait" = "Mount wait seconds"; "field.password" = "Password"; "field.repair_xattrs_path" = "Repair xattrs path"; +"helper.error.cancelled" = "Operation cancelled."; +"helper.error.missing_terminal_event" = "Helper exited without a result or error event."; "panel.connect" = "Discover And Connect"; "screen.advanced" = "Advanced"; "screen.connect" = "Connect"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift index 98696fb7..b421657f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -37,6 +37,20 @@ final class BackendEventTests: XCTestCase { XCTAssertEqual(event.description, "Upload managed Samba payload files.") } + func testBackendEventSummaryUsesLocalizedFallbackTemplates() { + let stage = BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload") + let check = BackendEvent(type: "check", operation: "doctor", message: "smbd is running") + let success = BackendEvent(type: "result", operation: "deploy", ok: true) + let failure = BackendEvent(type: "result", operation: "deploy", ok: false) + let error = BackendEvent(type: "error", operation: "deploy") + + XCTAssertEqual(stage.summary, "deploy: upload_payload") + XCTAssertEqual(check.summary, "INFO smbd is running") + XCTAssertEqual(success.summary, "deploy: finished") + XCTAssertEqual(failure.summary, "deploy: failed") + XCTAssertEqual(error.summary, "deploy: error") + } + func testJSONValueRoundTripsNestedObjects() throws { let value = JSONValue.object([ "operation": .string("paths"), diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index 8f15890c..a9ddcc49 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -47,6 +47,7 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(events.last?.type, "error") XCTAssertEqual(events.last?.code, "missing_terminal_event") + XCTAssertEqual(events.last?.message, L10n.string("helper.error.missing_terminal_event")) XCTAssertEqual(events.last?.debug, .object(["stderr": .string("stderr detail\n")])) } @@ -141,6 +142,7 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(result.exitCode, 130) XCTAssertEqual(recorder.events.last?.type, "error") XCTAssertEqual(recorder.events.last?.code, "cancelled") + XCTAssertEqual(recorder.events.last?.message, L10n.string("helper.error.cancelled")) } private func makeHelper(in directory: URL, body: String) throws -> URL { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 23b2da49..297b3baf 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -5,6 +5,8 @@ final class PendingConfirmationTests: XCTestCase { func testLocalizedStringsLoadFromResourceBundle() { XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") + XCTAssertEqual(L10n.format("event.summary.result", "deploy", "finished"), "deploy: finished") } func testUninstallPlanParamsCarryNoRebootSelection() { From 818e548143d4c6f68732321a178f90f85a2ac955 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 05:04:42 -0700 Subject: [PATCH 10/13] Normalize repair-xattrs summaries and modernize Swift helper output reads --- .../TimeCapsuleSMBApp/HelperRunner.swift | 31 +++++++++++------- .../Sources/TimeCapsuleSMBApp/Models.swift | 23 +++++++++++++ .../BackendEventTests.swift | 32 +++++++++++++++++++ src/timecapsulesmb/app/contracts.py | 15 +++++++-- src/timecapsulesmb/app/events.py | 2 +- src/timecapsulesmb/app/ops/maintenance.py | 2 +- src/timecapsulesmb/cli/repair_xattrs.py | 2 +- tests/test_app_api.py | 31 +++++++++++++++--- tests/test_cli.py | 3 ++ 9 files changed, 120 insertions(+), 21 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 0d108d85..2f6bffdf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -8,6 +8,8 @@ public struct HelperRunResult: Equatable { } public final class HelperRunner { + private static let pipeReadChunkSize = 4096 + private let locator: HelperLocator private let stderrLimit: Int @@ -113,23 +115,15 @@ public final class HelperRunner { } private static func readOutput(_ handle: FileHandle, parser: OutputLineParser) { - while true { - let data = handle.availableData - if data.isEmpty { - parser.finish() - return - } + readChunks(from: handle) { data in parser.append(data) } + parser.finish() } private static func readCapped(_ handle: FileHandle, limit: Int) -> String { var output = Data() - while true { - let data = handle.availableData - if data.isEmpty { - break - } + readChunks(from: handle) { data in if output.count < limit { output.append(data.prefix(limit - output.count)) } @@ -137,6 +131,21 @@ public final class HelperRunner { return String(decoding: output, as: UTF8.self) } + private static func readChunks(from handle: FileHandle, onChunk: (Data) -> Void) { + while true { + let data: Data? + do { + data = try handle.read(upToCount: pipeReadChunkSize) + } catch { + return + } + guard let data, !data.isEmpty else { + return + } + onChunk(data) + } + } + private static func waitForExit(_ process: Process) async { if !process.isRunning { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index e923af39..ffbb1353 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -63,6 +63,14 @@ public enum JSONValue: Codable, Hashable { return "null" } } + + public func stringValue(for key: String) -> String? { + guard case .object(let values) = self, case .string(let value)? = values[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value + } } public struct BackendEvent: Decodable, Identifiable { @@ -169,6 +177,9 @@ public struct BackendEvent: Decodable, Identifiable { message ?? "" ) case "result": + if let payloadSummary = payloadSummary { + return payloadSummary + } let result = ok == true ? L10n.string("event.summary.result.finished") : L10n.string("event.summary.result.failed") @@ -183,4 +194,16 @@ public struct BackendEvent: Decodable, Identifiable { return message ?? stage ?? operation } } + + private var payloadSummary: String? { + guard let payload else { + return nil + } + for key in ["summary", "message", "summary_text"] { + if let value = payload.stringValue(for: key) { + return value + } + } + return nil + } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift index b421657f..ba32dcfd 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -51,6 +51,38 @@ final class BackendEventTests: XCTestCase { XCTAssertEqual(error.summary, "deploy: error") } + func testBackendEventResultSummaryPrefersPayloadText() { + let summary = BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("Deployment completed on the Time Capsule.")]) + ) + let message = BackendEvent( + type: "result", + operation: "activate", + ok: true, + payload: .object(["message": .string("Activation completed without reboot.")]) + ) + let legacySummaryText = BackendEvent( + type: "result", + operation: "repair-xattrs", + ok: true, + payload: .object(["summary_text": .string("repair-xattrs found 2 issue(s), 1 repairable.")]) + ) + let blankSummaryFallsBack = BackendEvent( + type: "result", + operation: "doctor", + ok: true, + payload: .object(["summary": .string(" ")]) + ) + + XCTAssertEqual(summary.summary, "Deployment completed on the Time Capsule.") + XCTAssertEqual(message.summary, "Activation completed without reboot.") + XCTAssertEqual(legacySummaryText.summary, "repair-xattrs found 2 issue(s), 1 repairable.") + XCTAssertEqual(blankSummaryFallsBack.summary, "doctor: finished") + } + func testJSONValueRoundTripsNestedObjects() throws { let value = JSONValue.object([ "operation": .string("paths"), diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index f70c319d..382fbb03 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -232,14 +232,23 @@ def fsck_result_payload( def repair_xattrs_payload(raw: Mapping[str, object]) -> dict[str, object]: finding_count = int(raw.get("finding_count") or 0) repairable_count = int(raw.get("repairable_count") or 0) - return _with_schema({ + legacy_summary = raw.get("summary") + stats = raw.get("stats", legacy_summary if not isinstance(legacy_summary, str) else None) + summary = legacy_summary if isinstance(legacy_summary, str) and legacy_summary.strip() else ( + f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable." + ) + payload = { **raw, "counts": { "findings": finding_count, "repairable": repairable_count, }, - "summary_text": f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable.", - }) + "summary": summary, + "summary_text": summary, + } + if stats is not None: + payload["stats"] = jsonable(stats) + return _with_schema(payload) def doctor_payload( diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index e66df37a..1b577bb3 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -9,7 +9,7 @@ from timecapsulesmb.app.stage_policy import stage_policy -SENSITIVE_KEY_PARTS = ("password", "secret", "token") +SENSITIVE_KEY_PARTS = ("password", "secret", "token", "key") REDACTED = "" diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 5b405526..74b9e833 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -345,7 +345,7 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera "root": str(result.root), "finding_count": len(result.findings), "repairable_count": len(result.candidates), - "summary": jsonable(result.summary), + "stats": jsonable(result.summary), "report": result.report, "telemetry_result": context.result, "error": context.error, diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index 734df12b..403a3418 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -273,7 +273,7 @@ def _repair_result_payload(result: RepairRunResult, context: RepairExecutionCont "root": str(result.root), "finding_count": len(result.findings), "repairable_count": len(result.candidates), - "summary": jsonable(result.summary), + "stats": jsonable(result.summary), "report": result.report, "telemetry_result": context.result, "error": context.error if isinstance(context, RepairExecutionContext) else None, diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 4c08c1ef..4721f083 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -137,12 +137,16 @@ def assert_single_terminal_event(self, collector: CollectingSink, event_type: st self.assertEqual([event["type"] for event in terminals], [event_type]) return terminals[0] - def test_event_redacts_password_fields(self) -> None: + def test_event_redacts_sensitive_fields(self) -> None: event = AppEvent("result", "configure", { "ok": True, "payload": { "password": "secret", - "nested": {"TC_PASSWORD": "secret"}, + "nested": { + "TC_PASSWORD": "secret", + "api_key": "secret", + "ssh_private_key": "secret", + }, }, }) @@ -150,6 +154,8 @@ def test_event_redacts_password_fields(self) -> None: self.assertEqual(data["payload"]["password"], "") self.assertEqual(data["payload"]["nested"]["TC_PASSWORD"], "") + self.assertEqual(data["payload"]["nested"]["api_key"], "") + self.assertEqual(data["payload"]["nested"]["ssh_private_key"], "") def test_result_event_preserves_falsey_payloads(self) -> None: collector = CollectingSink() @@ -212,12 +218,24 @@ def test_contract_builders_keep_stable_representative_shapes(self) -> None: repair = contracts.repair_xattrs_payload({ "returncode": 0, "root": "/Volumes/Data", + "finding_count": 2, + "repairable_count": 1, + "stats": {"scanned": 3}, + }) + self.assertEqual(repair["summary"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["stats"], {"scanned": 3}) + + def test_repair_xattrs_payload_preserves_legacy_summary_stats_as_stats(self) -> None: + repair = contracts.repair_xattrs_payload({ "finding_count": 2, "repairable_count": 1, "summary": {"scanned": 3}, }) - self.assertEqual(repair["summary"], {"scanned": 3}) + + self.assertEqual(repair["summary"], "repair-xattrs found 2 issue(s), 1 repairable.") self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["stats"], {"scanned": 3}) def test_request_id_propagates_to_every_event(self) -> None: collector = CollectingSink() @@ -1090,7 +1108,12 @@ def test_repair_xattrs_uses_structured_runner(self) -> None: self.assertEqual(rc, 0) runner.assert_called_once() - self.assertEqual(collector.events_of_type("result")[0]["payload"]["finding_count"], 1) + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["finding_count"], 1) + self.assertEqual(payload["summary"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(payload["summary_text"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(payload["stats"]["scanned"], 1) + self.assertNotIsInstance(payload["summary"], dict) def test_repair_xattrs_captures_direct_stdout_and_stderr_logs(self) -> None: collector = CollectingSink() diff --git a/tests/test_cli.py b/tests/test_cli.py index ddef0f30..2da187cb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1263,6 +1263,9 @@ def test_repair_xattrs_json_emits_ndjson_result(self) -> None: self.assertEqual(events[0]["type"], "stage") self.assertEqual(events[-1]["type"], "result") self.assertEqual(events[-1]["payload"]["finding_count"], 1) + self.assertEqual(events[-1]["payload"]["summary"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["summary_text"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["stats"]["scanned"], 1) self.assertEqual(events[-1]["payload"]["repairable_count"], 1) def test_repair_xattrs_json_repair_requires_yes(self) -> None: From ff8c8c6663033988a3c4ba1d2f02bba489736579 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 05:33:25 -0700 Subject: [PATCH 11/13] Make GUI helper event delivery async and ordered --- .../TimeCapsuleSMBApp/BackendClient.swift | 59 +++++++-- .../TimeCapsuleSMBApp/HelperRunner.swift | 91 ++++++++------ .../Sources/TimeCapsuleSMBApp/Models.swift | 4 +- .../TimeCapsuleSMBApp/OutputLineParser.swift | 38 +++--- .../BackendClientTests.swift | 118 ++++++++++++++++++ .../HelperRunnerTests.swift | 67 ++++++---- .../OutputLineParserTests.swift | 14 +-- 7 files changed, 288 insertions(+), 103 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index 27a0f10e..c6d106d1 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -7,12 +7,15 @@ final class BackendClient: ObservableObject { @Published var isRunning = false @Published var lastExitCode: Int32? - private let runner: HelperRunner + private let runner: any HelperRunning private var runTask: Task? - init(runner: HelperRunner = HelperRunner()) { + init( + runner: any HelperRunning = HelperRunner(), + helperPath: String = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" + ) { self.runner = runner - helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" + self.helperPath = helperPath } func clear() { @@ -25,23 +28,59 @@ final class BackendClient: ObservableObject { isRunning = true lastExitCode = nil let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) - runTask = Task { + let runner = self.runner + let updateTarget = BackendClientUpdateTarget( + appendEvent: { [weak self] event in + self?.appendEvent(event) + }, + finishRun: { [weak self] exitCode in + self?.finishRun(exitCode: exitCode) + } + ) + runTask = Task.detached(priority: .userInitiated) { [runner, updateTarget, helperPath, operation, params] in let result = await runner.run( helperPath: helperPath.isEmpty ? nil : helperPath, operation: operation, params: params ) { event in - Task { @MainActor in - self.events.append(event) - } + await updateTarget.appendEvent(event) } - self.lastExitCode = result.exitCode - self.isRunning = false - self.runTask = nil + await updateTarget.finishRun(exitCode: result.exitCode) } } func cancel() { runTask?.cancel() } + + fileprivate func appendEvent(_ event: BackendEvent) { + events.append(event) + } + + fileprivate func finishRun(exitCode: Int32) { + lastExitCode = exitCode + isRunning = false + runTask = nil + } +} + +private final class BackendClientUpdateTarget: Sendable { + private let appendEventOnMain: @MainActor @Sendable (BackendEvent) -> Void + private let finishRunOnMain: @MainActor @Sendable (Int32) -> Void + + init( + appendEvent: @escaping @MainActor @Sendable (BackendEvent) -> Void, + finishRun: @escaping @MainActor @Sendable (Int32) -> Void + ) { + self.appendEventOnMain = appendEvent + self.finishRunOnMain = finishRun + } + + func appendEvent(_ event: BackendEvent) async { + await appendEventOnMain(event) + } + + func finishRun(exitCode: Int32) async { + await finishRunOnMain(exitCode) + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 2f6bffdf..0740dc9a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -1,13 +1,22 @@ import Darwin import Foundation -public struct HelperRunResult: Equatable { +public struct HelperRunResult: Equatable, Sendable { public let exitCode: Int32 public let sawTerminalEvent: Bool public let stderr: String } -public final class HelperRunner { +public protocol HelperRunning: Sendable { + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult +} + +public final class HelperRunner: @unchecked Sendable, HelperRunning { private static let pipeReadChunkSize = 4096 private let locator: HelperLocator @@ -22,19 +31,19 @@ public final class HelperRunner { helperPath: String?, operation: String, params: [String: JSONValue], - onEvent: @escaping (BackendEvent) -> Void + onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult { let terminalTracker = TerminalEventTracker() - let eventSink: (BackendEvent) -> Void = { event in - terminalTracker.record(event) - onEvent(event) + let eventSink: @Sendable (BackendEvent) async -> Void = { event in + await terminalTracker.record(event) + await onEvent(event) } let resolution: HelperResolution do { resolution = try locator.resolve(helperPath: helperPath) } catch { - eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) + await eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") } @@ -50,19 +59,19 @@ public final class HelperRunner { process.standardOutput = output process.standardError = error - let parser = OutputLineParser(onEvent: eventSink) do { try process.run() } catch { - eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) + await eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") } let stdoutTask = Task.detached { - Self.readOutput(output.fileHandleForReading, parser: parser) + await Self.readOutput(output.fileHandleForReading, onEvent: eventSink) } + let stderrLimit = self.stderrLimit let stderrTask = Task.detached { - Self.readCapped(error.fileHandleForReading, limit: self.stderrLimit) + Self.readCapped(error.fileHandleForReading, limit: stderrLimit) } do { @@ -73,7 +82,7 @@ public final class HelperRunner { } catch { try? input.fileHandleForWriting.close() await Self.terminate(process) - eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) await stdoutTask.value let stderr = await stderrTask.value return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) @@ -90,40 +99,49 @@ public final class HelperRunner { await stdoutTask.value let stderrText = await stderrTask.value - let sawTerminalEvent = terminalTracker.sawTerminalEvent + let sawTerminalEvent = await terminalTracker.sawTerminalEvent if cancelled { - eventSink(BackendEvent.error( + await eventSink(BackendEvent.error( operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } else if !sawTerminalEvent { - eventSink(BackendEvent.error( + await eventSink(BackendEvent.error( operation: operation, code: "missing_terminal_event", message: L10n.string("helper.error.missing_terminal_event"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } + let finalSawTerminalEvent = await terminalTracker.sawTerminalEvent return HelperRunResult( exitCode: cancelled ? 130 : process.terminationStatus, - sawTerminalEvent: terminalTracker.sawTerminalEvent, + sawTerminalEvent: finalSawTerminalEvent, stderr: stderrText ) } - private static func readOutput(_ handle: FileHandle, parser: OutputLineParser) { - readChunks(from: handle) { data in - parser.append(data) + private static func readOutput( + _ handle: FileHandle, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async { + var parser = OutputLineParser() + while let data = readChunk(from: handle) { + for event in parser.append(data) { + await onEvent(event) + } + } + for event in parser.finish() { + await onEvent(event) } - parser.finish() } private static func readCapped(_ handle: FileHandle, limit: Int) -> String { var output = Data() - readChunks(from: handle) { data in + while let data = readChunk(from: handle) { if output.count < limit { output.append(data.prefix(limit - output.count)) } @@ -131,19 +149,17 @@ public final class HelperRunner { return String(decoding: output, as: UTF8.self) } - private static func readChunks(from handle: FileHandle, onChunk: (Data) -> Void) { - while true { - let data: Data? - do { - data = try handle.read(upToCount: pipeReadChunkSize) - } catch { - return - } - guard let data, !data.isEmpty else { - return - } - onChunk(data) + private static func readChunk(from handle: FileHandle) -> Data? { + let data: Data? + do { + data = try handle.read(upToCount: pipeReadChunkSize) + } catch { + return nil + } + guard let data, !data.isEmpty else { + return nil } + return data } private static func waitForExit(_ process: Process) async { @@ -193,20 +209,15 @@ private final class TerminationContinuation: @unchecked Sendable { } } -private final class TerminalEventTracker: @unchecked Sendable { - private let lock = NSLock() +private actor TerminalEventTracker { private var seen = false var sawTerminalEvent: Bool { - lock.lock() - defer { lock.unlock() } - return seen + seen } func record(_ event: BackendEvent) { guard event.type == "result" || event.type == "error" else { return } - lock.lock() seen = true - lock.unlock() } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index ffbb1353..6037582f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -1,6 +1,6 @@ import Foundation -public enum JSONValue: Codable, Hashable { +public enum JSONValue: Codable, Hashable, Sendable { case string(String) case number(Double) case bool(Bool) @@ -73,7 +73,7 @@ public enum JSONValue: Codable, Hashable { } } -public struct BackendEvent: Decodable, Identifiable { +public struct BackendEvent: Decodable, Identifiable, Sendable { public let id = UUID() public let schemaVersion: Int? public let requestId: String? diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift index 4e702be2..50761c33 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift @@ -1,42 +1,38 @@ import Foundation -public final class OutputLineParser: @unchecked Sendable { - private let lock = NSLock() +public struct OutputLineParser { private var buffer = Data() private let decoder = JSONDecoder() - private let onEvent: (BackendEvent) -> Void - public init(onEvent: @escaping (BackendEvent) -> Void) { - self.onEvent = onEvent + public init() { } - public func append(_ data: Data) { - lock.lock() - defer { lock.unlock() } + public mutating func append(_ data: Data) -> [BackendEvent] { buffer.append(data) - consumeCompleteLines() + return consumeCompleteLines() } - public func finish() { - lock.lock() - defer { lock.unlock() } - guard !buffer.isEmpty else { return } - emit(buffer) + public mutating func finish() -> [BackendEvent] { + guard !buffer.isEmpty else { return [] } + let event = decode(buffer) buffer.removeAll() + return event.map { [$0] } ?? [] } - private func consumeCompleteLines() { + private mutating func consumeCompleteLines() -> [BackendEvent] { + var events: [BackendEvent] = [] while let newline = buffer.firstIndex(of: 0x0A) { let line = buffer.prefix(upTo: newline) buffer.removeSubrange(...newline) - emit(line) + if let event = decode(line) { + events.append(event) + } } + return events } - private func emit(_ line: Data.SubSequence) { - guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: Data(line)) else { - return - } - onEvent(event) + private func decode(_ line: Data.SubSequence) -> BackendEvent? { + guard !line.isEmpty else { return nil } + return try? decoder.decode(BackendEvent.self, from: Data(line)) } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift new file mode 100644 index 00000000..48088b7e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class BackendClientTests: XCTestCase { + func testRunPublishesEventsAndResetsState() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "stage", operation: "paths", stage: "start"), + BackendEvent(type: "result", operation: "paths", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 50_000_000 + ) + let client = BackendClient(runner: runner, helperPath: " /tmp/tcapsule ") + + client.run(operation: "paths", params: ["dry_run": .bool(true)]) + + XCTAssertTrue(client.isRunning) + try await waitUntil { + !client.isRunning && client.events.count == 2 + } + XCTAssertEqual(client.lastExitCode, 0) + XCTAssertEqual(client.events.map(\.type), ["stage", "result"]) + XCTAssertEqual( + runner.calls, + [RecordingHelperRunner.Call( + helperPath: "/tmp/tcapsule", + operation: "paths", + params: ["dry_run": .bool(true)] + )] + ) + } + + func testCancelCancelsDetachedRunAndResetsState() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 1_000_000_000 + ) + let client = BackendClient(runner: runner) + + client.run(operation: "doctor") + try await waitUntil { + runner.calls.count == 1 + } + + client.cancel() + + try await waitUntil { + !client.isRunning && client.lastExitCode == 130 && client.events.last?.code == "cancelled" + } + XCTAssertEqual(client.events.last?.type, "error") + } + + private func waitUntil( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !condition() { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for BackendClient state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } +} + +private final class RecordingHelperRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + } + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.RecordingHelperRunner") + private let events: [BackendEvent] + private let result: HelperRunResult + private let delayNanoseconds: UInt64 + private var storedCalls: [Call] = [] + + init(events: [BackendEvent], result: HelperRunResult, delayNanoseconds: UInt64 = 0) { + self.events = events + self.result = result + self.delayNanoseconds = delayNanoseconds + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + } + + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + if Task.isCancelled { + await onEvent(BackendEvent.error(operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"))) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in events { + await onEvent(event) + } + return result + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index a9ddcc49..f12fd120 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -17,15 +17,37 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { - recorder.append($0) + await recorder.append($0) } - let events = recorder.events + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(events.map(\.type), ["stage", "result"]) XCTAssertEqual(events.last?.ok, true) } + func testRunnerWaitsForEventDeliveryBeforeReturning() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"paths","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { event in + try? await Task.sleep(nanoseconds: 50_000_000) + await recorder.append(event) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.map(\.type), ["result"]) + } + func testRunnerSynthesizesErrorWhenHelperHasNoTerminalEvent() async throws { let temp = try TemporaryDirectory() let helper = try makeHelper( @@ -40,10 +62,10 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } - let events = recorder.events + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(events.last?.type, "error") XCTAssertEqual(events.last?.code, "missing_terminal_event") @@ -69,13 +91,14 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(result.stderr.count, 64 * 1024) - XCTAssertEqual(recorder.events.last?.type, "result") - XCTAssertEqual(recorder.events.last?.ok, true) + XCTAssertEqual(events.last?.type, "result") + XCTAssertEqual(events.last?.ok, true) } func testRunnerDecodesTruncatedUTF8StderrWithReplacementCharacter() async throws { @@ -94,12 +117,13 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(result.stderr, "\u{FFFD}") - XCTAssertEqual(recorder.events.last?.code, "missing_terminal_event") + XCTAssertEqual(events.last?.code, "missing_terminal_event") } func testRunnerReportsMissingHelper() async { @@ -108,12 +132,13 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: "/missing/tcapsule", operation: "paths", params: [:]) { - recorder.append($0) + await recorder.append($0) } + let events = await recorder.events XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(recorder.events.last?.type, "error") - XCTAssertEqual(recorder.events.last?.code, "helper_not_found") + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "helper_not_found") } func testRunnerCancelsLongRunningHelper() async throws { @@ -132,17 +157,18 @@ final class HelperRunnerTests: XCTestCase { let task = Task { await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } } try await Task.sleep(nanoseconds: 100_000_000) task.cancel() let result = await task.value + let events = await recorder.events XCTAssertEqual(result.exitCode, 130) - XCTAssertEqual(recorder.events.last?.type, "error") - XCTAssertEqual(recorder.events.last?.code, "cancelled") - XCTAssertEqual(recorder.events.last?.message, L10n.string("helper.error.cancelled")) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "cancelled") + XCTAssertEqual(events.last?.message, L10n.string("helper.error.cancelled")) } private func makeHelper(in directory: URL, body: String) throws -> URL { @@ -153,19 +179,14 @@ final class HelperRunnerTests: XCTestCase { } } -private final class EventRecorder: @unchecked Sendable { - private let lock = NSLock() +private actor EventRecorder { private var storage: [BackendEvent] = [] var events: [BackendEvent] { - lock.lock() - defer { lock.unlock() } - return storage + storage } func append(_ event: BackendEvent) { - lock.lock() storage.append(event) - lock.unlock() } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift index 93c87319..0c57055a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift @@ -4,14 +4,14 @@ import XCTest final class OutputLineParserTests: XCTestCase { func testParserHandlesSplitMultipleAndUnterminatedLines() { - var events: [BackendEvent] = [] - let parser = OutputLineParser { events.append($0) } + var parser = OutputLineParser() - parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8)) - parser.append(Data(#"_paths"}"#.utf8)) - parser.append(Data("\nnot-json\n".utf8)) - parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8)) - parser.finish() + var events: [BackendEvent] = [] + events.append(contentsOf: parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8))) + events.append(contentsOf: parser.append(Data(#"_paths"}"#.utf8))) + events.append(contentsOf: parser.append(Data("\nnot-json\n".utf8))) + events.append(contentsOf: parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8))) + events.append(contentsOf: parser.finish()) XCTAssertEqual(events.map(\.type), ["stage", "result"]) XCTAssertEqual(events.first?.stage, "resolve_paths") From b381409834143309c897a09f236c5d6e6c39274f Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 14:18:23 -0700 Subject: [PATCH 12/13] Implement service-layer GUI backend contracts with backend-driven confirmations, credential separation, cancellability state, and Swift confirmation handling --- .../TimeCapsuleSMBApp/BackendClient.swift | 42 ++ .../TimeCapsuleSMBApp/ContentView.swift | 123 +++-- .../TimeCapsuleSMBApp/OperationParams.swift | 75 ++-- .../PendingConfirmation.swift | 97 ++-- .../Resources/en.lproj/Localizable.strings | 4 + .../BackendClientTests.swift | 55 +++ .../PendingConfirmationTests.swift | 85 ++-- src/timecapsulesmb/app/confirmations.py | 133 ++++++ src/timecapsulesmb/app/contracts.py | 20 + src/timecapsulesmb/app/events.py | 3 + src/timecapsulesmb/app/operations.py | 178 -------- src/timecapsulesmb/app/ops/__init__.py | 8 +- src/timecapsulesmb/app/ops/configure.py | 11 +- src/timecapsulesmb/app/ops/deploy.py | 85 +++- src/timecapsulesmb/app/ops/doctor.py | 7 +- src/timecapsulesmb/app/ops/maintenance.py | 115 +++-- src/timecapsulesmb/app/ops/readiness.py | 37 +- src/timecapsulesmb/app/requests.py | 32 ++ src/timecapsulesmb/app/service.py | 51 +-- src/timecapsulesmb/app/stage_policy.py | 2 + src/timecapsulesmb/cli/fsck.py | 28 +- src/timecapsulesmb/cli/runtime.py | 73 +-- src/timecapsulesmb/services/app.py | 8 +- src/timecapsulesmb/services/config_store.py | 31 ++ src/timecapsulesmb/services/credentials.py | 31 ++ src/timecapsulesmb/services/doctor.py | 211 +++++++++ src/timecapsulesmb/services/maintenance.py | 23 + src/timecapsulesmb/services/repair_xattrs.py | 219 +++++++++ src/timecapsulesmb/services/runtime.py | 171 +++++++ tests/test_app_api.py | 421 +++++++++++------- 30 files changed, 1686 insertions(+), 693 deletions(-) create mode 100644 src/timecapsulesmb/app/confirmations.py delete mode 100644 src/timecapsulesmb/app/operations.py create mode 100644 src/timecapsulesmb/app/requests.py create mode 100644 src/timecapsulesmb/services/config_store.py create mode 100644 src/timecapsulesmb/services/credentials.py create mode 100644 src/timecapsulesmb/services/repair_xattrs.py create mode 100644 src/timecapsulesmb/services/runtime.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index c6d106d1..b11d6ed3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -6,9 +6,14 @@ final class BackendClient: ObservableObject { @Published var events: [BackendEvent] = [] @Published var isRunning = false @Published var lastExitCode: Int32? + @Published var pendingConfirmation: PendingConfirmation? + @Published var currentStage: String? + @Published var currentRisk: String? + @Published var currentCancellable: Bool? private let runner: any HelperRunning private var runTask: Task? + private var activeCall: BackendCall? init( runner: any HelperRunning = HelperRunner(), @@ -21,12 +26,25 @@ final class BackendClient: ObservableObject { func clear() { events.removeAll() lastExitCode = nil + pendingConfirmation = nil + currentStage = nil + currentRisk = nil + currentCancellable = nil + } + + var canCancel: Bool { + isRunning && (currentCancellable ?? true) } func run(operation: String, params: [String: JSONValue] = [:]) { guard !isRunning else { return } isRunning = true lastExitCode = nil + pendingConfirmation = nil + currentStage = nil + currentRisk = nil + currentCancellable = nil + activeCall = BackendCall(operation: operation, params: params) let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) let runner = self.runner let updateTarget = BackendClientUpdateTarget( @@ -50,10 +68,28 @@ final class BackendClient: ObservableObject { } func cancel() { + guard canCancel else { return } runTask?.cancel() } + func confirmPending() { + guard let confirmation = pendingConfirmation, !isRunning else { return } + pendingConfirmation = nil + run(operation: confirmation.operation, params: confirmation.params) + } + fileprivate func appendEvent(_ event: BackendEvent) { + if event.type == "stage" { + currentStage = event.stage + currentRisk = event.risk + currentCancellable = event.cancellable + } + if let activeCall, let confirmation = PendingConfirmation( + confirmationEvent: event, + originalParams: activeCall.params + ) { + pendingConfirmation = confirmation + } events.append(event) } @@ -61,9 +97,15 @@ final class BackendClient: ObservableObject { lastExitCode = exitCode isRunning = false runTask = nil + activeCall = nil } } +private struct BackendCall: Sendable { + let operation: String + let params: [String: JSONValue] +} + private final class BackendClientUpdateTarget: Sendable { private let appendEventOnMain: @MainActor @Sendable (BackendEvent) -> Void private let finishRunOnMain: @MainActor @Sendable (Int32) -> Void diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 46de61c5..060871cd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -15,7 +15,6 @@ public struct ContentView: View { @State private var mountWait = "30" @State private var bonjourTimeout = "6" @State private var noWait = false - @State private var pendingConfirmation: PendingConfirmation? public init() {} @@ -45,22 +44,21 @@ public struct ContentView: View { } label: { Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") } - .disabled(!backend.isRunning) + .disabled(!backend.canCancel) } } } .frame(minWidth: 980, minHeight: 680) .alert( - pendingConfirmation?.title ?? "", + backend.pendingConfirmation?.title ?? "", isPresented: confirmationPresented, - presenting: pendingConfirmation + presenting: backend.pendingConfirmation ) { confirmation in Button(confirmation.actionTitle, role: .destructive) { - backend.run(operation: confirmation.operation, params: confirmation.params) - pendingConfirmation = nil + backend.confirmPending() } Button(L10n.string("action.cancel"), role: .cancel) { - pendingConfirmation = nil + backend.pendingConfirmation = nil } } message: { confirmation in Text(confirmation.message) @@ -69,10 +67,10 @@ public struct ContentView: View { private var confirmationPresented: Binding { Binding( - get: { pendingConfirmation != nil }, + get: { backend.pendingConfirmation != nil }, set: { isPresented in if !isPresented { - pendingConfirmation = nil + backend.pendingConfirmation = nil } } ) @@ -85,6 +83,7 @@ public struct ContentView: View { CommandPanel(title: L10n.string("screen.readiness")) { TextField(L10n.string("field.helper"), text: $backend.helperPath) HStack { + runButton(L10n.string("button.capabilities"), icon: "info.circle", operation: "capabilities") runButton(L10n.string("button.paths"), icon: "folder", operation: "paths") runButton(L10n.string("button.validate"), icon: "checkmark.seal", operation: "validate-install") } @@ -100,7 +99,8 @@ public struct ContentView: View { L10n.string("button.discover"), icon: "network", operation: "discover", - params: OperationParams.discover(timeout: numberDouble(bonjourTimeout, default: 6)) + params: OperationParams.discover(timeout: bonjourTimeoutValue ?? 6), + disabled: bonjourTimeoutValue == nil ) Button { backend.run( @@ -134,16 +134,21 @@ public struct ContentView: View { noWait: noWait, nbnsEnabled: nbnsEnabled, debugLogging: deployDebugLogging, - mountWait: numberDouble(mountWait, default: 30) + mountWait: mountWaitValue ?? 30, + password: password ) ) } else { - pendingConfirmation = .deploy( - noReboot: noReboot, - nbnsEnabled: nbnsEnabled, - debugLogging: deployDebugLogging, - mountWait: numberDouble(mountWait, default: 30), - noWait: noWait + backend.run( + operation: "deploy", + params: OperationParams.deployRun( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging, + mountWait: mountWaitValue ?? 30, + password: password + ) ) } } label: { @@ -152,7 +157,7 @@ public struct ContentView: View { systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up" ) } - .disabled(backend.isRunning) + .disabled(backend.isRunning || mountWaitValue == nil) } case .doctor: CommandPanel(title: L10n.string("screen.doctor")) { @@ -161,7 +166,8 @@ public struct ContentView: View { L10n.string("button.run_doctor"), icon: "stethoscope", operation: "doctor", - params: OperationParams.doctor(bonjourTimeout: numberDouble(bonjourTimeout, default: 6)) + params: OperationParams.doctor(bonjourTimeout: bonjourTimeoutValue ?? 6, password: password), + disabled: bonjourTimeoutValue == nil ) } case .maintenance: @@ -173,7 +179,7 @@ public struct ContentView: View { Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) HStack { Button { - pendingConfirmation = .activate() + backend.run(operation: "activate", params: OperationParams.activateRun(password: password)) } label: { Label(L10n.string("button.activate"), systemImage: "power") } @@ -185,26 +191,33 @@ public struct ContentView: View { params: OperationParams.uninstallPlan( noReboot: noReboot, noWait: noWait, - mountWait: numberDouble(mountWait, default: 30) - ) + mountWait: mountWaitValue ?? 30, + password: password + ), + disabled: mountWaitValue == nil ) Button { - pendingConfirmation = .uninstall( - noReboot: noReboot, - mountWait: numberDouble(mountWait, default: 30), - noWait: noWait + backend.run( + operation: "uninstall", + params: OperationParams.uninstallRun( + noReboot: noReboot, + noWait: noWait, + mountWait: mountWaitValue ?? 30, + password: password + ) ) } label: { Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") } - .disabled(backend.isRunning) + .disabled(backend.isRunning || mountWaitValue == nil) } HStack { runButton( L10n.string("button.list_fsck_volumes"), icon: "list.bullet.rectangle", operation: "fsck", - params: OperationParams.fsckList(mountWait: numberDouble(mountWait, default: 30)) + params: OperationParams.fsckList(mountWait: mountWaitValue ?? 30, password: password), + disabled: mountWaitValue == nil ) runButton( L10n.string("button.plan_fsck"), @@ -214,20 +227,26 @@ public struct ContentView: View { volume: volume, noReboot: noReboot, noWait: noWait, - mountWait: numberDouble(mountWait, default: 30) - ) + mountWait: mountWaitValue ?? 30, + password: password + ), + disabled: mountWaitValue == nil ) Button { - pendingConfirmation = .fsck( - volume: volume, - noReboot: noReboot, - mountWait: numberDouble(mountWait, default: 30), - noWait: noWait + backend.run( + operation: "fsck", + params: OperationParams.fsckRun( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: mountWaitValue ?? 30, + password: password + ) ) } label: { Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") } - .disabled(backend.isRunning) + .disabled(backend.isRunning || mountWaitValue == nil) } HStack { Button { @@ -240,7 +259,10 @@ public struct ContentView: View { } .disabled(backend.isRunning || repairPath.isEmpty) Button { - pendingConfirmation = .repairXattrs(path: repairPath) + backend.run( + operation: "repair-xattrs", + params: OperationParams.repairXattrsRun(path: repairPath) + ) } label: { Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") } @@ -261,19 +283,38 @@ public struct ContentView: View { _ title: String, icon: String, operation: String, - params: [String: JSONValue] = [:] + params: [String: JSONValue] = [:], + disabled: Bool = false ) -> some View { Button { backend.run(operation: operation, params: params) } label: { Label(title, systemImage: icon) } - .disabled(backend.isRunning) + .disabled(backend.isRunning || disabled) } - private func numberDouble(_ text: String, default defaultValue: Double) -> Double { + private var mountWaitValue: Double? { + nonNegativeIntegerDouble(mountWait) + } + + private var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + private func nonNegativeDouble(_ text: String) -> Double? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return Double(trimmed) ?? defaultValue + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } + + private func nonNegativeIntegerDouble(_ text: String) -> Double? { + guard let value = nonNegativeDouble(text), value.rounded(.towardZero) == value else { + return nil + } + return value } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift index d4f487fa..75023d92 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -1,6 +1,16 @@ import Foundation enum OperationParams { + private static func withCredentials(_ params: [String: JSONValue], password: String) -> [String: JSONValue] { + let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return params + } + var updated = params + updated["credentials"] = .object(["password": .string(password)]) + return updated + } + static func discover(timeout: Double) -> [String: JSONValue] { ["timeout": .number(timeout)] } @@ -16,8 +26,8 @@ enum OperationParams { return params } - static func doctor(bonjourTimeout: Double) -> [String: JSONValue] { - ["bonjour_timeout": .number(bonjourTimeout)] + static func doctor(bonjourTimeout: Double, password: String) -> [String: JSONValue] { + withCredentials(["bonjour_timeout": .number(bonjourTimeout)], password: password) } static func deployPlan( @@ -25,87 +35,83 @@ enum OperationParams { noWait: Bool, nbnsEnabled: Bool, debugLogging: Bool, - mountWait: Double + mountWait: Double, + password: String ) -> [String: JSONValue] { - [ + withCredentials([ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func deployConfirmed( + static func deployRun( noReboot: Bool, noWait: Bool, nbnsEnabled: Bool, debugLogging: Bool, - mountWait: Double + mountWait: Double, + password: String ) -> [String: JSONValue] { - [ + withCredentials([ "dry_run": .bool(false), - "confirm_deploy": .bool(true), - "confirm_reboot": .bool(!noReboot), - "confirm_netbsd4_activation": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ + static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func uninstallConfirmed(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ + static func uninstallRun(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "dry_run": .bool(false), - "confirm_uninstall": .bool(true), - "confirm_reboot": .bool(!noReboot), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func activateConfirmed() -> [String: JSONValue] { - ["confirm_netbsd4_activation": .bool(true)] + static func activateRun(password: String) -> [String: JSONValue] { + withCredentials([:], password: password) } - static func fsckList(mountWait: Double) -> [String: JSONValue] { - [ + static func fsckList(mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "list_volumes": .bool(true), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ + static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait), "volume": .string(volume) - ] + ], password: password) } - static func fsckConfirmed(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ - "confirm_fsck": .bool(true), + static func fsckRun(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait), "volume": .string(volume) - ] + ], password: password) } static func repairXattrsScan(path: String) -> [String: JSONValue] { @@ -115,11 +121,10 @@ enum OperationParams { ] } - static func repairXattrsConfirmed(path: String) -> [String: JSONValue] { + static func repairXattrsRun(path: String) -> [String: JSONValue] { [ "path": .string(path), - "dry_run": .bool(false), - "confirm_repair": .bool(true) + "dry_run": .bool(false) ] } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 41f13f12..497530f2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -8,80 +8,33 @@ struct PendingConfirmation: Identifiable { let operation: String let params: [String: JSONValue] - static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { - PendingConfirmation( - title: noReboot ? L10n.string("confirm.deploy.no_reboot.title") : (noWait ? L10n.string("confirm.deploy.no_wait.title") : L10n.string("confirm.deploy.reboot.title")), - message: noReboot - ? L10n.string("confirm.deploy.no_reboot.message") - : (noWait - ? L10n.string("confirm.deploy.no_wait.message") - : L10n.string("confirm.deploy.reboot.message")), - actionTitle: noReboot ? L10n.string("action.deploy") : L10n.string("action.deploy_allow_reboot"), - operation: "deploy", - params: OperationParams.deployConfirmed( - noReboot: noReboot, - noWait: noWait, - nbnsEnabled: nbnsEnabled, - debugLogging: debugLogging, - mountWait: mountWait - ) - ) - } - - static func activate() -> PendingConfirmation { - PendingConfirmation( - title: L10n.string("confirm.activate.title"), - message: L10n.string("confirm.activate.message"), - actionTitle: L10n.string("action.activate"), - operation: "activate", - params: OperationParams.activateConfirmed() - ) - } - - static func fsck(volume: String, noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { - PendingConfirmation( - title: noReboot ? L10n.string("confirm.fsck.no_reboot.title") : (noWait ? L10n.string("confirm.fsck.no_wait.title") : L10n.string("confirm.fsck.reboot.title")), - message: noReboot - ? L10n.string("confirm.fsck.no_reboot.message") - : (noWait - ? L10n.string("confirm.fsck.no_wait.message") - : L10n.string("confirm.fsck.reboot.message")), - actionTitle: L10n.string("action.run_fsck"), - operation: "fsck", - params: OperationParams.fsckConfirmed( - volume: volume, - noReboot: noReboot, - noWait: noWait, - mountWait: mountWait - ) - ) - } + init?( + confirmationEvent event: BackendEvent, + originalParams: [String: JSONValue] + ) { + guard + event.type == "error", + event.code == "confirmation_required", + case .object(let details)? = event.details, + case .string(let confirmationId)? = details["confirmation_id"] + else { + return nil + } - static func uninstall(noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { - PendingConfirmation( - title: noReboot ? L10n.string("confirm.uninstall.no_reboot.title") : (noWait ? L10n.string("confirm.uninstall.no_wait.title") : L10n.string("confirm.uninstall.reboot.title")), - message: noReboot - ? L10n.string("confirm.uninstall.no_reboot.message") - : (noWait - ? L10n.string("confirm.uninstall.no_wait.message") - : L10n.string("confirm.uninstall.reboot.message")), - actionTitle: L10n.string("action.uninstall"), - operation: "uninstall", - params: OperationParams.uninstallConfirmed( - noReboot: noReboot, - noWait: noWait, - mountWait: mountWait - ) - ) + self.title = Self.detailString(details, "title") ?? L10n.string("confirm.backend.title") + self.message = Self.detailString(details, "message") ?? event.message ?? L10n.string("confirm.backend.message") + self.actionTitle = Self.detailString(details, "action_title") ?? L10n.string("action.confirm") + self.operation = event.operation + var confirmedParams = originalParams + confirmedParams["confirmation_id"] = .string(confirmationId) + self.params = confirmedParams } - static func repairXattrs(path: String) -> PendingConfirmation { - PendingConfirmation( - title: L10n.string("confirm.repair_xattrs.title"), - message: L10n.string("confirm.repair_xattrs.message"), - actionTitle: L10n.string("action.repair_xattrs"), - operation: "repair-xattrs", - params: OperationParams.repairXattrsConfirmed(path: path) - ) + private static func detailString(_ details: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = details[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 143d0c85..b1fcd4f0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -1,5 +1,6 @@ "action.activate" = "Activate"; "action.cancel" = "Cancel"; +"action.confirm" = "Confirm"; "action.deploy" = "Deploy"; "action.deploy_allow_reboot" = "Deploy And Allow Reboot"; "action.repair_xattrs" = "Repair xattrs"; @@ -8,6 +9,7 @@ "advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; "advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; "button.activate" = "Activate"; +"button.capabilities" = "Capabilities"; "button.configure" = "Configure"; "button.deploy" = "Deploy"; "button.discover" = "Discover"; @@ -24,6 +26,8 @@ "button.validate" = "Validate"; "confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; "confirm.activate.title" = "Activate NetBSD 4 Runtime?"; +"confirm.backend.message" = "Confirm this operation."; +"confirm.backend.title" = "Confirm Operation"; "confirm.deploy.no_reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device."; "confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; "confirm.deploy.no_wait.message" = "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device."; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift index 48088b7e..789e93a4 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -55,6 +55,61 @@ final class BackendClientTests: XCTestCase { XCTAssertEqual(client.events.last?.type, "error") } + func testStagePolicyControlsCancellation() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "deploy", ok: true) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 50_000_000 + ) + let client = BackendClient(runner: runner) + + client.run(operation: "deploy") + try await waitUntil { + client.currentStage == "upload_payload" + } + + XCTAssertFalse(client.canCancel) + client.cancel() + + try await waitUntil { + !client.isRunning + } + XCTAssertEqual(client.lastExitCode, 0) + } + + func testConfirmationRequiredEventPublishesPendingConfirmation() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deploy.", + details: .object([ + "title": .string("Confirm deployment"), + "message": .string("Deploy and reboot."), + "action_title": .string("Deploy"), + "confirmation_id": .string("confirm-1") + ]) + ) + ], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + let client = BackendClient(runner: runner) + + client.run(operation: "deploy", params: ["dry_run": .bool(false)]) + + try await waitUntil { + client.pendingConfirmation != nil && !client.isRunning + } + XCTAssertEqual(client.pendingConfirmation?.operation, "deploy") + XCTAssertEqual(client.pendingConfirmation?.params["confirmation_id"], .string("confirm-1")) + XCTAssertEqual(client.pendingConfirmation?.params["dry_run"], .bool(false)) + } + private func waitUntil( timeoutNanoseconds: UInt64 = 2_000_000_000, _ condition: @escaping @MainActor () -> Bool diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 297b3baf..cae0980e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -5,60 +5,83 @@ final class PendingConfirmationTests: XCTestCase { func testLocalizedStringsLoadFromResourceBundle() { XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + XCTAssertEqual(L10n.string("button.capabilities"), "Capabilities") XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") XCTAssertEqual(L10n.format("event.summary.result", "deploy", "finished"), "deploy: finished") } func testUninstallPlanParamsCarryNoRebootSelection() { - let params = OperationParams.uninstallPlan(noReboot: true, noWait: true, mountWait: 9) + let params = OperationParams.uninstallPlan(noReboot: true, noWait: true, mountWait: 9, password: "pw") XCTAssertEqual(params["dry_run"], .bool(true)) XCTAssertEqual(params["no_reboot"], .bool(true)) XCTAssertEqual(params["no_wait"], .bool(true)) XCTAssertEqual(params["mount_wait"], .number(9)) + XCTAssertEqual(params["credentials"], .object(["password": .string("pw")])) } - func testDeployConfirmationCarriesDeployAndRebootConsent() { - let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true, mountWait: 45, noWait: true) + func testDeployRunParamsCarryOptionsWithoutFrontendConsentFlags() { + let params = OperationParams.deployRun( + noReboot: false, + noWait: true, + nbnsEnabled: true, + debugLogging: true, + mountWait: 45, + password: "" + ) - XCTAssertEqual(confirmation.operation, "deploy") - XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) - XCTAssertEqual(confirmation.params["confirm_deploy"], .bool(true)) - XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(true)) - XCTAssertEqual(confirmation.params["confirm_netbsd4_activation"], .bool(true)) - XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) - XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) - XCTAssertEqual(confirmation.params["debug_logging"], .bool(true)) - XCTAssertEqual(confirmation.params["mount_wait"], .number(45)) - XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) + XCTAssertEqual(params["dry_run"], .bool(false)) + XCTAssertNil(params["confirm_deploy"]) + XCTAssertNil(params["confirm_reboot"]) + XCTAssertNil(params["confirm_netbsd4_activation"]) + XCTAssertEqual(params["no_reboot"], .bool(false)) + XCTAssertEqual(params["nbns_enabled"], .bool(true)) + XCTAssertEqual(params["debug_logging"], .bool(true)) + XCTAssertEqual(params["mount_wait"], .number(45)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertNil(params["credentials"]) } - func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { - let confirmation = PendingConfirmation.uninstall(noReboot: true, mountWait: 12, noWait: true) + func testPendingConfirmationBuildsFromBackendEvent() throws { + let event = BackendEvent( + type: "error", + operation: "uninstall", + code: "confirmation_required", + message: "Confirm uninstall.", + details: .object([ + "title": .string("Confirm uninstall"), + "message": .string("Remove files."), + "action_title": .string("Uninstall"), + "confirmation_id": .string("abc123") + ]) + ) + let originalParams = OperationParams.uninstallRun(noReboot: true, noWait: true, mountWait: 12, password: "pw") + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: originalParams)) XCTAssertEqual(confirmation.operation, "uninstall") - XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) - XCTAssertEqual(confirmation.params["confirm_uninstall"], .bool(true)) - XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(false)) + XCTAssertEqual(confirmation.title, "Confirm uninstall") + XCTAssertEqual(confirmation.message, "Remove files.") + XCTAssertEqual(confirmation.actionTitle, "Uninstall") + XCTAssertEqual(confirmation.params["confirmation_id"], .string("abc123")) XCTAssertEqual(confirmation.params["no_reboot"], .bool(true)) XCTAssertEqual(confirmation.params["mount_wait"], .number(12)) XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) + XCTAssertEqual(confirmation.params["credentials"], .object(["password": .string("pw")])) } - func testMaintenanceConfirmationsCarryExplicitOperationConsent() { - let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true, mountWait: 18, noWait: true) - let repair = PendingConfirmation.repairXattrs(path: "/Volumes/Data") + func testMaintenanceRunParamsDoNotCarryFrontendConsentFlags() { + let fsck = OperationParams.fsckRun(volume: "Data", noReboot: true, noWait: true, mountWait: 18, password: "") + let repair = OperationParams.repairXattrsRun(path: "/Volumes/Data") - XCTAssertEqual(fsck.operation, "fsck") - XCTAssertEqual(fsck.params["confirm_fsck"], .bool(true)) - XCTAssertEqual(fsck.params["no_reboot"], .bool(true)) - XCTAssertEqual(fsck.params["mount_wait"], .number(18)) - XCTAssertEqual(fsck.params["no_wait"], .bool(true)) - XCTAssertEqual(fsck.params["volume"], .string("Data")) + XCTAssertNil(fsck["confirm_fsck"]) + XCTAssertEqual(fsck["no_reboot"], .bool(true)) + XCTAssertEqual(fsck["mount_wait"], .number(18)) + XCTAssertEqual(fsck["no_wait"], .bool(true)) + XCTAssertEqual(fsck["volume"], .string("Data")) - XCTAssertEqual(repair.operation, "repair-xattrs") - XCTAssertEqual(repair.params["path"], .string("/Volumes/Data")) - XCTAssertEqual(repair.params["dry_run"], .bool(false)) - XCTAssertEqual(repair.params["confirm_repair"], .bool(true)) + XCTAssertEqual(repair["path"], .string("/Volumes/Data")) + XCTAssertEqual(repair["dry_run"], .bool(false)) + XCTAssertNil(repair["confirm_repair"]) } } diff --git a/src/timecapsulesmb/app/confirmations.py b/src/timecapsulesmb/app/confirmations.py new file mode 100644 index 00000000..26c9f22f --- /dev/null +++ b/src/timecapsulesmb/app/confirmations.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from typing import Mapping + +from timecapsulesmb.services.app import AppOperationError, jsonable + + +CONFIRMATION_SCHEMA_VERSION = 1 +_LEGACY_CONFIRM_KEYS = frozenset({ + "yes", + "confirm", + "confirm_deploy", + "confirm_reboot", + "confirm_netbsd4_activation", + "confirm_uninstall", + "confirm_fsck", + "confirm_repair", +}) +_CONFIRMATION_ONLY_KEYS = frozenset({ + "confirmation_id", + "confirmation", + *_LEGACY_CONFIRM_KEYS, +}) +_SECRET_PARAM_KEYS = frozenset({"password", "credentials"}) + + +@dataclass(frozen=True) +class ConfirmationRequest: + operation: str + title: str + message: str + action_title: str + risk: str + confirmation_id: str + summary: str + context: Mapping[str, object] + + def to_jsonable(self) -> dict[str, object]: + return { + "schema_version": CONFIRMATION_SCHEMA_VERSION, + "operation": self.operation, + "title": self.title, + "message": self.message, + "action_title": self.action_title, + "risk": self.risk, + "confirmation_id": self.confirmation_id, + "summary": self.summary, + "context": jsonable(dict(self.context)), + } + + +class AppConfirmationRequired(AppOperationError): + def __init__(self, confirmation: ConfirmationRequest) -> None: + super().__init__(confirmation.message, code="confirmation_required") + self.confirmation = confirmation + + +def _safe_params(params: Mapping[str, object]) -> dict[str, object]: + return { + str(key): value + for key, value in params.items() + if str(key) not in _CONFIRMATION_ONLY_KEYS and str(key) not in _SECRET_PARAM_KEYS + } + + +def _confirmation_id(operation: str, params: Mapping[str, object], context: Mapping[str, object]) -> str: + canonical = { + "schema_version": CONFIRMATION_SCHEMA_VERSION, + "operation": operation, + "params": jsonable(_safe_params(params)), + "context": jsonable(dict(context)), + } + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def build_confirmation( + *, + operation: str, + params: Mapping[str, object], + title: str, + message: str, + action_title: str, + risk: str, + summary: str, + context: Mapping[str, object], +) -> ConfirmationRequest: + return ConfirmationRequest( + operation=operation, + title=title, + message=message, + action_title=action_title, + risk=risk, + confirmation_id=_confirmation_id(operation, params, context), + summary=summary, + context=context, + ) + + +def supplied_confirmation_id(params: Mapping[str, object]) -> str: + direct = params.get("confirmation_id") + if isinstance(direct, str): + return direct.strip() + nested = params.get("confirmation") + if isinstance(nested, Mapping): + nested_id = nested.get("id") or nested.get("confirmation_id") + if isinstance(nested_id, str): + return nested_id.strip() + return "" + + +def has_legacy_confirmation(params: Mapping[str, object], *names: str) -> bool: + from timecapsulesmb.services.app import bool_param + + if "yes" in params and bool_param(dict(params), "yes"): + return True + return bool(names) and all(name in params and bool_param(dict(params), name) for name in names) + + +def require_confirmation( + params: Mapping[str, object], + confirmation: ConfirmationRequest, + *, + legacy_names: tuple[str, ...] = (), +) -> None: + if has_legacy_confirmation(params, *legacy_names): + return + if supplied_confirmation_id(params) == confirmation.confirmation_id: + return + raise AppConfirmationRequired(confirmation) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index 382fbb03..b1e322dd 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -17,6 +17,26 @@ def _with_schema(payload: Mapping[str, object]) -> dict[str, object]: return data +def capabilities_payload( + *, + helper_version: str, + helper_version_code: int, + operations: list[str], + distribution_root: str, + artifact_manifest_sha256: str | None, +) -> dict[str, object]: + return _with_schema({ + "api_schema_version": SCHEMA_VERSION, + "helper_version": helper_version, + "helper_version_code": helper_version_code, + "operations": operations, + "distribution_root": distribution_root, + "artifact_manifest_sha256": artifact_manifest_sha256, + "confirmation_schema_version": 1, + "summary": "helper capabilities resolved.", + }) + + def _device_payload(*, host: str | None = None, syap: str | None = None, model: str | None = None) -> dict[str, object]: return { "host": host, diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index 1b577bb3..9abdb1e4 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -112,10 +112,13 @@ def error( message: str, *, code: str = "operation_failed", + details: object | None = None, debug: object | None = None, recovery: object | None = None, ) -> None: fields: dict[str, object] = {"code": code, "message": message} + if details is not None: + fields["details"] = details if debug is not None: fields["debug"] = debug if recovery is not None: diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py deleted file mode 100644 index ab90ec31..00000000 --- a/src/timecapsulesmb/app/operations.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -# Compatibility shim for callers that imported or monkeypatched the original -# monolithic module. New code should import from timecapsulesmb.app.ops. - -from collections.abc import Callable - -from timecapsulesmb.app.events import EventSink -from timecapsulesmb.app.ops import configure as _configure -from timecapsulesmb.app.ops import deploy as _deploy -from timecapsulesmb.app.ops import doctor as _doctor -from timecapsulesmb.app.ops import maintenance as _maintenance -from timecapsulesmb.app.ops import readiness as _readiness -from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME -from timecapsulesmb.device.storage import build_dry_run_payload_home -from timecapsulesmb.services.app import ( - AppOperationError, - OperationResult, - bool_param as _bool_param, - config_path as _config_path, - confirm_param as _confirm_param, - float_param as _float_param, - int_param as _int_param, - jsonable as _jsonable, - optional_int_param as _optional_int_param, - require_string_param as _require_string_param, - string_param as _string_param, -) - - -discover_snapshot = _readiness.discover_snapshot - -probe_connection_state = _configure.probe_connection_state -enable_ssh = _configure.enable_ssh - -load_env_config = _deploy.load_env_config -resolve_validated_managed_target = _deploy.resolve_validated_managed_target -resolve_app_paths = _deploy.resolve_app_paths -validate_artifacts = _deploy.validate_artifacts -resolve_payload_artifacts = _deploy.resolve_payload_artifacts -run_remote_actions = _deploy.run_remote_actions -wait_for_mast_volumes_conn = _deploy.wait_for_mast_volumes_conn -select_payload_home_with_diagnostics_conn = _deploy.select_payload_home_with_diagnostics_conn -verify_payload_home_conn = _deploy.verify_payload_home_conn -upload_deployment_payload = _deploy.upload_deployment_payload -flush_remote_filesystem_writes = _deploy.flush_remote_filesystem_writes -wait_for_ssh_state_conn = _deploy.wait_for_ssh_state_conn - -resolve_env_connection = _maintenance.resolve_env_connection -remote_uninstall_payload = _maintenance.remote_uninstall_payload -read_mast_volumes_conn = _maintenance.read_mast_volumes_conn -mounted_mast_volumes_conn = _maintenance.mounted_mast_volumes_conn -run_ssh = _maintenance.run_ssh -probe_managed_runtime_conn = _maintenance.probe_managed_runtime_conn -load_optional_env_config = _maintenance.load_optional_env_config -repair_xattrs_cli = _maintenance.repair_xattrs_cli -sys = _maintenance.sys - -run_doctor_checks = _doctor.run_doctor_checks - - -def _sync_compat_bindings() -> None: - _readiness.discover_snapshot = discover_snapshot - _readiness.resolve_app_paths = resolve_app_paths - - _configure.probe_connection_state = probe_connection_state - _configure.enable_ssh = enable_ssh - _configure.resolve_app_paths = resolve_app_paths - - _deploy.load_env_config = load_env_config - _deploy.resolve_validated_managed_target = resolve_validated_managed_target - _deploy.resolve_app_paths = resolve_app_paths - _deploy.validate_artifacts = validate_artifacts - _deploy.resolve_payload_artifacts = resolve_payload_artifacts - _deploy.run_remote_actions = run_remote_actions - _deploy.wait_for_mast_volumes_conn = wait_for_mast_volumes_conn - _deploy.select_payload_home_with_diagnostics_conn = select_payload_home_with_diagnostics_conn - _deploy.verify_payload_home_conn = verify_payload_home_conn - _deploy.upload_deployment_payload = upload_deployment_payload - _deploy.flush_remote_filesystem_writes = flush_remote_filesystem_writes - _deploy.wait_for_ssh_state_conn = wait_for_ssh_state_conn - - _maintenance.load_env_config = load_env_config - _maintenance.resolve_env_connection = resolve_env_connection - _maintenance.remote_uninstall_payload = remote_uninstall_payload - _maintenance.read_mast_volumes_conn = read_mast_volumes_conn - _maintenance.mounted_mast_volumes_conn = mounted_mast_volumes_conn - _maintenance.run_ssh = run_ssh - _maintenance.wait_for_ssh_state_conn = wait_for_ssh_state_conn - _maintenance.run_remote_actions = run_remote_actions - _maintenance.probe_managed_runtime_conn = probe_managed_runtime_conn - _maintenance.load_optional_env_config = load_optional_env_config - _maintenance.repair_xattrs_cli = repair_xattrs_cli - _maintenance.sys = sys - - _doctor.load_env_config = load_env_config - _doctor.resolve_app_paths = resolve_app_paths - _doctor.resolve_env_connection = resolve_env_connection - _doctor.run_doctor_checks = run_doctor_checks - - -def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _readiness.discover_operation(params, sink) - - -def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _readiness.paths_operation(params, sink) - - -def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _readiness.validate_install_operation(params, sink) - - -def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _configure.configure_operation(params, sink) - - -def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _deploy.deploy_operation(params, sink) - - -def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.activate_operation(params, sink) - - -def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.uninstall_operation(params, sink) - - -def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.fsck_operation(params, sink) - - -def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.repair_xattrs_operation(params, sink) - - -def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _doctor.doctor_operation(params, sink) - - -_selected_record_host = _readiness.selected_record_host -_selected_record_properties = _readiness.selected_record_properties -_snapshot_payload = _readiness.snapshot_payload -_wait_for_ssh_port = _configure.wait_for_ssh_port -_require_supported_payload = _deploy.require_supported_payload -_load_config_and_target = _deploy.load_config_and_target -_verify_payload_upload = _deploy.verify_payload_upload -_verify_runtime = _deploy.verify_runtime -_request_reboot_and_wait = _deploy.request_reboot_and_wait -_request_ssh_reboot = _deploy.request_ssh_reboot -_observe_reboot_cycle = _maintenance.observe_reboot_cycle -_RepairContext = _maintenance.RepairExecutionContext -_StreamLogCapture = _maintenance.LineLogCapture - - -OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { - "activate": activate_operation, - "configure": configure_operation, - "deploy": deploy_operation, - "discover": discover_operation, - "doctor": doctor_operation, - "fsck": fsck_operation, - "paths": paths_operation, - "repair-xattrs": repair_xattrs_operation, - "uninstall": uninstall_operation, - "validate-install": validate_install_operation, -} diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index b015146d..8a961c45 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -12,12 +12,18 @@ repair_xattrs_operation, uninstall_operation, ) -from timecapsulesmb.app.ops.readiness import discover_operation, paths_operation, validate_install_operation +from timecapsulesmb.app.ops.readiness import ( + capabilities_operation, + discover_operation, + paths_operation, + validate_install_operation, +) from timecapsulesmb.services.app import OperationResult OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { "activate": activate_operation, + "capabilities": capabilities_operation, "configure": configure_operation, "deploy": deploy_operation, "discover": discover_operation, diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index 5e9b7b54..1d88b0dd 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -9,7 +9,6 @@ DEFAULTS, parse_bool, parse_env_file, - write_env_file, ) from timecapsulesmb.core.net import extract_host from timecapsulesmb.core.paths import resolve_app_paths @@ -26,11 +25,11 @@ require_string_param, string_param, ) +from timecapsulesmb.services.config_store import EnvFileConfigStore from timecapsulesmb.services.configure import build_configure_env_values +from timecapsulesmb.services.runtime import ssh_target_link_local_resolution_error, wait_for_tcp_port_state from timecapsulesmb.transport.ssh import SshConnection -from timecapsulesmb.cli.runtime import ssh_target_link_local_resolution_error - def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "configure" @@ -113,7 +112,8 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation sink.stage(operation, "write_env") env_path.parent.mkdir(parents=True, exist_ok=True) - write_env_file(env_path, values) + omit_keys = frozenset() if bool_param(params, "persist_password") else frozenset({"TC_PASSWORD"}) + EnvFileConfigStore(omit_keys=omit_keys).save(env_path, values) return OperationResult(True, configure_payload( config_path=str(env_path), host=host, @@ -126,13 +126,10 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation def wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: - from timecapsulesmb.cli.flows import wait_for_tcp_port_state - return wait_for_tcp_port_state( extract_host(host), 22, expected_state=True, timeout_seconds=timeout_seconds, - verbose=False, service_name="SSH port", ) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 7757082d..75716b59 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -5,8 +5,8 @@ import tempfile from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.events import EventSink -from timecapsulesmb.cli.runtime import load_env_config, resolve_validated_managed_target from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, airport_family_display_name_from_identity from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP from timecapsulesmb.core.net import extract_host @@ -64,9 +64,9 @@ OperationResult, bool_param, config_path, - confirm_param, int_param, ) +from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import ( DEPLOY_REBOOT_NO_DOWN_MESSAGE, DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, @@ -75,6 +75,7 @@ payload_verification_error, render_flash_runtime_config, ) +from timecapsulesmb.services.runtime import load_env_config, resolve_validated_managed_target from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError @@ -105,7 +106,7 @@ def load_config_and_target( include_probe: bool, ) -> tuple[AppConfig, object]: sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_managed_target") target = resolve_validated_managed_target( config, @@ -122,16 +123,10 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes dry_run = bool_param(params, "dry_run") no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") - confirm_deploy = confirm_param(params, "confirm_deploy") - confirm_reboot = confirm_param(params, "confirm_reboot") - confirm_netbsd4_activation = confirm_param(params, "confirm_netbsd4_activation") mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) allow_unsupported = bool_param(params, "allow_unsupported") debug_logging = bool_param(params, "debug_logging") - if not dry_run and not confirm_deploy: - raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") - config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) connection = target.connection app_paths = resolve_app_paths(config_path=config_path(params)) @@ -148,21 +143,63 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) if not dry_run: - if is_netbsd4 and not confirm_netbsd4_activation: - raise AppOperationError( - "NetBSD 4 deploy requires explicit activation confirmation.", - code="confirmation_required", - ) - if not is_netbsd4 and not no_reboot and not confirm_reboot: - device_name = airport_family_display_name_from_identity( - model=target.probe_state.probe_result.airport_model if target.probe_state else None, - syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, - ) - raise AppOperationError( - f"Deploy requires confirmation to reboot the {device_name}.", - code="confirmation_required", - ) - + confirmation_plan = build_deployment_plan( + connection.host, + build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME), + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + device_name = airport_family_display_name_from_identity( + model=target.probe_state.probe_result.airport_model if target.probe_state else None, + syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, + ) + if is_netbsd4: + title = "Confirm NetBSD4 deployment" + message = f"Deploy and activate the NetBSD4 payload on this {device_name}. Remote services will be changed." + action_title = "Deploy and activate" + risk = "destructive" + summary = "NetBSD4 deployment with service activation" + elif no_reboot: + title = "Confirm deployment" + message = f"Deploy TimeCapsuleSMB to this {device_name} without rebooting it." + action_title = "Deploy" + risk = "remote_write" + summary = "Deployment without reboot" + else: + title = "Confirm deployment and reboot" + message = f"Deploy TimeCapsuleSMB and reboot this {device_name}." + action_title = "Deploy and reboot" + risk = "reboot" + summary = "Deployment with reboot request" + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title=title, + message=message, + action_title=action_title, + risk=risk, + summary=summary, + context={ + "host": connection.host, + "payload_family": payload_family, + "netbsd4": is_netbsd4, + "requires_reboot": bool(confirmation_plan.reboot_required), + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=( + ("confirm_deploy", "confirm_netbsd4_activation") + if is_netbsd4 + else ("confirm_deploy",) if no_reboot else ("confirm_deploy", "confirm_reboot") + ), + ) if dry_run: payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) else: diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py index 0996e41c..7bc12d81 100644 --- a/src/timecapsulesmb/app/ops/doctor.py +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -4,18 +4,19 @@ from timecapsulesmb.app.events import EventSink from timecapsulesmb.checks.doctor import run_doctor_checks from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.cli.doctor import build_doctor_error -from timecapsulesmb.cli.runtime import load_env_config, resolve_env_connection from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.services.app import OperationResult, bool_param, config_path, float_param +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.doctor import build_doctor_error +from timecapsulesmb.services.runtime import load_env_config, resolve_env_connection def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "doctor" bonjour_timeout = float_param(params, "bonjour_timeout", DEFAULT_BROWSE_TIMEOUT_SEC) sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) app_paths = resolve_app_paths(config_path=config_path(params)) connection = None if not bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 74b9e833..b2db7485 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -15,6 +15,7 @@ uninstall_plan_payload, uninstall_result_payload, ) +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.deploy import ( load_config_and_target, @@ -23,12 +24,6 @@ require_supported_payload, verify_runtime, ) -from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli -from timecapsulesmb.cli.fsck import ( - FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - build_remote_fsck_script, -) -from timecapsulesmb.cli.runtime import load_env_config, load_optional_env_config, resolve_env_connection from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP @@ -52,19 +47,21 @@ OperationResult, bool_param, config_path, - confirm_param, int_param, jsonable, optional_int_param, required_path_param, string_param, ) +from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE, LineLogCapture, RepairExecutionContext, + build_remote_fsck_script, format_fsck_plan, format_fsck_targets, fsck_plan_to_jsonable, @@ -72,12 +69,13 @@ fsck_target_to_jsonable, select_fsck_target, ) +from timecapsulesmb.services import repair_xattrs as repair_xattrs_service +from timecapsulesmb.services.runtime import load_env_config, load_optional_env_config, resolve_env_connection from timecapsulesmb.transport.ssh import SshConnection, run_ssh def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "activate" - confirm_activation = confirm_param(params, "confirm_netbsd4_activation") dry_run = bool_param(params, "dry_run") _, target = load_config_and_target(operation, params, sink, profile="activate", include_probe=True) compatibility = require_supported_payload(target, allow_unsupported=False) @@ -90,12 +88,28 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR plan = build_netbsd4_activation_plan() if dry_run: return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) - if not confirm_activation: - raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") connection = target.connection sink.stage(operation, "probe_runtime") if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: return OperationResult(True, activation_result_payload(already_active=True)) + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm NetBSD4 activation", + message="Activate the deployed NetBSD4 payload and restart managed services.", + action_title="Activate", + risk="destructive", + summary="NetBSD4 service activation", + context={ + "host": connection.host, + "payload_family": compatibility.payload_family, + "netbsd4": True, + }, + ), + legacy_names=("confirm_netbsd4_activation",), + ) sink.stage(operation, "run_activation") run_remote_actions(connection, plan.actions) verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) @@ -111,16 +125,33 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) - confirm_uninstall = confirm_param(params, "confirm_uninstall") - confirm_reboot = confirm_param(params, "confirm_reboot") - if not dry_run and not confirm_uninstall: - raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") - if not dry_run and not no_reboot and not confirm_reboot: - raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) + if not dry_run: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm uninstall", + message=( + "Remove managed TimeCapsuleSMB files from the device" + + (" and reboot it." if not no_reboot else ".") + ), + action_title="Uninstall", + risk="destructive" if not no_reboot else "remote_write", + summary="Uninstall managed payload" + (" with reboot" if not no_reboot else " without reboot"), + context={ + "host": connection.host, + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=("confirm_uninstall",) if no_reboot else ("confirm_uninstall", "confirm_reboot"), + ) if dry_run: volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] @@ -187,16 +218,33 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul operation = "fsck" dry_run = bool_param(params, "dry_run") list_volumes = bool_param(params, "list_volumes") - confirm_fsck = confirm_param(params, "confirm_fsck") no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) if dry_run and list_volumes: raise AppOperationError("dry_run and list_volumes are mutually exclusive.", code="validation_failed") - if not dry_run and not list_volumes and not confirm_fsck: - raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") + if not dry_run and not list_volumes: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm fsck", + message="Run fsck on the selected HFS volume" + (" and reboot the device." if not no_reboot else "."), + action_title="Run fsck", + risk="destructive" if not no_reboot else "remote_write", + summary="Filesystem check and repair", + context={ + "volume": string_param(params, "volume"), + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=("confirm_fsck",), + ) sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) sink.stage(operation, "read_mast") @@ -297,12 +345,6 @@ def observe_reboot_cycle( def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" dry_run = bool_param(params, "dry_run") - confirm_repair = confirm_param(params, "confirm_repair") - if not dry_run and not confirm_repair: - raise AppOperationError( - "repair-xattrs requires dry_run or explicit confirmation.", - code="confirmation_required", - ) sink.stage(operation, "platform_check") if sys.platform != "darwin": raise AppOperationError( @@ -311,11 +353,26 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera ) sink.stage(operation, "validate_params") path = required_path_param(params, "path") + if not dry_run: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm xattr repair", + message=f"Repair known-safe macOS metadata issues under {path}.", + action_title="Repair xattrs", + risk="local_write", + summary="Repair local mounted-share metadata", + context={"path": str(path)}, + ), + legacy_names=("confirm_repair",), + ) config = load_optional_env_config(env_path=config_path(params)) args = argparse.Namespace( path=path, dry_run=dry_run, - yes=confirm_repair, + yes=not dry_run, recursive=bool_param(params, "recursive", True), max_depth=optional_int_param(params, "max_depth"), include_hidden=bool_param(params, "include_hidden"), @@ -328,7 +385,7 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) try: with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): - result = repair_xattrs_cli.run_repair_structured( + result = repair_xattrs_service.run_repair_structured( args, context, config, diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index 05e10b38..7fc7d82a 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -1,8 +1,11 @@ from __future__ import annotations -from timecapsulesmb.app.contracts import discover_payload, install_validation_payload, paths_payload +import hashlib + +from timecapsulesmb.app.contracts import capabilities_payload, discover_payload, install_validation_payload, paths_payload from timecapsulesmb.app.events import EventSink -from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.core.paths import artifact_manifest_resource, resolve_app_paths +from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE from timecapsulesmb.discovery.bonjour import ( DEFAULT_BROWSE_TIMEOUT_SEC, BonjourDiscoverySnapshot, @@ -67,6 +70,36 @@ def discover_operation(params: dict[str, object], sink: EventSink) -> OperationR return OperationResult(True, discover_payload(snapshot_payload(snapshot))) +def capabilities_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "capabilities" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "summarize_capabilities") + try: + manifest_hash = hashlib.sha256(artifact_manifest_resource().read_bytes()).hexdigest() + except OSError: + manifest_hash = None + return OperationResult(True, capabilities_payload( + helper_version=CLI_VERSION, + helper_version_code=CLI_VERSION_CODE, + operations=[ + "activate", + "capabilities", + "configure", + "deploy", + "discover", + "doctor", + "fsck", + "paths", + "repair-xattrs", + "uninstall", + "validate-install", + ], + distribution_root=str(app_paths.distribution_root), + artifact_manifest_sha256=manifest_hash, + )) + + def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "paths" sink.stage(operation, "resolve_paths") diff --git a/src/timecapsulesmb/app/requests.py b/src/timecapsulesmb/app/requests.py new file mode 100644 index 00000000..ed844415 --- /dev/null +++ b/src/timecapsulesmb/app/requests.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from timecapsulesmb.services.app import AppOperationError + + +@dataclass(frozen=True) +class ApiRequest: + operation: str + params: dict[str, object] + request_id: str | None = None + + +def parse_api_request(request: Mapping[str, object]) -> ApiRequest: + request_id = request.get("request_id") + operation = str(request.get("operation") or "") + if not operation: + raise AppOperationError("missing required field: operation", code="invalid_request") + + raw_params = request.get("params", {}) + if raw_params is None: + raw_params = {} + if not isinstance(raw_params, dict): + raise AppOperationError("params must be a JSON object", code="invalid_request") + + return ApiRequest( + operation=operation, + params=dict(raw_params), + request_id=str(request_id) if request_id is not None and str(request_id).strip() else None, + ) diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index ca796394..92695ec7 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -4,46 +4,32 @@ from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.app.operations import OPERATIONS +from timecapsulesmb.app.ops import OPERATIONS +from timecapsulesmb.app.confirmations import AppConfirmationRequired +from timecapsulesmb.app.requests import parse_api_request from timecapsulesmb.app.recovery import recovery_for from timecapsulesmb.core.config import ConfigError from timecapsulesmb.services.app import AppOperationError, OperationResult from timecapsulesmb.transport.errors import TransportError -def _request_operation(request: dict[str, object]) -> str: - return str(request.get("operation") or "") - - -def _request_params(request: dict[str, object]) -> object: - if "params" not in request or request.get("params") is None: - return {} - return request.get("params") - - def run_api_request(request: dict[str, object], sink: EventSink) -> int: - request_id = request.get("request_id") - if request_id is not None and str(request_id).strip(): - sink = sink.with_request_id(str(request_id)) - - operation = _request_operation(request) - params = _request_params(request) - if not operation: + try: + api_request = parse_api_request(request) + except AppOperationError as exc: sink.error( "api", - "missing required field: operation", - code="invalid_request", + str(exc), + code=exc.code, recovery=recovery_for("api", "invalid_request"), ) return 1 - if not isinstance(params, dict): - sink.error( - operation, - "params must be a JSON object", - code="invalid_request", - recovery=recovery_for(operation, "invalid_request"), - ) - return 1 + + if api_request.request_id: + sink = sink.with_request_id(api_request.request_id) + + operation = api_request.operation + params = api_request.params handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) if handler is None: sink.error( @@ -56,6 +42,15 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: return 1 try: result = handler(params, sink) + except AppConfirmationRequired as exc: + sink.error( + operation, + str(exc), + code=exc.code, + details=exc.confirmation.to_jsonable(), + recovery=recovery_for(operation, exc.code, stage=sink.current_stage(operation)), + ) + return 1 except AppOperationError as exc: recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) sink.error( diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index 263fc478..3ea3c875 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -35,6 +35,8 @@ def to_jsonable(self) -> dict[str, object]: _POLICIES: dict[tuple[str, str], StagePolicy] = { + ("capabilities", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve helper configuration and distribution paths."), + ("capabilities", "summarize_capabilities"): StagePolicy(LOCAL_READ, True, "Summarize helper API capabilities."), ("discover", "bonjour_discovery"): StagePolicy(LOCAL_READ, True, "Browse for AirPort Bonjour services."), ("paths", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve configuration, state, and distribution paths."), ("paths", "summarize_artifacts"): StagePolicy(LOCAL_READ, True, "Summarize bundled artifact paths."), diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index c49bbf54..524fcacf 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -7,10 +7,11 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import observe_reboot_cycle from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config -from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, FSCK_REBOOT_NO_DOWN_MESSAGE, + build_remote_fsck_script, format_fsck_plan, format_fsck_targets, fsck_target_from_volume, @@ -20,31 +21,6 @@ from timecapsulesmb.transport.ssh import run_ssh -FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 - - -def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: - lines = [ - render_direct_pkill9_watchdog(), - render_direct_pkill9_by_ucomm("smbd"), - render_direct_pkill9_by_ucomm("afpserver"), - render_direct_pkill9_by_ucomm("wcifsnd"), - render_direct_pkill9_by_ucomm("wcifsfs"), - "sleep 2", - f"/sbin/umount -f {shlex.quote(mountpoint)} >/dev/null 2>&1 || true", - f"echo '--- fsck_hfs {device} ---'", - f"/sbin/fsck_hfs -fy {shlex.quote(device)} 2>&1 || true", - ] - if reboot: - lines.extend( - [ - "echo '--- reboot ---'", - "/sbin/reboot >/dev/null 2>&1 || true", - ] - ) - return "\n".join(lines) - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Run fsck_hfs on a mounted HFS volume and reboot by default.") add_config_argument(parser) diff --git a/src/timecapsulesmb/cli/runtime.py b/src/timecapsulesmb/cli/runtime.py index 702c519d..de1fab3f 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -31,6 +31,7 @@ ) from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC +from timecapsulesmb.services import runtime as service_runtime from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -175,8 +176,7 @@ def load_config_from_args( def load_env_config(*, env_path: Path | None = None, defaults: dict[str, str] | None = None) -> AppConfig: - resolved_path = resolve_app_paths(config_path=env_path).config_path - return load_app_config(resolved_path, defaults=defaults) + return service_runtime.load_env_config(env_path=env_path, defaults=defaults, resolve_paths=resolve_app_paths) def load_optional_env_config( @@ -184,16 +184,7 @@ def load_optional_env_config( env_path: Path | None = None, defaults: dict[str, str] | None = None, ) -> AppConfig: - try: - resolved_path = resolve_app_paths(config_path=env_path).config_path - except Exception: - return AppConfig.missing(path=env_path or Path.cwd() / ".env") - if not resolved_path.exists(): - return AppConfig.missing(path=resolved_path) - try: - return load_app_config(resolved_path, defaults=defaults) - except OSError: - return AppConfig.missing(path=resolved_path) + return service_runtime.load_optional_env_config(env_path=env_path, defaults=defaults, resolve_paths=resolve_app_paths) def resolve_ssh_credentials( @@ -201,12 +192,7 @@ def resolve_ssh_credentials( *, allow_empty_password: bool = False, ) -> tuple[str, str]: - host = config.require("TC_HOST") - password = config.get("TC_PASSWORD") - if not password and not allow_empty_password: - import getpass - password = getpass.getpass("Device root password: ") - return host, password + return service_runtime.resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) def resolve_env_connection( @@ -215,10 +201,11 @@ def resolve_env_connection( required_keys: tuple[str, ...] = (), allow_empty_password: bool = False, ) -> SshConnection: - for key in required_keys: - config.require(key) - host, password = resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) - return SshConnection(host=host, password=password, ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + return service_runtime.resolve_env_connection( + config, + required_keys=required_keys, + allow_empty_password=allow_empty_password, + ) def inspect_managed_connection( @@ -227,9 +214,7 @@ def inspect_managed_connection( *, include_probe: bool = False, ) -> ManagedTargetState: - interface_probe = probe_remote_interface_conn(connection, iface) - probe_state = probe_connection_state(connection) if include_probe else None - return ManagedTargetState(connection=connection, interface_probe=interface_probe, probe_state=probe_state) + return service_runtime.inspect_managed_connection(connection, iface, include_probe=include_probe) def ssh_target_link_local_resolution_error( @@ -238,20 +223,7 @@ def ssh_target_link_local_resolution_error( *, field_name: str = "Device SSH target", ) -> str | None: - if ssh_opts_use_proxy(ssh_opts): - return None - host = extract_host(target).strip() - if not host or ipv4_literal(host) is not None: - return None - link_local_ips = tuple(ip for ip in resolve_host_ipv4s(host) if is_link_local_ipv4(ip)) - if not link_local_ips: - return None - noun = "address" if len(link_local_ips) == 1 else "addresses" - return ( - f"{field_name} host {host} resolves to 169.254.x.x link-local IPv4 {noun} " - f"{', '.join(link_local_ips)}. Use the device's LAN IP or a hostname that resolves " - "to its LAN IP; 169.254.x.x is only suitable for temporary SSH recovery." - ) + return service_runtime.ssh_target_link_local_resolution_error(target, ssh_opts, field_name=field_name) def resolve_validated_managed_target( @@ -261,27 +233,16 @@ def resolve_validated_managed_target( profile: str, include_probe: bool = False, ) -> ManagedTargetState: - require_valid_app_config(config, profile=profile, command_name=command_name) - resolution_error = ssh_target_link_local_resolution_error( - config.require("TC_HOST"), - config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), - field_name="TC_HOST", + return service_runtime.resolve_validated_managed_target( + config, + command_name=command_name, + profile=profile, + include_probe=include_probe, ) - if resolution_error is not None: - raise ConfigError(resolution_error) - connection = resolve_env_connection(config) - if profile == "flash": - return ManagedTargetState(connection=connection, interface_probe=None, probe_state=None) - probe_state = probe_connection_state(connection) if include_probe else None - return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) def require_connection_compatibility(connection: SshConnection) -> DeviceCompatibility: - state = probe_connection_state(connection) - return require_compatibility( - state.compatibility, - fallback_error=state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) + return service_runtime.require_connection_compatibility(connection) def require_supported_device_compatibility( diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 64723ebe..6ed5b105 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -53,8 +53,12 @@ def bool_param(params: dict[str, object], name: str, default: bool = False) -> b if isinstance(value, bool): return value if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y"} - return bool(value) + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y"}: + return True + if normalized in {"0", "false", "no", "n"}: + return False + raise AppOperationError(f"{name} must be a boolean", code="validation_failed") def confirm_param(params: dict[str, object], name: str) -> bool: diff --git a/src/timecapsulesmb/services/config_store.py b/src/timecapsulesmb/services/config_store.py new file mode 100644 index 00000000..8aaaebce --- /dev/null +++ b/src/timecapsulesmb/services/config_store.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Protocol + +from timecapsulesmb.core.config import AppConfig, load_app_config, write_env_file + + +class ConfigStore(Protocol): + def load(self, path: Path, *, defaults: dict[str, str] | None = None) -> AppConfig: + ... + + def save(self, path: Path, values: Mapping[str, str]) -> None: + ... + + +@dataclass(frozen=True) +class EnvFileConfigStore: + omit_keys: frozenset[str] = frozenset() + + def load(self, path: Path, *, defaults: dict[str, str] | None = None) -> AppConfig: + return load_app_config(path, defaults=defaults) + + def save(self, path: Path, values: Mapping[str, str]) -> None: + filtered = { + key: value + for key, value in values.items() + if key not in self.omit_keys + } + write_env_file(path, filtered) diff --git a/src/timecapsulesmb/services/credentials.py b/src/timecapsulesmb/services/credentials.py new file mode 100644 index 00000000..d1c214cc --- /dev/null +++ b/src/timecapsulesmb/services/credentials.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Mapping + +from timecapsulesmb.core.config import AppConfig + + +def request_password(params: Mapping[str, object]) -> str: + value = params.get("password") + if isinstance(value, str) and value: + return value + credentials = params.get("credentials") + if isinstance(credentials, Mapping): + nested = credentials.get("password") + if isinstance(nested, str) and nested: + return nested + return "" + + +def overlay_request_credentials(config: AppConfig, params: Mapping[str, object]) -> AppConfig: + password = request_password(params) + if not password: + return config + values = dict(config.values) + values["TC_PASSWORD"] = password + return AppConfig.from_values( + values, + path=config.path, + exists=config.exists, + file_values=config.file_values, + ) diff --git a/src/timecapsulesmb/services/doctor.py b/src/timecapsulesmb/services/doctor.py index c8737d37..992db561 100644 --- a/src/timecapsulesmb/services/doctor.py +++ b/src/timecapsulesmb/services/doctor.py @@ -1,10 +1,221 @@ from __future__ import annotations +import re +from collections.abc import Mapping + from timecapsulesmb.checks.models import CheckResult +BONJOUR_INSTANCE_FAILURE_PREFIX = "no discovered _smb._tcp instance matched" + + def doctor_status_counts(results: list[CheckResult]) -> dict[str, int]: return { status: sum(1 for result in results if result.status == status) for status in ("PASS", "WARN", "FAIL", "INFO") } + + +def _mapping_value(value: object, key: str) -> object | None: + if isinstance(value, Mapping): + return value.get(key) + return None + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _as_sequence(value: object) -> list[object]: + if isinstance(value, list): + return list(value) + if isinstance(value, tuple): + return list(value) + return [] + + +def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | None: + for result in results: + if result.status != "FAIL" or BONJOUR_INSTANCE_FAILURE_PREFIX not in result.message: + continue + match = re.search( + r"expected (?:device |configured )?instance (?P['\"])(?P.*?)(?P=quote)", + result.message, + ) + if match: + return match.group("name") + return None + + +def _debug_bonjour_expected_instance(debug_fields: Mapping[str, object]) -> str | None: + expected = _mapping_value(debug_fields, "bonjour_expected") + value = _mapping_value(expected, "instance_name") + return value if isinstance(value, str) and value else None + + +def _bonjour_failure_uses_instance_match(results: list[CheckResult]) -> bool: + return any(result.status == "FAIL" and BONJOUR_INSTANCE_FAILURE_PREFIX in result.message for result in results) + + +def _native_dns_sd_smb_names(debug_fields: Mapping[str, object]) -> list[str]: + native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") + names: list[str] = [] + for browse in _as_sequence(_mapping_value(native_dns_sd, "browses")): + browse_type = str(_mapping_value(browse, "service_type") or "") + for event in _as_sequence(_mapping_value(browse, "events")): + event_type = str(_mapping_value(event, "service_type") or browse_type) + if not event_type.rstrip(".").startswith("_smb._tcp"): + continue + if str(_mapping_value(event, "action") or "").lower() != "add": + continue + name = _mapping_value(event, "name") + if isinstance(name, str) and name and name not in names: + names.append(name) + return names + + +def build_discovery_context(results: list[CheckResult], debug_fields: Mapping[str, object]) -> list[str]: + if not _bonjour_failure_uses_instance_match(results): + return [] + + zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") + zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) + if zeroconf_instance_count != 0: + return [] + + native_smb_names = _native_dns_sd_smb_names(debug_fields) + expected_instance = _debug_bonjour_expected_instance(debug_fields) or _expected_bonjour_instance_from_results(results) + native_saw_expected = expected_instance is not None and expected_instance in native_smb_names + if not native_saw_expected: + return [] + + return [ + "INFO Python zeroconf discovered 0 Bonjour instances during doctor", + f"INFO native dns-sd discovered expected _smb._tcp instance {expected_instance!r}", + ( + "INFO likely doctor false negative: native macOS mDNS saw the expected service " + "but Python zeroconf did not receive browse events" + ), + ] + + +def _last_regex_group(pattern: str, text: str) -> str | None: + matches = list(re.finditer(pattern, text)) + if not matches: + return None + match = matches[-1] + return match.group(1) if match.groups() else match.group(0) + + +def _extract_generated_service_types(mdns_log: str) -> list[str]: + service_types: list[str] = [] + for match in re.finditer(r"serving service: type=([^ ]+)", mdns_log): + service_type = match.group(1) + if service_type not in service_types: + service_types.append(service_type) + return service_types + + +def build_mdns_boot_context(debug_fields: Mapping[str, object]) -> list[str]: + rc_log = _mapping_value(debug_fields, "remote_rc_local_log_tail") + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + rc_text = rc_log if isinstance(rc_log, str) else "" + mdns_text = mdns_log if isinstance(mdns_log, str) else "" + combined = f"{rc_text}\n{mdns_text}" + if not combined.strip(): + return [] + + lines: list[str] = [] + capture_failed = any( + marker in combined + for marker in ( + "mDNS snapshot capture exited with failure", + "mDNS snapshot capture ended without status", + "mDNS snapshot capture timed out", + "mDNS snapshot capture did not produce trusted Apple snapshot", + "warning: could not identify local Apple mDNS records", + ) + ) + fallback_generated = ( + "generating AirPort fallback" in combined + or "airport snapshot: wrote" in combined + or "mDNS AirPort snapshot generated" in combined + ) + generated_fallback = "mdns advertiser will fall back to generated records" in combined + + if capture_failed and fallback_generated: + lines.append("INFO trusted Apple mDNS snapshot capture failed; AirPort fallback snapshot was generated") + elif capture_failed and generated_fallback: + lines.append( + "INFO trusted Apple mDNS snapshot capture failed; mdns-advertiser fell back to generated records" + ) + elif capture_failed: + lines.append("INFO trusted Apple mDNS snapshot capture failed") + + snapshot_load = _last_regex_group(r"snapshot load: loaded ([^\n]+)", mdns_text) + if snapshot_load: + lines.append(f"INFO mDNS snapshot load: loaded {snapshot_load}") + + source = _last_regex_group(r"serving summary: source=([^\s]+)", mdns_text) + service_types = _extract_generated_service_types(mdns_text) + if source and service_types: + lines.append( + f"INFO mdns-advertiser source={source}; generated services include {', '.join(service_types)}" + ) + elif source: + lines.append(f"INFO mdns-advertiser source={source}") + + takeover = _last_regex_group(r"mDNS takeover established after ([^\n]+)", mdns_text) + if takeover: + lines.append(f"INFO mDNS takeover established after {takeover}") + + return lines + + +def build_doctor_error(results: list[CheckResult], debug_fields: Mapping[str, object] | None = None) -> str | None: + debug_fields = debug_fields or {} + fail_lines = [f"{result.status} {result.message}" for result in results if result.status == "FAIL"] + warn_lines = [f"{result.status} {result.message}" for result in results if result.status == "WARN"] + info_lines = [ + f"{result.status} {result.message}" + for result in results + if result.status == "INFO" and result.message.startswith("discovered _smb._tcp candidates:") + ] + discovery_lines = build_discovery_context(results, debug_fields) + mdns_boot_lines = build_mdns_boot_context(debug_fields) + lines: list[str] = [] + if fail_lines: + lines.append("Doctor failures:") + lines.extend(fail_lines) + if warn_lines: + if lines: + lines.append("") + lines.append("Doctor warnings:") + lines.extend(warn_lines) + if info_lines: + if lines: + lines.append("") + lines.append("Doctor context:") + lines.extend(info_lines) + if discovery_lines: + if lines: + lines.append("") + lines.append("Discovery context:") + lines.extend(discovery_lines) + if mdns_boot_lines: + if lines: + lines.append("") + lines.append("mDNS boot context:") + lines.extend(mdns_boot_lines) + return "\n".join(lines) if lines else None diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py index 67889e5e..cdc925a4 100644 --- a/src/timecapsulesmb/services/maintenance.py +++ b/src/timecapsulesmb/services/maintenance.py @@ -1,11 +1,14 @@ from __future__ import annotations from dataclasses import dataclass +import shlex from typing import Callable +from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.device.storage import MaStVolume +FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( "Reboot was requested but the device did not go down.\n" "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." @@ -117,6 +120,26 @@ def format_fsck_plan(target: FsckTarget, *, reboot: bool, wait: bool) -> str: return "\n".join(lines) +def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: + lines = [ + render_direct_pkill9_watchdog(), + render_direct_pkill9_by_ucomm("smbd"), + render_direct_pkill9_by_ucomm("afpserver"), + render_direct_pkill9_by_ucomm("wcifsnd"), + render_direct_pkill9_by_ucomm("wcifsfs"), + "sleep 2", + f"/sbin/umount -f {shlex.quote(mountpoint)} >/dev/null 2>&1 || true", + f"echo '--- fsck_hfs {device} ---'", + f"/sbin/fsck_hfs -fy {shlex.quote(device)} 2>&1 || true", + ] + if reboot: + lines.extend([ + "echo '--- reboot ---'", + "/sbin/reboot >/dev/null 2>&1 || true", + ]) + return "\n".join(lines) + + class RepairExecutionContext: def __init__(self, stage_callback: Callable[[str], None]) -> None: self._stage_callback = stage_callback diff --git a/src/timecapsulesmb/services/repair_xattrs.py b/src/timecapsulesmb/services/repair_xattrs.py new file mode 100644 index 00000000..6a9b336c --- /dev/null +++ b/src/timecapsulesmb/services/repair_xattrs.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.repair_xattrs import ( + ACTION_CLEAR_ARCH_FLAG, + ACTION_FIX_PERMISSIONS, + RepairCandidate, + RepairFinding, + RepairSummary, + actionable_findings, + build_repair_report, + default_share_path_from_config, + find_findings, + finding_to_candidate, + mounted_smb_shares, + path_exists, + repair_candidate, + unresolved_findings_after_success, + validate_repair_root_under_volumes, +) + + +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + + +def render_candidate_lines(candidates: list[RepairCandidate], *, dry_run: bool) -> list[str]: + verb = "Would repair" if dry_run else "Repairable" + lines: list[str] = [] + for candidate in candidates: + actions = ", ".join(candidate.actions) or "none" + flags = f", flags: {candidate.flags}" if candidate.flags else "" + lines.append(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + return lines + + +def render_diagnostic_lines(findings: list[RepairFinding], *, verbose: bool) -> list[str]: + lines: list[str] = [] + for finding in findings: + if finding.repairable: + continue + if finding.xattr_error or verbose: + detail = f"{finding.kind}: {finding.path} ({finding.path_type})" + if finding.flags: + detail += f" flags={finding.flags}" + if finding.xattr_error: + detail += f" xattr_error={finding.xattr_error}" + lines.append(f"WARN {detail}") + return lines + + +def render_summary_lines(summary: RepairSummary, *, dry_run: bool) -> list[str]: + lines = [ + "", + "Summary:", + f" scanned paths: {summary.scanned}", + f" scanned files: {summary.scanned_files}", + f" scanned directories: {summary.scanned_dirs}", + f" skipped: {summary.skipped}", + f" unreadable xattrs: {summary.unreadable}", + f" not repairable: {summary.not_repairable}", + f" repairable: {summary.repairable}", + f" permission repairs: {summary.permission_repairable}", + ] + if not dry_run: + lines.extend([ + f" repaired: {summary.repaired}", + f" failed: {summary.failed}", + ]) + return lines + + +def _emit_lines(emit: Callable[[str], None], lines: list[str]) -> None: + for line in lines: + emit(line) + + +def run_repair_structured( + args: argparse.Namespace, + command_context, + config: AppConfig, + *, + emit_log: Callable[[str], None] | None = None, + confirm: Callable[[str], bool] | None = None, +) -> RepairRunResult: + def emit(message: str) -> None: + if emit_log is not None: + emit_log(message) + + command_context.set_stage("resolve_scan_root") + command_context.update_fields( + dry_run=args.dry_run, + recursive=args.recursive, + max_depth=args.max_depth, + include_hidden=args.include_hidden, + include_time_machine=args.include_time_machine, + fix_permissions=args.fix_permissions, + explicit_path=args.path is not None, + ) + if args.path is None: + try: + root = default_share_path_from_config( + config, + shares=mounted_smb_shares(), + path_exists_func=path_exists, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + else: + root = args.path + if root is None: + raise SystemExit("Could not determine mounted share path. Pass --path explicitly.") + try: + root = validate_repair_root_under_volumes(root) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + summary = RepairSummary() + command_context.update_fields(repair_root=str(root)) + command_context.set_stage("scan_findings") + emit(f"Scanning {root}") + try: + findings = find_findings( + root, + recursive=args.recursive, + max_depth=args.max_depth, + include_hidden=args.include_hidden, + include_time_machine=args.include_time_machine, + include_directories=True, + include_root_directory=True, + fix_permissions=args.fix_permissions, + summary=summary, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + repairs = actionable_findings(findings) + candidates = [finding_to_candidate(finding) for finding in repairs] + command_context.update_fields( + scanned_paths=summary.scanned, + scanned_files=summary.scanned_files, + scanned_dirs=summary.scanned_dirs, + skipped_paths=summary.skipped, + unreadable_xattrs=summary.unreadable, + finding_count=len(findings), + repairable_count=len(candidates), + permission_repairable=summary.permission_repairable, + ) + + if not findings: + emit("No repairable files found.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + command_context.succeed() + return RepairRunResult(0, root, findings, candidates, summary) + + command_context.set_stage("report_findings") + _emit_lines(emit, render_diagnostic_lines(findings, verbose=args.verbose)) + if candidates: + _emit_lines(emit, render_candidate_lines(candidates, dry_run=args.dry_run)) + + if args.dry_run: + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) + + if not candidates: + emit("No known-safe repairs are available for the detected issues.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) + + command_context.set_stage("confirm_repair") + if not args.yes and not (confirm is not None and confirm(f"Repair {len(candidates)} paths with known-safe fixes?")): + emit("No changes made.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) + + command_context.set_stage("repair_findings") + failed_findings: list[RepairFinding] = [] + for finding, candidate in zip(repairs, candidates): + emit(f"Repairing: {candidate.path}") + if repair_candidate(candidate): + summary.repaired += 1 + if ACTION_CLEAR_ARCH_FLAG in candidate.actions: + emit(f"PASS xattr now readable: {candidate.path}") + if ACTION_FIX_PERMISSIONS in candidate.actions: + emit(f"PASS permissions repaired: {candidate.path}") + else: + summary.failed += 1 + failed_findings.append(finding) + if ACTION_CLEAR_ARCH_FLAG in candidate.actions: + emit(f"FAIL repair did not make xattr readable: {candidate.path}") + else: + emit(f"FAIL repair did not fix detected issue: {candidate.path}") + + unresolved = unresolved_findings_after_success(findings) + failed_findings + command_context.update_fields(repaired_count=summary.repaired, repair_failed_count=summary.failed) + _emit_lines(emit, render_summary_lines(summary, dry_run=False)) + if unresolved: + report = build_repair_report(findings, failed=unresolved) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) + command_context.succeed() + return RepairRunResult(0, root, findings, candidates, summary) diff --git a/src/timecapsulesmb/services/runtime.py b/src/timecapsulesmb/services/runtime.py new file mode 100644 index 00000000..4eedb638 --- /dev/null +++ b/src/timecapsulesmb/services/runtime.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import time +from typing import Callable + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, ConfigError, load_app_config, require_valid_app_config +from timecapsulesmb.core.net import extract_host, ipv4_literal, is_link_local_ipv4, resolve_host_ipv4s +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.compat import require_compatibility +from timecapsulesmb.device.probe import ( + ProbedDeviceState, + RemoteInterfaceProbeResult, + probe_connection_state, + probe_remote_interface_conn, +) +from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy +from timecapsulesmb.transport.local import tcp_open + + +@dataclass(frozen=True) +class ManagedTargetState: + connection: SshConnection + interface_probe: RemoteInterfaceProbeResult | None + probe_state: ProbedDeviceState | None + + +def load_env_config( + *, + env_path: Path | None = None, + defaults: dict[str, str] | None = None, + resolve_paths=resolve_app_paths, +) -> AppConfig: + resolved_path = resolve_paths(config_path=env_path).config_path + return load_app_config(resolved_path, defaults=defaults) + + +def load_optional_env_config( + *, + env_path: Path | None = None, + defaults: dict[str, str] | None = None, + resolve_paths=resolve_app_paths, +) -> AppConfig: + try: + resolved_path = resolve_paths(config_path=env_path).config_path + except Exception: + return AppConfig.missing(path=env_path or Path.cwd() / ".env") + if not resolved_path.exists(): + return AppConfig.missing(path=resolved_path) + try: + return load_app_config(resolved_path, defaults=defaults) + except OSError: + return AppConfig.missing(path=resolved_path) + + +def resolve_ssh_credentials( + config: AppConfig, + *, + allow_empty_password: bool = False, +) -> tuple[str, str]: + host = config.require("TC_HOST") + password = config.get("TC_PASSWORD") + if not password and not allow_empty_password: + import getpass + password = getpass.getpass("Device root password: ") + return host, password + + +def resolve_env_connection( + config: AppConfig, + *, + required_keys: tuple[str, ...] = (), + allow_empty_password: bool = False, +) -> SshConnection: + for key in required_keys: + config.require(key) + host, password = resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) + return SshConnection(host=host, password=password, ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + + +def inspect_managed_connection( + connection: SshConnection, + iface: str, + *, + include_probe: bool = False, +) -> ManagedTargetState: + interface_probe = probe_remote_interface_conn(connection, iface) + probe_state = probe_connection_state(connection) if include_probe else None + return ManagedTargetState(connection=connection, interface_probe=interface_probe, probe_state=probe_state) + + +def ssh_target_link_local_resolution_error( + target: str, + ssh_opts: str, + *, + field_name: str = "Device SSH target", +) -> str | None: + if ssh_opts_use_proxy(ssh_opts): + return None + host = extract_host(target).strip() + if not host or ipv4_literal(host) is not None: + return None + link_local_ips = tuple(ip for ip in resolve_host_ipv4s(host) if is_link_local_ipv4(ip)) + if not link_local_ips: + return None + noun = "address" if len(link_local_ips) == 1 else "addresses" + return ( + f"{field_name} host {host} resolves to 169.254.x.x link-local IPv4 {noun} " + f"{', '.join(link_local_ips)}. Use the device's LAN IP or a hostname that resolves " + "to its LAN IP; 169.254.x.x is only suitable for temporary SSH recovery." + ) + + +def resolve_validated_managed_target( + config: AppConfig, + *, + command_name: str, + profile: str, + include_probe: bool = False, +) -> ManagedTargetState: + require_valid_app_config(config, profile=profile, command_name=command_name) + resolution_error = ssh_target_link_local_resolution_error( + config.require("TC_HOST"), + config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), + field_name="TC_HOST", + ) + if resolution_error is not None: + raise ConfigError(resolution_error) + connection = resolve_env_connection(config) + if profile == "flash": + return ManagedTargetState(connection=connection, interface_probe=None, probe_state=None) + probe_state = probe_connection_state(connection) if include_probe else None + return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) + + +def require_connection_compatibility(connection: SshConnection): + state = probe_connection_state(connection) + return require_compatibility( + state.compatibility, + fallback_error=state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + + +def wait_for_tcp_port_state( + host: str, + port: int, + *, + expected_state: bool, + timeout_seconds: int = 120, + interval_seconds: int = 5, + log: Callable[[str], None] | None = None, + service_name: str | None = None, +) -> bool: + label = service_name or f"TCP port {port}" + expected_state_string = "open" if expected_state else "closed" + if log is not None: + log(f"Waiting for {label} to be {expected_state_string}...") + deadline = time.time() + timeout_seconds + while True: + is_open = tcp_open(host, port) + if is_open == expected_state: + if log is not None: + log(f"{label} is {expected_state_string}.") + return True + if time.time() >= deadline: + break + time.sleep(interval_seconds) + if log is not None: + log(f"{label} did not become {expected_state_string} within {timeout_seconds}s.") + return False diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 4721f083..37aab352 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -20,13 +20,13 @@ from timecapsulesmb.app.events import AppEvent, EventSink from timecapsulesmb import repair_xattrs as repair_xattrs_domain -from timecapsulesmb.app import contracts, helper, operations, service +from timecapsulesmb.app import contracts, helper, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, ConfigError, parse_env_file from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState -from timecapsulesmb.device.storage import MaStVolume +from timecapsulesmb.device.storage import MaStVolume, build_dry_run_payload_home from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError from timecapsulesmb.services.app import jsonable @@ -247,6 +247,19 @@ def test_request_id_propagates_to_every_event(self) -> None: self.assertEqual({event["request_id"] for event in collector.events}, {"req-123"}) self.assert_single_terminal_event(collector, "result") + def test_capabilities_returns_helper_contract_details(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "capabilities", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["api_schema_version"], 1) + self.assertIn("deploy", payload["operations"]) + self.assertIn("capabilities", payload["operations"]) + self.assertIn("helper_version", payload) + self.assertIn("artifact_manifest_sha256", payload) + def test_missing_params_defaults_to_empty_object(self) -> None: collector = CollectingSink() @@ -339,7 +352,7 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: ], ) - with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot): + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot): rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) self.assertEqual(rc, 0) @@ -354,7 +367,7 @@ def test_discover_rejects_invalid_timeout_values(self) -> None: for timeout in ("bad", "nan", -1, True): with self.subTest(timeout=timeout): collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.discover_snapshot") as discover: + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot") as discover: rc = service.run_api_request( {"operation": "discover", "params": {"timeout": timeout}}, collector.sink, @@ -370,7 +383,7 @@ def test_discover_accepts_numeric_timeout_string(self) -> None: collector = CollectingSink() snapshot = BonjourDiscoverySnapshot(instances=[], resolved=[]) - with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot) as discover: + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot) as discover: rc = service.run_api_request( {"operation": "discover", "params": {"timeout": "0.25"}}, collector.sink, @@ -379,11 +392,11 @@ def test_discover_accepts_numeric_timeout_string(self) -> None: self.assertEqual(rc, 0) discover.assert_called_once_with(timeout=0.25) - def test_configure_writes_env_without_leaking_password_to_events(self) -> None: + def test_configure_writes_env_without_persisting_or_leaking_password_by_default(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -398,11 +411,36 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: self.assertEqual(rc, 0) self.assertIn("TC_HOST=root@10.0.0.2", config_path.read_text()) - self.assertIn("TC_PASSWORD=goodpw", config_path.read_text()) + self.assertNotIn("TC_PASSWORD=goodpw", config_path.read_text()) + self.assertEqual(parse_env_file(config_path)["TC_PASSWORD"], "") self.assertIn("TC_DEBUG_LOGGING=false", config_path.read_text()) serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) + def test_configure_can_persist_password_for_env_compatibility_when_requested(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "persist_password": True, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_PASSWORD"], "goodpw") + self.assertNotIn("goodpw", json.dumps(collector.events)) + def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: @@ -415,7 +453,7 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s "TC_SAMBA_USER=old-admin\n" "TC_PAYLOAD_DIR_NAME=old-payload\n" ) - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -432,7 +470,7 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s self.assertEqual(rc, 0) self.assertEqual(values["TC_HOST"], "root@10.0.0.2") - self.assertEqual(values["TC_PASSWORD"], "newpw") + self.assertEqual(values["TC_PASSWORD"], "") self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") self.assertEqual(values["TC_DEBUG_LOGGING"], "true") self.assertNotIn("TC_SAMBA_USER", values) @@ -442,7 +480,7 @@ def test_configure_debug_logging_param_writes_true(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -465,8 +503,8 @@ def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): - with mock.patch("timecapsulesmb.app.operations.enable_ssh", side_effect=ACPAuthError("bad password")): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh", side_effect=ACPAuthError("bad password")): rc = service.run_api_request( { "operation": "configure", @@ -493,7 +531,7 @@ def test_configure_reports_unsupported_device(self) -> None: ) with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unsupported_state): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unsupported_state): rc = service.run_api_request( { "operation": "configure", @@ -514,8 +552,8 @@ def test_configure_rejects_boolean_ssh_wait_timeout(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): - with mock.patch("timecapsulesmb.app.operations.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh") as enable_ssh: rc = service.run_api_request( { "operation": "configure", @@ -544,10 +582,10 @@ def fake_run_doctor_checks(*_args, **kwargs): kwargs["on_result"](CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})) return [CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})], False - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", side_effect=fake_run_doctor_checks): rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) self.assertEqual(rc, 0) @@ -560,10 +598,10 @@ def test_doctor_passes_bonjour_timeout_to_checks(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", return_value=([], False)) as checks: + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", return_value=([], False)) as checks: rc = service.run_api_request( {"operation": "doctor", "params": {"bonjour_timeout": "2.75"}}, collector.sink, @@ -580,10 +618,10 @@ def fake_run_doctor_checks(*_args, **kwargs): kwargs["on_result"](CheckResult("FAIL", "SMB is not reachable", {"password": "pw"})) return [CheckResult("FAIL", "SMB is not reachable", {"password": "pw"})], True - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", side_effect=fake_run_doctor_checks): rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) self.assertEqual(rc, 1) @@ -603,12 +641,12 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, collector.sink, @@ -632,12 +670,12 @@ def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions") as remote_actions: rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, collector.sink, @@ -657,13 +695,13 @@ def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(s "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn") as read_mast: - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions") as remote_actions: rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, collector.sink, @@ -676,22 +714,84 @@ def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(s def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: - rc = service.run_api_request( - {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, - collector.sink, - ) + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, + collector.sink, + ) self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], "confirmation_required") - load_config.assert_not_called() + self.assertEqual(error["details"]["action_title"], "Deploy") + self.assertIn("confirmation_id", error["details"]) + read_mast.assert_not_called() + + def test_deploy_accepts_backend_confirmation_id_before_remote_writes(self) -> None: + first = CollectingSink() + second = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + base_params = {"dry_run": False, "no_reboot": True, "mount_wait": 30} + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + rc = service.run_api_request( + {"operation": "deploy", "params": dict(base_params)}, + first.sink, + ) + + self.assertEqual(rc, 1) + confirmation_id = first.events_of_type("error")[0]["details"]["confirmation_id"] + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + confirmed = dict(base_params) + confirmed["confirmation_id"] = confirmation_id + rc = service.run_api_request( + {"operation": "deploy", "params": confirmed}, + second.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + self.assertEqual(second.events_of_type("error"), []) def test_deploy_rejects_boolean_mount_wait_before_remote_connection(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config") as load_config: rc = service.run_api_request( { "operation": "deploy", @@ -718,20 +818,20 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) - - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): - with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): - with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): - with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload") as upload: - with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): - with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: rc = service.run_api_request( { "operation": "deploy", @@ -758,21 +858,21 @@ def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) - - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): - with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): - with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): - with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): - with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot") as reboot: - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: rc = service.run_api_request( { @@ -805,21 +905,21 @@ def test_deploy_no_wait_reports_reboot_request_failure(self) -> None: "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) - - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): - with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): - with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): - with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): - with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot: - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: rc = service.run_api_request( { @@ -853,12 +953,12 @@ def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): rc = service.run_api_request( { "operation": "deploy", @@ -887,9 +987,9 @@ def test_activate_requires_explicit_confirmation(self) -> None: ), ) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) self.assertEqual(rc, 1) @@ -901,10 +1001,10 @@ def test_activate_accepts_yes_alias_for_confirmation(self) -> None: connection = SshConnection("root@10.0.0.2", "pw", "-o foo") target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.maintenance.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: rc = service.run_api_request( {"operation": "activate", "params": {"yes": True}}, collector.sink, @@ -921,37 +1021,42 @@ def test_uninstall_requires_confirmation_before_remote_removal(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: - with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection") as resolve_connection: + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload") as uninstall: rc = service.run_api_request({"operation": "uninstall", "params": {}}, collector.sink) self.assertEqual(rc, 1) self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") - resolve_connection.assert_not_called() + resolve_connection.assert_called_once() uninstall.assert_not_called() def test_uninstall_requires_reboot_confirmation_before_remote_connection(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: - rc = service.run_api_request( - {"operation": "uninstall", "params": {"confirm_uninstall": True}}, - collector.sink, - ) + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"confirm_uninstall": True}}, + collector.sink, + ) self.assertEqual(rc, 1) self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") - load_config.assert_not_called() + read_mast.assert_not_called() def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) connection = SshConnection("root@10.0.0.2", "pw", "-o foo") - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload") as uninstall: rc = service.run_api_request( {"operation": "uninstall", "params": {"dry_run": True}}, collector.sink, @@ -970,13 +1075,13 @@ def test_uninstall_no_wait_uses_mount_wait_and_skips_post_reboot_verification(se connection = SshConnection("root@10.0.0.2", "pw", "-o foo") mounted = [SimpleNamespace(volume_root="/Volumes/dk2")] - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): - with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: - with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload"): with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.maintenance.wait_for_ssh_state_conn") as wait: with mock.patch("timecapsulesmb.app.ops.maintenance.verify_post_uninstall") as verify: rc = service.run_api_request( { @@ -1005,8 +1110,8 @@ def test_fsck_requires_confirmation_before_remote_connection(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection") as resolve_connection: rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) self.assertEqual(rc, 1) @@ -1017,7 +1122,7 @@ def test_fsck_rejects_non_integer_mount_wait_before_remote_connection(self) -> N for value in (12.5, True): with self.subTest(value=value): collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config") as load_config: rc = service.run_api_request( { "operation": "fsck", @@ -1038,11 +1143,11 @@ def test_fsck_list_volumes_returns_targets_without_confirmation_or_remote_fsck(s connection = SshConnection("root@10.0.0.2", "pw", "-o foo") mounted = [MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "uuid", True, "hfs")] - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): - with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: - with mock.patch("timecapsulesmb.app.operations.run_ssh") as run_ssh: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.ops.maintenance.run_ssh") as run_ssh: rc = service.run_api_request( { "operation": "fsck", @@ -1064,11 +1169,11 @@ def test_fsck_dry_run_returns_plan_without_remote_fsck(self) -> None: connection = SshConnection("root@10.0.0.2", "pw", "-o foo") mounted = [MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "uuid", True, "hfs")] - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): - with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted): - with mock.patch("timecapsulesmb.app.operations.run_ssh") as run_ssh: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_ssh") as run_ssh: rc = service.run_api_request( { "operation": "fsck", @@ -1095,9 +1200,9 @@ def test_repair_xattrs_uses_structured_runner(self) -> None: report="detected issues", ) - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", return_value=repair_result) as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1132,9 +1237,9 @@ def fake_runner(*_args, **_kwargs): print("stderr detail", file=sys.stderr) return repair_result - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", side_effect=fake_runner): + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", side_effect=fake_runner): rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1160,9 +1265,9 @@ def test_repair_xattrs_rejects_invalid_path_before_runner(self) -> None: collector = CollectingSink() params = {"dry_run": True} params.update(extra_params) - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config") as load_config: - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1183,9 +1288,9 @@ def test_repair_xattrs_rejects_invalid_max_depth_before_runner(self) -> None: for max_depth in ("bad", -1, True): with self.subTest(max_depth=max_depth): collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1216,9 +1321,9 @@ def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: report=None, ) - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", return_value=repair_result) as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1238,7 +1343,7 @@ def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: rc = service.run_api_request( { "operation": "repair-xattrs", From 8dfb061abb6921a7165989a517e2cb33723c38cb Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 15:33:54 -0700 Subject: [PATCH 13/13] Fix app API typing and repair confirmation ordering --- src/timecapsulesmb/app/ops/deploy.py | 7 +++-- src/timecapsulesmb/app/ops/maintenance.py | 32 +++++++++++-------- src/timecapsulesmb/services/runtime.py | 4 +-- tests/test_app_api.py | 38 ++++++++++++++++++----- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index b2428e0a..4b734f03 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -43,6 +43,7 @@ verify_managed_runtime, ) from timecapsulesmb.device.compat import ( + DeviceCompatibility, is_netbsd4_payload_family, payload_family_description, render_compatibility_message, @@ -74,14 +75,14 @@ payload_verification_error, render_flash_runtime_config, ) -from timecapsulesmb.services.runtime import load_env_config, resolve_validated_managed_target +from timecapsulesmb.services.runtime import ManagedTargetState, load_env_config, resolve_validated_managed_target from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 -def require_supported_payload(target, *, allow_unsupported: bool) -> object: +def require_supported_payload(target: ManagedTargetState, *, allow_unsupported: bool) -> DeviceCompatibility: probe_state = target.probe_state if probe_state is None: raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") @@ -103,7 +104,7 @@ def load_config_and_target( *, profile: str, include_probe: bool, -) -> tuple[AppConfig, object]: +) -> tuple[AppConfig, ManagedTargetState]: sink.stage(operation, "load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_managed_target") diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index a94d0f72..d0e638d1 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -344,15 +344,15 @@ def observe_reboot_cycle( def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" - dry_run = bool_param(params, "dry_run") - sink.stage(operation, "platform_check") - if sys.platform != "darwin": - raise AppOperationError( - "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", - code="validation_failed", - ) sink.stage(operation, "validate_params") + dry_run = bool_param(params, "dry_run") path = required_path_param(params, "path") + recursive = bool_param(params, "recursive", True) + max_depth = optional_int_param(params, "max_depth") + include_hidden = bool_param(params, "include_hidden") + include_time_machine = bool_param(params, "include_time_machine") + fix_permissions = bool_param(params, "fix_permissions") + verbose = bool_param(params, "verbose") if not dry_run: require_confirmation( params, @@ -368,17 +368,23 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera ), legacy_names=("confirm_repair",), ) + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) config = load_optional_env_config(env_path=config_path(params)) args = argparse.Namespace( path=path, dry_run=dry_run, yes=not dry_run, - recursive=bool_param(params, "recursive", True), - max_depth=optional_int_param(params, "max_depth"), - include_hidden=bool_param(params, "include_hidden"), - include_time_machine=bool_param(params, "include_time_machine"), - fix_permissions=bool_param(params, "fix_permissions"), - verbose=bool_param(params, "verbose"), + recursive=recursive, + max_depth=max_depth, + include_hidden=include_hidden, + include_time_machine=include_time_machine, + fix_permissions=fix_permissions, + verbose=verbose, ) context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) diff --git a/src/timecapsulesmb/services/runtime.py b/src/timecapsulesmb/services/runtime.py index 4eedb638..0e72a52e 100644 --- a/src/timecapsulesmb/services/runtime.py +++ b/src/timecapsulesmb/services/runtime.py @@ -8,7 +8,7 @@ from timecapsulesmb.core.config import DEFAULTS, AppConfig, ConfigError, load_app_config, require_valid_app_config from timecapsulesmb.core.net import extract_host, ipv4_literal, is_link_local_ipv4, resolve_host_ipv4s from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.device.compat import require_compatibility +from timecapsulesmb.device.compat import DeviceCompatibility, require_compatibility from timecapsulesmb.device.probe import ( ProbedDeviceState, RemoteInterfaceProbeResult, @@ -134,7 +134,7 @@ def resolve_validated_managed_target( return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) -def require_connection_compatibility(connection: SshConnection): +def require_connection_compatibility(connection: SshConnection) -> DeviceCompatibility: state = probe_connection_state(connection) return require_compatibility( state.compatibility, diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 4ee123bd..3ae79c96 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1358,14 +1358,15 @@ def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: - rc = service.run_api_request( - { - "operation": "repair-xattrs", - "params": {"path": "/Volumes/Data", "dry_run": False}, - }, - collector.sink, - ) + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "linux"): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False}, + }, + collector.sink, + ) self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") @@ -1373,6 +1374,27 @@ def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: self.assertEqual(error["recovery"]["title"], "Repair confirmation required") runner.assert_not_called() + def test_repair_xattrs_checks_platform_after_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "linux"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False, "confirm_repair": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "repair-xattrs requires macOS") + load_config.assert_not_called() + runner.assert_not_called() + def test_helper_reads_request_and_writes_ndjson(self) -> None: output = io.StringIO() fake_stdin = io.StringIO('{"operation":"paths","params":{}}')