diff --git a/.gitignore b/.gitignore index a59cc14..23fb2f0 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 0000000..b29a750 --- /dev/null +++ b/macos/TimeCapsuleSMB/Package.swift @@ -0,0 +1,40 @@ +// 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", + defaultLocalization: "en", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) + ], + targets: [ + .target( + name: "TimeCapsuleSMBApp", + path: "Sources/TimeCapsuleSMBApp", + resources: [.process("Resources")] + ), + .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 new file mode 100644 index 0000000..b11d6ed --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -0,0 +1,128 @@ +import Foundation + +@MainActor +final class BackendClient: ObservableObject { + @Published var helperPath: String + @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(), + helperPath: String = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" + ) { + self.runner = runner + self.helperPath = helperPath + } + + 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( + 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 + await updateTarget.appendEvent(event) + } + await updateTarget.finishRun(exitCode: result.exitCode) + } + } + + 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) + } + + fileprivate func finishRun(exitCode: Int32) { + 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 + + 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/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift new file mode 100644 index 0000000..060871c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -0,0 +1,388 @@ +import SwiftUI + +public 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 + @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 + + public init() {} + + public 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(L10n.string("toolbar.clear"), systemImage: "trash") + } + .disabled(backend.isRunning) + Button { + backend.cancel() + } label: { + Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") + } + .disabled(!backend.canCancel) + } + } + } + .frame(minWidth: 980, minHeight: 680) + .alert( + backend.pendingConfirmation?.title ?? "", + isPresented: confirmationPresented, + presenting: backend.pendingConfirmation + ) { confirmation in + Button(confirmation.actionTitle, role: .destructive) { + backend.confirmPending() + } + Button(L10n.string("action.cancel"), role: .cancel) { + backend.pendingConfirmation = nil + } + } message: { confirmation in + Text(confirmation.message) + } + } + + private var confirmationPresented: Binding { + Binding( + get: { backend.pendingConfirmation != nil }, + set: { isPresented in + if !isPresented { + backend.pendingConfirmation = nil + } + } + ) + } + + @ViewBuilder + private var form: some View { + switch selection { + case .readiness: + 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") + } + } + case .connect: + 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( + L10n.string("button.discover"), + icon: "network", + operation: "discover", + params: OperationParams.discover(timeout: bonjourTimeoutValue ?? 6), + disabled: bonjourTimeoutValue == nil + ) + Button { + backend.run( + operation: "configure", + params: OperationParams.configure( + host: host, + password: password, + debugLogging: configureDebugLogging + ) + ) + } label: { + Label(L10n.string("button.configure"), systemImage: "lock.open") + } + .disabled(backend.isRunning || password.isEmpty) + } + } + case .deploy: + 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: OperationParams.deployPlan( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging, + mountWait: mountWaitValue ?? 30, + password: password + ) + ) + } else { + backend.run( + operation: "deploy", + params: OperationParams.deployRun( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging, + mountWait: mountWaitValue ?? 30, + password: password + ) + ) + } + } label: { + Label( + dryRun ? L10n.string("button.plan_deploy") : L10n.string("button.deploy"), + systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up" + ) + } + .disabled(backend.isRunning || mountWaitValue == nil) + } + case .doctor: + 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: bonjourTimeoutValue ?? 6, password: password), + disabled: bonjourTimeoutValue == nil + ) + } + case .maintenance: + 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 { + backend.run(operation: "activate", params: OperationParams.activateRun(password: password)) + } label: { + Label(L10n.string("button.activate"), systemImage: "power") + } + .disabled(backend.isRunning) + runButton( + L10n.string("button.uninstall_plan"), + icon: "xmark.bin", + operation: "uninstall", + params: OperationParams.uninstallPlan( + noReboot: noReboot, + noWait: noWait, + mountWait: mountWaitValue ?? 30, + password: password + ), + disabled: mountWaitValue == nil + ) + Button { + 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 || mountWaitValue == nil) + } + HStack { + runButton( + L10n.string("button.list_fsck_volumes"), + icon: "list.bullet.rectangle", + operation: "fsck", + params: OperationParams.fsckList(mountWait: mountWaitValue ?? 30, password: password), + disabled: mountWaitValue == nil + ) + runButton( + L10n.string("button.plan_fsck"), + icon: "doc.text.magnifyingglass", + operation: "fsck", + params: OperationParams.fsckPlan( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: mountWaitValue ?? 30, + password: password + ), + disabled: mountWaitValue == nil + ) + Button { + 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 || mountWaitValue == nil) + } + HStack { + Button { + backend.run( + operation: "repair-xattrs", + params: OperationParams.repairXattrsScan(path: repairPath) + ) + } label: { + Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") + } + .disabled(backend.isRunning || repairPath.isEmpty) + Button { + backend.run( + operation: "repair-xattrs", + params: OperationParams.repairXattrsRun(path: repairPath) + ) + } label: { + Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") + } + .disabled(backend.isRunning || repairPath.isEmpty) + } + } + case .advanced: + CommandPanel(title: L10n.string("screen.advanced")) { + Text(L10n.string("advanced.flash_cli_only")) + .foregroundStyle(.secondary) + Text(L10n.string("advanced.flash_help")) + .font(.system(.body, design: .monospaced)) + } + } + } + + private func runButton( + _ title: String, + icon: String, + operation: String, + params: [String: JSONValue] = [:], + disabled: Bool = false + ) -> some View { + Button { + backend.run(operation: operation, params: params) + } label: { + Label(title, systemImage: icon) + } + .disabled(backend.isRunning || disabled) + } + + private var mountWaitValue: Double? { + nonNegativeIntegerDouble(mountWait) + } + + private var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + 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 + } + +} + +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 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") + } + } + + 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/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift new file mode 100644 index 0000000..9ee981d --- /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 0000000..0740dc9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -0,0 +1,223 @@ +import Darwin +import Foundation + +public struct HelperRunResult: Equatable, Sendable { + public let exitCode: Int32 + public let sawTerminalEvent: Bool + public let stderr: String +} + +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 + 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 @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let terminalTracker = TerminalEventTracker() + 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 { + await 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 + + do { + try process.run() + } catch { + await eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let stdoutTask = Task.detached { + await Self.readOutput(output.fileHandleForReading, onEvent: eventSink) + } + let stderrLimit = self.stderrLimit + let stderrTask = Task.detached { + Self.readCapped(error.fileHandleForReading, limit: 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() + await Self.terminate(process) + 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) + } + + await withTaskCancellationHandler { + await Self.waitForExit(process) + } onCancel: { + Task { + await Self.terminate(process) + } + } + let cancelled = Task.isCancelled + + await stdoutTask.value + let stderrText = await stderrTask.value + let sawTerminalEvent = await terminalTracker.sawTerminalEvent + if cancelled { + 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 { + 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: finalSawTerminalEvent, + stderr: stderrText + ) + } + + 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) + } + } + + private static func readCapped(_ handle: FileHandle, limit: Int) -> String { + var output = Data() + while let data = readChunk(from: handle) { + if output.count < limit { + output.append(data.prefix(limit - output.count)) + } + } + return String(decoding: output, as: UTF8.self) + } + + 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 { + 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 + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + } +} + +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 actor TerminalEventTracker { + private var seen = false + + var sawTerminalEvent: Bool { + seen + } + + func record(_ event: BackendEvent) { + guard event.type == "result" || event.type == "error" else { return } + seen = true + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift new file mode 100644 index 0000000..7ac2503 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift @@ -0,0 +1,11 @@ +import Foundation + +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 new file mode 100644 index 0000000..6037582 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -0,0 +1,209 @@ +import Foundation + +public enum JSONValue: Codable, Hashable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public 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)) + } + } + + public 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() + } + } + + public 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" + } + } + + 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, Sendable { + 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 let recovery: JSONValue? + public let risk: String? + public let cancellable: Bool? + public let description: String? + + 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, + recovery: JSONValue? = nil, + risk: String? = nil, + cancellable: Bool? = nil, + description: String? = 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 + self.recovery = recovery + self.risk = risk + self.cancellable = cancellable + self.description = description + } + + 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 + case status + case ok + case payload + case details + case debug + case recovery + case risk + case cancellable + case description + } + + public var summary: String { + switch type { + case "stage": + return stage.map { L10n.format("event.summary.stage", operation, $0) } ?? operation + case "check": + return L10n.format( + "event.summary.check", + status ?? L10n.string("event.summary.check.default_status"), + 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") + return L10n.format("event.summary.result", operation, result) + case "error": + return L10n.format( + "event.summary.error", + operation, + message ?? L10n.string("event.summary.error.default_message") + ) + default: + 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/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift new file mode 100644 index 0000000..75023d9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -0,0 +1,130 @@ +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)] + } + + 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, password: String) -> [String: JSONValue] { + withCredentials(["bonjour_timeout": .number(bonjourTimeout)], password: password) + } + + static func deployPlan( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + 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 deployRun( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double, + password: String + ) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(false), + "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, 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 uninstallRun(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(false), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ], password: password) + } + + static func activateRun(password: String) -> [String: JSONValue] { + withCredentials([:], password: password) + } + + 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, 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 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] { + [ + "path": .string(path), + "dry_run": .bool(true) + ] + } + + static func repairXattrsRun(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(false) + ] + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift new file mode 100644 index 0000000..50761c3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct OutputLineParser { + private var buffer = Data() + private let decoder = JSONDecoder() + + public init() { + } + + public mutating func append(_ data: Data) -> [BackendEvent] { + buffer.append(data) + return consumeCompleteLines() + } + + public mutating func finish() -> [BackendEvent] { + guard !buffer.isEmpty else { return [] } + let event = decode(buffer) + buffer.removeAll() + return event.map { [$0] } ?? [] + } + + private mutating func consumeCompleteLines() -> [BackendEvent] { + var events: [BackendEvent] = [] + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + if let event = decode(line) { + events.append(event) + } + } + return events + } + + 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/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift new file mode 100644 index 0000000..497530f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -0,0 +1,40 @@ +import Foundation + +struct PendingConfirmation: Identifiable { + let id = UUID() + let title: String + let message: String + let actionTitle: String + let operation: String + let params: [String: JSONValue] + + 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 + } + + 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 + } + + 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 new file mode 100644 index 0000000..b1fcd4f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -0,0 +1,82 @@ +"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"; +"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.capabilities" = "Capabilities"; +"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.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."; +"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?"; +"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"; +"field.host" = "Host"; +"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"; +"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/Sources/TimeCapsuleSMBExecutable/main.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift new file mode 100644 index 0000000..b3620ec --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -0,0 +1,11 @@ +import SwiftUI +import TimeCapsuleSMBApp + +@main +struct TimeCapsuleSMBExecutable: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift new file mode 100644 index 0000000..789e93a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -0,0 +1,173 @@ +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") + } + + 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 + ) 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/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift new file mode 100644 index 0000000..ba32dcf --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -0,0 +1,101 @@ +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"},"recovery":{"title":"No HFS volumes found","retryable":true,"actions":["retry"]}} + """.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")])) + 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 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 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"), + "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 0000000..0aaf26b --- /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 0000000..f12fd12 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -0,0 +1,192 @@ +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: [:]) { + await recorder.append($0) + } + + 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( + 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: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + 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")])) + } + + 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: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr.count, 64 * 1024) + XCTAssertEqual(events.last?.type, "result") + XCTAssertEqual(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: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr, "\u{FFFD}") + XCTAssertEqual(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) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: "/missing/tcapsule", operation: "paths", params: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(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: [:]) { + 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(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 { + 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 actor EventRecorder { + private var storage: [BackendEvent] = [] + + var events: [BackendEvent] { + storage + } + + func append(_ event: BackendEvent) { + storage.append(event) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift new file mode 100644 index 0000000..0c57055 --- /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 parser = OutputLineParser() + + 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") + 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 0000000..cae0980 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -0,0 +1,87 @@ +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") + 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, 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 testDeployRunParamsCarryOptionsWithoutFrontendConsentFlags() { + let params = OperationParams.deployRun( + noReboot: false, + noWait: true, + nbnsEnabled: true, + debugLogging: true, + mountWait: 45, + password: "" + ) + + 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 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.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 testMaintenanceRunParamsDoNotCarryFrontendConsentFlags() { + let fsck = OperationParams.fsckRun(volume: "Data", noReboot: true, noWait: true, mountWait: 18, password: "") + let repair = OperationParams.repairXattrsRun(path: "/Volumes/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["path"], .string("/Volumes/Data")) + XCTAssertEqual(repair["dry_run"], .bool(false)) + XCTAssertNil(repair["confirm_repair"]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift new file mode 100644 index 0000000..9e16dae --- /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/__init__.py b/src/timecapsulesmb/app/__init__.py new file mode 100644 index 0000000..bd0eaf1 --- /dev/null +++ b/src/timecapsulesmb/app/__init__.py @@ -0,0 +1,2 @@ +"""Structured app backend for GUI integrations.""" + diff --git a/src/timecapsulesmb/app/confirmations.py b/src/timecapsulesmb/app/confirmations.py new file mode 100644 index 0000000..26c9f22 --- /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 new file mode 100644 index 0000000..b1e322d --- /dev/null +++ b/src/timecapsulesmb/app/contracts.py @@ -0,0 +1,290 @@ +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 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, + "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, + 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, +) -> 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 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 + 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, + reboot_requested: bool | None = None, + waited: bool | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "rebooted": rebooted, + "verified": verified, + "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.", + }) + + +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, + "mountpoint": mountpoint, + "summary": "fsck completed.", + } + 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) + + +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) + 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": summary, + "summary_text": summary, + } + if stats is not None: + payload["stats"] = jsonable(stats) + return _with_schema(payload) + + +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 new file mode 100644 index 0000000..9abdb1e --- /dev/null +++ b/src/timecapsulesmb/app/events.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from timecapsulesmb.app.stage_policy import stage_policy + + +SENSITIVE_KEY_PARTS = ("password", "secret", "token", "key") +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) + request_id: str | None = None + schema_version: int = 1 + + def to_jsonable(self) -> dict[str, object]: + 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 + + 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], + *, + 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 + 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) + + 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 current_stage(self, operation: str) -> str | None: + return self._current_stage_by_operation.get(operation) + + def stage(self, operation: str, stage: str) -> None: + 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})) + + 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, + *, + 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: + fields["recovery"] = recovery + 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 0000000..15178b9 --- /dev/null +++ b/src/timecapsulesmb/app/helper.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import json +import sys +import uuid +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 + + +MAX_REQUEST_CHARS = 1024 * 1024 + + +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).with_request_id(str(uuid.uuid4())) + + 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: + message = f"invalid JSON request: {exc.msg}" + 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", + recovery=recovery_for("api", "invalid_request"), + ) + return 1 + return run_api_request(request, sink) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py new file mode 100644 index 0000000..8a961c4 --- /dev/null +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -0,0 +1,36 @@ +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 ( + 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, + "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 0000000..1d88b0d --- /dev/null +++ b/src/timecapsulesmb/app/ops/configure.py @@ -0,0 +1,135 @@ +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, +) +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.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 + + +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"])), + ), + debug_logging=bool_param( + params, + "debug_logging", + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), + ), + ) + + 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) + 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, + 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: + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + 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 0000000..4b734f0 --- /dev/null +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -0,0 +1,462 @@ +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.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.events import EventSink +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, + 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 ( + DeviceCompatibility, + 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, + 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, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) +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: 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") + 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, 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") + 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") + no_wait = bool_param(params, "no_wait") + 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), 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: + 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: + 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, + reboot_requested=False, + waited=False, + verified=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, + reboot_requested=False, + waited=False, + verified=False, + payload_family=payload_family, + )) + + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + raise_on_request_error=True, + ) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + reboot_requested=True, + waited=False, + verified=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, + reboot_requested=True, + waited=True, + verified=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: + request_reboot(operation, sink, connection, strategy=strategy) + + 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_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + raise_on_request_error: 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, + raise_on_request_error=raise_on_request_error, + ) + else: + request_ssh_reboot( + operation, + sink, + connection, + raise_on_request_error=raise_on_request_error, + ) + + +def request_ssh_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + raise_on_request_error: bool = False, +) -> None: + try: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + if raise_on_request_error: + raise AppOperationError(f"SSH reboot request timed out: {exc}", code="remote_error") from exc + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + if raise_on_request_error: + 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 new file mode 100644 index 0000000..7bc12d8 --- /dev/null +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -0,0 +1,43 @@ +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.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 = 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"): + 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"), + bonjour_timeout=bonjour_timeout, + 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 0000000..d0e638d --- /dev/null +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import argparse +import shlex +import sys +from contextlib import redirect_stderr, redirect_stdout + +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, +) +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, + request_reboot, + request_reboot_and_wait, + require_supported_payload, + verify_runtime, +) +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 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, + 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, + 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, + fsck_target_from_volume, + 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" + 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(activation_plan_to_jsonable(plan))) + 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) + 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") + no_wait = bool_param(params, "no_wait") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + sink.stage(operation, "load_config") + 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}"] + 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=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] + 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, + reboot_requested=False, + waited=False, + )) + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="acp_then_ssh", + raise_on_request_error=True, + ) + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=True, + waited=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, + 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") + 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: + 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 = 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") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + 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( + 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( + 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, + 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, + 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, + reboot_requested=True, + waited=True, + verified=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") + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + 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, + 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",), + ) + 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=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")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = repair_xattrs_service.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), + "stats": 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 0000000..7fc7d82 --- /dev/null +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +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 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, + 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 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") + 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 0000000..e32e724 --- /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/requests.py b/src/timecapsulesmb/app/requests.py new file mode 100644 index 0000000..ed84441 --- /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 new file mode 100644 index 0000000..92695ec --- /dev/null +++ b/src/timecapsulesmb/app/service.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import traceback +from collections.abc import Callable + +from timecapsulesmb.app.events import EventSink, redact +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 run_api_request(request: dict[str, object], sink: EventSink) -> int: + try: + api_request = parse_api_request(request) + except AppOperationError as exc: + sink.error( + "api", + str(exc), + code=exc.code, + recovery=recovery_for("api", "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( + 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 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( + 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", + 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", + recovery=recovery_for(operation, "remote_error", stage=sink.current_stage(operation)), + ) + return 1 + except (SystemExit, KeyboardInterrupt): + raise + except Exception as exc: + sink.error( + operation, + 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) + return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py new file mode 100644 index 0000000..3ea3c87 --- /dev/null +++ b/src/timecapsulesmb/app/stage_policy.py @@ -0,0 +1,109 @@ +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] = { + ("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."), + ("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/checks/doctor.py b/src/timecapsulesmb/checks/doctor.py index 5720404..30098fd 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 fd74f1f..bcf786c 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 577b4bb..132cfea 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 738e853..3521285 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 ea381c1..4de0ab5 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 @@ -297,6 +296,8 @@ 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) + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -326,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: @@ -357,9 +358,15 @@ 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) + 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 1e52a65..86dc322 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -7,24 +7,21 @@ 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, 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 @@ -35,9 +32,6 @@ BINARY_MDNS_SOURCE, 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 +49,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,36 +72,17 @@ 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) - 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) @@ -212,7 +142,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 +154,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 +248,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 +266,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.") @@ -379,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, raise_on_request_error=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 09bb686..2208fc0 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -8,9 +8,10 @@ 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 from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.core.paths import resolve_app_paths @@ -250,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() @@ -261,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"): @@ -279,11 +282,12 @@ 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, ) 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/flash.py b/src/timecapsulesmb/cli/flash.py index bf2b843..df83605 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, raise_on_request_error=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 12dce9b..b089f66 100644 --- a/src/timecapsulesmb/cli/flows.py +++ b/src/timecapsulesmb/cli/flows.py @@ -83,9 +83,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, @@ -96,6 +94,17 @@ def request_reboot_and_wait( ) +def request_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + raise_on_request_error: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_acp_then_ssh(connection, command_context, raise_on_request_error=raise_on_request_error) + + def request_deploy_reboot_and_wait( connection: SshConnection, command_context: CommandContext, @@ -104,9 +113,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, @@ -117,23 +124,53 @@ def request_deploy_reboot_and_wait( ) +def request_deploy_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + raise_on_request_error: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_via_ssh_shutdown( + connection, + command_context, + raise_on_request_error=raise_on_request_error, + ) + + def request_ssh_reboot( connection: SshConnection, command_context: CommandContext, *, log: LogCallback = None, + raise_on_request_error: 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, + raise_on_request_error=raise_on_request_error, + ) -def _request_reboot_acp_then_ssh(connection: SshConnection, command_context: CommandContext) -> None: +def _request_reboot_acp_then_ssh( + connection: SshConnection, + command_context: CommandContext, + *, + raise_on_request_error: 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, + raise_on_request_error=raise_on_request_error, + ) def _request_reboot_via_acp(connection: SshConnection, command_context: CommandContext) -> bool: @@ -162,6 +199,7 @@ def _request_reboot_via_ssh_shutdown( command_context: CommandContext, *, log: LogCallback = None, + raise_on_request_error: bool = False, ) -> None: command_context.add_debug_fields(reboot_request_strategy="ssh_shutdown_then_reboot") _request_reboot_via_ssh( @@ -170,6 +208,7 @@ def _request_reboot_via_ssh_shutdown( log=log, request_reboot=remote_request_reboot, progress_message=SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, + raise_on_request_error=raise_on_request_error, ) @@ -180,6 +219,7 @@ def _request_reboot_via_ssh( log: LogCallback = None, request_reboot: Callable[[SshConnection], None] | None = None, progress_message: str = SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, + raise_on_request_error: bool = False, ) -> None: command_context.add_debug_fields(ssh_reboot_attempted=True) emit_progress(log, progress_message) @@ -193,6 +233,8 @@ def _request_reboot_via_ssh( ssh_reboot_timed_out=True, ssh_reboot_error=system_exit_message(exc), ) + if raise_on_request_error: + raise print("SSH reboot request timed out; checking whether the device is rebooting...") return except SshError as exc: @@ -200,6 +242,8 @@ def _request_reboot_via_ssh( ssh_reboot_succeeded=False, ssh_reboot_error=system_exit_message(exc), ) + if raise_on_request_error: + 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 78cffad..524fcac 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -2,110 +2,42 @@ 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.executor import DETACHED_SHUTDOWN_REBOOT_COMMAND -from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS -from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.device.storage import MaStVolume +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, + select_fsck_target, +) 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" - - -@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: - 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 ---'", - DETACHED_SHUTDOWN_REBOOT_COMMAND, - ] - ) - 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) + 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) @@ -124,15 +56,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 @@ -140,6 +79,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) @@ -170,6 +114,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/main.py b/src/timecapsulesmb/cli/main.py index 0dc61e2..0062d5a 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/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index bb00930..403a341 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -2,12 +2,17 @@ import argparse import sys +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass from pathlib import Path -from typing import Optional +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, @@ -41,18 +46,38 @@ xattr_status, xattrs_readable, ) +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.maintenance import LineLogCapture, RepairExecutionContext 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 +87,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 +173,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 +202,108 @@ 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 _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), + "stats": 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: @@ -216,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 be49d26..de1fab3 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,9 @@ 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.services import runtime as service_runtime from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -54,6 +58,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) @@ -128,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( @@ -137,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( @@ -154,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( @@ -168,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( @@ -180,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( @@ -191,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( @@ -214,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/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index f6ba21b..dd95086 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -1,11 +1,12 @@ from __future__ import annotations import argparse +from enum import Enum from typing import Optional 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 @@ -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, *, @@ -67,32 +84,62 @@ 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: - command_context.update_fields(set_ssh_action="enable_ssh") + + 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 + 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 + + 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") @@ -105,77 +152,99 @@ 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 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") - should_disable = confirm( - "SSH already enabled. Disable?", - default=False, - eof_default=False, - interrupt_default=False, - ) - if not should_disable: + if not args.yes: + confirmed = confirm( + "SSH already enabled. Disable?", + default=False, + eof_default=False, + interrupt_default=False, + ) + 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 - - 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 - - print("SSH is configured. You can connect as 'root' using the AirPort admin password.") + 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 - return 1 diff --git a/src/timecapsulesmb/cli/uninstall.py b/src/timecapsulesmb/cli/uninstall.py index 771d5cc..01ca476 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -4,27 +4,24 @@ 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 +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) + 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") @@ -59,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] @@ -105,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, raise_on_request_error=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 1d23bda..ec4941f 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,8 +84,22 @@ 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({ + # 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 @@ -496,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, } @@ -511,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", @@ -557,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, @@ -640,9 +662,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/src/timecapsulesmb/deploy/dry_run.py b/src/timecapsulesmb/deploy/dry_run.py index 5689c38..e91c4bf 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/__init__.py b/src/timecapsulesmb/services/__init__.py new file mode 100644 index 0000000..da30ee9 --- /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 0000000..6ed5b10 --- /dev/null +++ b/src/timecapsulesmb/services/app.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum +import math +from pathlib import Path + + +class AppOperationError(RuntimeError): + def __init__( + self, + message: str, + *, + 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) +class OperationResult: + ok: bool + payload: object | None = None + + +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): + 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): + 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: + 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) + 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) + 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 _parse_optional_int_value(value: object, name: str) -> int: + 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) + 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): + 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) + + +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 + + +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/src/timecapsulesmb/services/config_store.py b/src/timecapsulesmb/services/config_store.py new file mode 100644 index 0000000..8aaaebc --- /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/configure.py b/src/timecapsulesmb/services/configure.py new file mode 100644 index 0000000..db65261 --- /dev/null +++ b/src/timecapsulesmb/services/configure.py @@ -0,0 +1,39 @@ +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, + debug_logging: 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_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/credentials.py b/src/timecapsulesmb/services/credentials.py new file mode 100644 index 0000000..d1c214c --- /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/deploy.py b/src/timecapsulesmb/services/deploy.py new file mode 100644 index 0000000..129b3ec --- /dev/null +++ b/src/timecapsulesmb/services/deploy.py @@ -0,0 +1,63 @@ +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"]) + 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), + ("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 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/src/timecapsulesmb/services/doctor.py b/src/timecapsulesmb/services/doctor.py new file mode 100644 index 0000000..992db56 --- /dev/null +++ b/src/timecapsulesmb/services/doctor.py @@ -0,0 +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 new file mode 100644 index 0000000..93dcb5e --- /dev/null +++ b/src/timecapsulesmb/services/maintenance.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from dataclasses import dataclass +import shlex +from typing import Callable + +from timecapsulesmb.deploy.executor import DETACHED_SHUTDOWN_REBOOT_COMMAND +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." +) +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) + + +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 ---'", + DETACHED_SHUTDOWN_REBOOT_COMMAND, + ]) + 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/src/timecapsulesmb/services/repair_xattrs.py b/src/timecapsulesmb/services/repair_xattrs.py new file mode 100644 index 0000000..6a9b336 --- /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 0000000..0e72a52 --- /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 DeviceCompatibility, 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) -> 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.", + ) + + +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 new file mode 100644 index 0000000..3ae79c9 --- /dev/null +++ b/tests/test_app_api.py @@ -0,0 +1,1464 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +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 import repair_xattrs as repair_xattrs_domain +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 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, build_dry_run_payload_home +from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance +from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.services.app import AppOperationError, jsonable +from timecapsulesmb.transport.errors import SshCommandTimeout, 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]] = [] + 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 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( + 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(), + ) + + +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_sensitive_fields(self) -> None: + event = AppEvent("result", "configure", { + "ok": True, + "payload": { + "password": "secret", + "nested": { + "TC_PASSWORD": "secret", + "api_key": "secret", + "ssh_private_key": "secret", + }, + }, + }) + + data = event.to_jsonable() + + 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() + + collector.sink.result("paths", ok=True, payload=[]) + + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"], []) + 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() + + 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, + "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"], "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() + + 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_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() + + 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") + self.assertEqual(error["recovery"]["title"], "Invalid request") + self.assertTrue(error["recovery"]["retryable"]) + + 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) + 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() + + 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) + self.assertIn("recovery", error) + + 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( + 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.ops.readiness.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"]) + 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.ops.readiness.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.ops.readiness.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_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.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", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertIn("TC_HOST=root@10.0.0.2", 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: + 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_DEBUG_LOGGING=true\n" + "TC_SAMBA_USER=old-admin\n" + "TC_PAYLOAD_DIR_NAME=old-payload\n" + ) + 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": "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"], "") + 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.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", + "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: + config_path = Path(tmp) / ".env" + 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", + "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.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: + 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.ops.configure.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_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.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", + "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"}) + + 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.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) + 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_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.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, + ) + + 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"}) + + 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.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) + 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") + 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.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, + ) + + 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) + 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() + 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.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, + ) + + 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.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, + ) + + 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() + 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.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") + 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.ops.deploy.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") + 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) + + 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", + "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_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 = 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_reboot") as reboot: + 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( + { + "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 = 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_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot: + 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( + { + "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_request_ssh_reboot_reports_timeout_when_request_error_is_required(self) -> None: + from timecapsulesmb.app.ops import deploy as deploy_ops + + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + with mock.patch( + "timecapsulesmb.app.ops.deploy.remote_request_reboot", + side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), + ): + with self.assertRaises(AppOperationError) as raised: + deploy_ops.request_ssh_reboot("deploy", collector.sink, connection, raise_on_request_error=True) + + self.assertEqual(raised.exception.code, "remote_error") + self.assertIn("Timed out waiting for ssh command to finish: reboot", str(raised.exception)) + + 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.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", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + 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() + 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.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) + 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.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, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + 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: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + 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_called_once() + uninstall.assert_not_called() + + def test_uninstall_requires_reboot_confirmation_before_remote_connection(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.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") + 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.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, + ) + + 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_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.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.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( + { + "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"}) + + 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) + 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.ops.maintenance.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.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", + "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.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", + "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) + 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.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", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + runner.assert_called_once() + 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() + 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.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", + "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_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.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", + "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): + collector = CollectingSink() + 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", + "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.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", + "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() + + 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") + self.assertEqual(error["code"], "confirmation_required") + 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":{}}') + 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") + 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_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"]')): + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 65e3a6f..a20fbc1 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 @@ -1235,6 +1236,40 @@ 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"]["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: + 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): @@ -1461,6 +1496,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"]) @@ -1539,6 +1575,40 @@ 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_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"}, + 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": @@ -1570,6 +1640,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([ @@ -4077,6 +4148,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"} @@ -4095,6 +4190,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"} @@ -4155,6 +4308,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"} @@ -4249,6 +4420,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") @@ -4261,6 +4447,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") @@ -4427,6 +4623,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) @@ -4453,6 +4650,75 @@ 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_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_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_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): @@ -4461,6 +4727,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( @@ -6309,8 +6589,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() @@ -6973,6 +7295,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 = { @@ -7121,6 +7463,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() @@ -7367,6 +7752,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 = { diff --git a/tests/test_cli_flows.py b/tests/test_cli_flows.py index f86eb63..dbaf4aa 100644 --- a/tests/test_cli_flows.py +++ b/tests/test_cli_flows.py @@ -288,6 +288,20 @@ def test_request_ssh_reboot_records_timeout_without_raising(self) -> None: self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "Timed out waiting for ssh command to finish: reboot") self.assertIn("SSH reboot request timed out; checking whether the device is rebooting...", output.getvalue()) + def test_request_ssh_reboot_raises_timeout_when_request_error_is_required(self) -> None: + command_context = FakeCommandContext() + with mock.patch( + "timecapsulesmb.cli.flows.remote_request_reboot", + side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), + ): + with self.assertRaises(SshCommandTimeout): + request_ssh_reboot(self.make_connection(), command_context, raise_on_request_error=True) + + self.assertEqual(command_context.debug_fields["reboot_request_strategy"], "ssh") + self.assertEqual(command_context.debug_fields["ssh_reboot_succeeded"], False) + self.assertEqual(command_context.debug_fields["ssh_reboot_timed_out"], True) + self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "Timed out waiting for ssh command to finish: reboot") + def test_request_ssh_reboot_records_ssh_error_without_raising(self) -> None: command_context = FakeCommandContext() output = io.StringIO() @@ -300,6 +314,16 @@ def test_request_ssh_reboot_records_ssh_error_without_raising(self) -> None: self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "ssh failed") self.assertIn("SSH reboot request failed; checking whether the device is rebooting anyway...", output.getvalue()) + def test_request_ssh_reboot_raises_ssh_error_when_request_error_is_required(self) -> None: + command_context = FakeCommandContext() + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh failed")): + with self.assertRaises(SshError): + request_ssh_reboot(self.make_connection(), command_context, raise_on_request_error=True) + + self.assertEqual(command_context.debug_fields["reboot_request_strategy"], "ssh") + self.assertEqual(command_context.debug_fields["ssh_reboot_succeeded"], False) + self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "ssh failed") + def test_request_reboot_and_wait_fails_when_device_never_goes_down_after_acp_request(self) -> None: command_context = FakeCommandContext() output = io.StringIO() diff --git a/tests/test_config.py b/tests/test_config.py index 1a20280..f310863 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, @@ -146,8 +147,44 @@ 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: + 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) @@ -473,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) @@ -485,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 2b221d9..cc6198a 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)