From 0ff5d5c579fd6725e05a36f121f05813eceeedcc Mon Sep 17 00:00:00 2001 From: chippy <17513790+PaSSw0rds@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:28:35 +0800 Subject: [PATCH] Group forwarded ports as Containers and stop them with docker stop Ports forwarded by Docker Desktop, OrbStack or colima used to scatter across Apps and Dev Servers, named after the forwarder process. A forwarded port now lands in its own Containers section that names the container, locates its compose project or bind mounts, and closes it with docker stop instead of a signal. - Resolve host ports to containers through the Engine API over the daemon's unix socket (one ~10 ms call, no process spawn), falling back to docker ps and then to the runtime's name. The daemon chunks its responses even over HTTP/1.0, so the socket client decodes chunked framing; socket and CLI failures back off separately and a socket failure never disables the CLI fallback. - compose containers sit in one row named after their project with each container as its port row; a plain docker run without --name is listed by its image, since a generated name says nothing. - The port page reveals the compose directory or a mounted host directory in Finder and offers Stop Container, the one shell action: a review page (restart-policy note included) that runs docker stop through the CLI and verifies the port went free. Signalling the forwarder stays refused - it only breaks the mapping while the container keeps running. --- README.md | 2 +- README.zh-CN.md | 2 +- Sources/LeftOpenApp/AppMain.swift | 53 +++ Sources/LeftOpenApp/MenuPanel.swift | 130 +++++- Sources/LeftOpenCore/CloseService.swift | 8 + Sources/LeftOpenCore/ContainerResolver.swift | 408 ++++++++++++++++++ Sources/LeftOpenCore/Models.swift | 4 +- Sources/LeftOpenCore/PortCategory.swift | 29 +- Sources/LeftOpenCore/Scanner.swift | 23 +- .../LeftOpenCoreTests/LeftOpenCoreTests.swift | 180 ++++++++ 10 files changed, 826 insertions(+), 13 deletions(-) create mode 100644 Sources/LeftOpenCore/ContainerResolver.swift diff --git a/README.md b/README.md index bee5879..4d0f756 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Get `LeftOpen-release.zip` from [GitHub Releases](https://github.com/SonghaiFan/ - **Knows whose port it is.** Walks up from the process's working directory to `.git`, `package.json`, `pyproject.toml`, `Cargo.toml` or `go.mod`, or resolves the owning `.app`. Global npm packages, `python -m` modules and standalone services (Redis, Postgres, Ollama…) are named too, so you rarely see a bare `node` or `python`. - **Real icons, nothing bundled.** Uses the `.app` icon when there is one; otherwise the icon the project or package ships itself (Tauri/Electron app icon, the favicon declared in `index.html`, `public/` conventions); otherwise the favicon served by the local server; otherwise a symbol. -- **Grouped by who started them.** Dev Servers (from a project, terminal, editor or agent), Background Services (launchd: brew services, login items), Apps and System. The group is also how a port is closed for good, and each one says so: SIGTERM for dev servers, `brew services stop …` for services launchd would restart, quitting the app for apps. +- **Grouped by who started them.** Dev Servers (from a project, terminal, editor or agent), Background Services (launchd: brew services, login items), Containers (forwarded by Docker Desktop, OrbStack or colima; the Engine API resolves the container's name, compose project and mounted directories), Apps and System. The group is also how a port is closed for good, and each one says so: SIGTERM for dev servers, `brew services stop …` for services launchd would restart, one click `docker stop` for containers on their port page, quitting the app for apps. - **Closes gently first.** Sends `SIGTERM` and re-checks process identity. If a single-process close still leaves it listening after five seconds, that process is marked and the warning explains that closing it again within two minutes sends `SIGKILL`. The second click or swipe is the deliberate force-close action; existing safety protections and identity checks still apply. - **Local vs LAN.** Tells apart ports bound to `127.0.0.1` from ones on `0.0.0.0` or a LAN address. - **Nothing running in the background.** Native SwiftUI `MenuBarExtra`. No daemon, no Dock icon, no telemetry. The only request that leaves this Mac is a daily check of the latest GitHub release, which you can turn off in Settings. diff --git a/README.zh-CN.md b/README.zh-CN.md index e2974e3..a9140de 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -54,7 +54,7 @@ brew install --cask songhaifan/tap/leftopen - **知道端口是谁的。** 从进程的工作目录向上找 `.git`、`package.json`、`pyproject.toml`、`Cargo.toml`、`go.mod`,或者解析它所属的 `.app`。全局 npm 包、`python -m` 模块和 Redis / Postgres / Ollama 这类独立服务也按名字识别,很少再看到一个光秃秃的 `node` 或 `python`。 - **真实图标,不预存。** 有 `.app` 就用它的图标;否则用项目或包自己带的(Tauri / Electron app icon、`index.html` 声明的 favicon、`public/` 约定);否则向本机服务器抓 favicon;再没有才用符号。 -- **按来历分组。** 开发服务器(从项目、终端、编辑器或 agent 启动)、后台服务(由 launchd 管理:brew services、登录项)、App 和系统。分组即关闭方式,每组都写明:开发服务器发 SIGTERM,launchd 会重启的服务提示 `brew services stop …`,App 的端口需要退出 App。 +- **按来历分组。** 开发服务器(从项目、终端、编辑器或 agent 启动)、后台服务(由 launchd 管理:brew services、登录项)、容器(由 Docker Desktop、OrbStack、colima 转发,通过 Engine API 解析出容器名、compose 项目和挂载目录)、App 和系统。分组即关闭方式,每组都写明:开发服务器发 SIGTERM,launchd 会重启的服务提示 `brew services stop …`,容器可在详情页一键 `docker stop`,App 的端口需要退出 App。 - **先轻轻关闭。** 发送 `SIGTERM`,关闭前重新核对进程身份。单独关闭一个进程后等待五秒,如果仍在监听,该进程会显示失败提示;警告会说明两分钟内再次点击或右滑关闭将发送 `SIGKILL`。第二次关闭操作就是明确的强制关闭,原有安全保护和身份核验仍然生效。 - **本机还是局域网。** 区分绑在 `127.0.0.1` 的端口和绑在 `0.0.0.0` / 局域网地址上的端口。 - **没有后台常驻。** 原生 SwiftUI `MenuBarExtra`。无守护进程,无 Dock 图标,无遥测。唯一离开这台 Mac 的网络请求是每天向 GitHub 查询一次最新版本,可在设置中关闭。 diff --git a/Sources/LeftOpenApp/AppMain.swift b/Sources/LeftOpenApp/AppMain.swift index 2287654..8ad9a51 100644 --- a/Sources/LeftOpenApp/AppMain.swift +++ b/Sources/LeftOpenApp/AppMain.swift @@ -73,6 +73,7 @@ final class MenuModel: ObservableObject { @Published var isClosing = false @Published var pendingPlan: ClosePlan? @Published var pendingBatchPlans: [ClosePlan]? + @Published var pendingContainerStop: ContainerStopPlan? @Published private(set) var notice: Notice? @Published private(set) var forceCloseOffer: ForceCloseOffer? @Published var selectedActivityID: String? @@ -130,6 +131,7 @@ final class MenuModel: ObservableObject { MainActor.assumeIsolated { self?.notice = nil self?.forceCloseOffer = nil + self?.pendingContainerStop = nil Task { await self?.refresh() } } } @@ -140,6 +142,7 @@ final class MenuModel: ObservableObject { MainActor.assumeIsolated { self?.pendingPlan = nil self?.pendingBatchPlans = nil + self?.pendingContainerStop = nil self?.forceCloseOffer = nil } } @@ -243,6 +246,56 @@ final class MenuModel: ObservableObject { await execute(plan) } + /// The stop review is prepared like a close plan: the restart policy is fetched once, here, + /// instead of on every scan. + func previewContainerStop(_ activity: Activity) async { + guard !isPreparingClose && !isClosing, let container = activity.container else { return } + isPreparingClose = true + notice = nil + defer { isPreparingClose = false } + let policy = await Task.detached(priority: .utility) { + ContainerResolver.restartPolicy(of: container) + }.value + pendingContainerStop = ContainerStopPlan(activity: activity, container: container, + restartPolicy: policy) + } + + func confirmContainerStop() async { + guard let plan = pendingContainerStop, !isClosing else { return } + isClosing = true + defer { isClosing = false } + // The ports this container owned before stopping; the refreshed scan tells which remain. + let containerPorts = Set(snapshot.activities.filter { + $0.container?.name == plan.container.name + || (plan.container.id.isEmpty == false && $0.container?.id == plan.container.id) + }.map(\.listener.port)) + do { + _ = try await Task.detached(priority: .utility) { + try ContainerResolver.stop(plan.container) + }.value + pendingContainerStop = nil + selectedActivityID = nil + await refresh() + let stillForwarded = snapshot.activities.filter { containerPorts.contains($0.listener.port) } + let outcome: String + let kind: NoticeKind + if stillForwarded.isEmpty { + outcome = L("Container \(plan.container.name) stopped; port \(plan.activity.listener.port) is free.", + "容器 \(plan.container.name) 已停止,端口 \(plan.activity.listener.port) 已释放。") + kind = .success + } else { + let held = stillForwarded.map { String($0.listener.port) }.sorted().joined(separator: ", ") + outcome = L("Container \(plan.container.name) stopped, but \(held) still listen. Another container or the runtime may hold them.", + "容器 \(plan.container.name) 已停止,但 \(held) 仍在监听,可能由其他容器或运行时占用。") + kind = .warning + } + post(Notice(kind: kind, text: outcome)) + } catch { + let detail = error.localizedDescription + post(Notice(kind: .error, text: L("docker stop failed: \(detail)", "docker stop 失败:\(detail)"))) + } + } + /// Swipe-to-close: the swipe past the threshold is the confirmation, so the plan is prepared /// and executed without the review page. Identity checks in CloseService still apply. func closeNow(_ activity: Activity) async { diff --git a/Sources/LeftOpenApp/MenuPanel.swift b/Sources/LeftOpenApp/MenuPanel.swift index 0e40cc1..c1ebcd6 100644 --- a/Sources/LeftOpenApp/MenuPanel.swift +++ b/Sources/LeftOpenApp/MenuPanel.swift @@ -106,7 +106,12 @@ struct MenuPanel: View { var groups: [String: [Activity]] = [:] var orderedIDs: [String] = [] for activity in activities { + // One runtime forwarder process serves many containers. Rows follow the owner a human + // thinks in: the compose project, else the single container, so related ports of one + // project stay together and a port never sits under an unrelated name. + let owner = activity.container.map { $0.composeProject ?? $0.name } let id = "pid:\(activity.process.pid):\(activity.process.executablePath ?? activity.process.command)" + + (owner.map { ":container:\($0)" } ?? "") if groups[id] == nil { orderedIDs.append(id) } groups[id, default: []].append(activity) } @@ -126,7 +131,9 @@ struct MenuPanel: View { private var path: [Page] { var pages: [Page] = [.list] if model.selectedActivityID != nil { pages.append(.port) } - if model.pendingPlan != nil || model.pendingBatchPlans != nil { pages.append(.review) } + if model.pendingPlan != nil || model.pendingBatchPlans != nil || model.pendingContainerStop != nil { + pages.append(.review) + } return pages } @@ -143,6 +150,8 @@ struct MenuPanel: View { model.pendingBatchPlans = nil } else if model.pendingPlan != nil { model.pendingPlan = nil + } else if model.pendingContainerStop != nil { + model.pendingContainerStop = nil } else { model.selectedActivityID = nil } @@ -242,6 +251,8 @@ struct MenuPanel: View { batchReviewPage(plans) } else if let plan = model.pendingPlan { reviewPage(plan) + } else if let stop = model.pendingContainerStop { + containerStopReviewPage(stop) } } } @@ -527,7 +538,7 @@ struct MenuPanel: View { port: expanded ? nil : group.ports[0], extraPorts: expanded ? 0 : group.ports.count - 1, icon: group.primary, - title: group.primary.inference.label, + title: group.title, subtitle: group.subtitle, isLAN: group.scope == .lan, closeFailed: model.awaitsForceClose(group.primary), @@ -553,7 +564,7 @@ struct MenuPanel: View { PortRow( port: activity.listener.port, icon: activity, - title: activity.inference.label, + title: group.title, subtitle: group.subtitle, trailing: activity.process.compactUptime, isLAN: activity.scope == .lan, @@ -573,7 +584,8 @@ struct MenuPanel: View { return PortRow( port: activity.listener.port, icon: nil, - title: activity.listener.addresses.joined(separator: ", "), + title: activity.container.map(\.displayName) + ?? activity.listener.addresses.joined(separator: ", "), subtitle: activity.scope == .lan ? L("LAN-facing", "局域网可见") : L("Local only", "仅本机"), isLAN: activity.scope == .lan, closeFailed: model.awaitsForceClose(activity), @@ -695,6 +707,22 @@ struct MenuPanel: View { .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } + if let container = activity.container { + HStack { + Button { + Task { await model.previewContainerStop(activity) } + } label: { + Label(L("Stop Container…", "停止容器…"), systemImage: "stop.fill") + .foregroundStyle(Color(nsColor: .systemRed)) + } + .disabled(model.isPreparingClose || model.isClosing) + .help(L("Review stopping container \(container.name) with docker stop", "确认用 docker stop 停止容器 \(container.name)")) + Spacer(minLength: 0) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + Divider() VStack(alignment: .leading, spacing: 8) { infoRow(L("Command", "命令"), activity.process.command) @@ -707,6 +735,18 @@ struct MenuPanel: View { } infoRow(L("Executable", "可执行文件"), compactPath(activity.process.executablePath)) infoRow(L("Folder", "目录"), compactPath(activity.process.cwd)) + if let container = activity.container { + if let project = container.composeProject { + let location = container.composeDir.map { " · \(compactPath($0))" } ?? "" + infoRow(L("Compose", "Compose"), project + location) + } + if !container.mounts.isEmpty { + let mounts = container.mounts + .map { "\(compactPath($0.source)) → \($0.destination)" } + .joined(separator: "\n") + infoRow(L("Mounts", "挂载"), mounts) + } + } infoRow(L("Addresses", "地址"), activity.listener.addresses.joined(separator: ", ")) if let webURL { infoRow(L("Web URL", "网址"), webURL.absoluteString) @@ -824,6 +864,47 @@ struct MenuPanel: View { } } + /// Stopping a container is LeftOpen's one shell action: `docker stop` through the CLI, which + /// gives the container its grace period and leaves the runtime itself running. + private func containerStopReviewPage(_ plan: ContainerStopPlan) -> some View { + let container = plan.container + let containerPorts = model.snapshot.activities + .filter { $0.container?.name == container.name + || (container.id.isEmpty == false && $0.container?.id == container.id) } + .map { String($0.listener.port) } + return VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(L("Stop container \(container.name)?", "停止容器 \(container.name)?")) + .font(.title3.weight(.semibold)) + Text(L("LeftOpen runs `docker stop`, which asks the container to exit and gives it about 10 seconds before forcing it. The runtime itself keeps running.", + "LeftOpen 会执行 `docker stop`,先请求容器自行退出,约 10 秒后才强制结束。容器运行时本身不受影响。")) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Divider() + infoRow(L("Command", "命令"), container.stopCommand) + infoRow(L("Image", "镜像"), container.image) + if let project = container.composeProject { + infoRow(L("Compose", "Compose"), project + (container.composeDir.map { " · \($0)" } ?? "")) + } + if containerPorts.count > 1 { + caution(L("All of this container's ports close too: \(containerPorts.sorted().joined(separator: ", ")).", + "该容器的全部端口也会一起关闭:\(containerPorts.sorted().joined(separator: ", "))。")) + } + if plan.resurrectsOnDaemonRestart { + caution(L("This container restarts when Docker does. To keep it down across restarts, run `docker update --restart=no \(container.name)`.", + "该容器设置了自动重启策略,Docker 重启后会再次运行。如需一直保持停止,可执行 `docker update --restart=no \(container.name)`。")) + } + } + .padding(14) + } + confirmBar(L("Stop Container", "停止容器")) { + Task { await model.confirmContainerStop() } + } + } + } + /// Shared by both review pages: Cancel is the header's back button (Esc), so the bar only /// carries progress and the destructive default action. private func confirmBar(_ title: String, action: @escaping () -> Void) -> some View { @@ -908,6 +989,22 @@ struct MenuPanel: View { copy(ports.map(String.init).joined(separator: ", ")) } Button(L("Copy PID", "复制 PID")) { copy(String(primary.process.pid)) } + // A compose row spans several containers; each one gets its own stop item. + let stopTargets = activities.reduce( + into: [(container: ContainerInfo, activity: Activity)]() + ) { partial, activity in + guard let container = activity.container, + !partial.contains(where: { $0.container.name == container.name }) else { return } + partial.append((container, activity)) + } + if !stopTargets.isEmpty { + Divider() + ForEach(stopTargets, id: \.container.name) { target in + Button(L("Stop Container \(target.container.displayName)…", "停止容器 \(target.container.displayName)…")) { + Task { await model.previewContainerStop(target.activity) } + } + } + } if let folder = revealTarget(for: primary) { Divider() Button(L("Reveal in Finder", "在访达中显示")) { reveal(folder) } @@ -933,9 +1030,12 @@ struct MenuPanel: View { } private func revealTarget(for activity: Activity) -> String? { - let candidates = [activity.projectMarker?.root, activity.applicationBundle?.path, - activity.inference.category == .project ? activity.process.cwd : nil, - activity.process.executablePath] + // The compose project directory, then a mounted code directory, say where the container + // came from; the runtime's app bundle is only the last resort. + let mounts = (activity.container?.mounts ?? []).map(\.source) + let candidates = [activity.container?.composeDir, activity.projectMarker?.root, + activity.inference.category == .project ? activity.process.cwd : nil] + + mounts + [activity.applicationBundle?.path, activity.process.executablePath] return candidates.compactMap { $0 }.first { FileManager.default.fileExists(atPath: $0) } } @@ -971,6 +1071,18 @@ private struct ListenerGroup: Identifiable { var ports: [Int] { Array(Set(activities.map(\.listener.port))).sorted() } var pids: [Int32] { Array(Set(activities.map(\.process.pid))).sorted() } var scope: ListenerScope { activities.contains { $0.scope == .lan } ? .lan : .local } + /// The containers this row speaks for, in first-seen order. + var containers: [ContainerInfo] { + var seen = Set() + return activities.compactMap { activity in + activity.container.flatMap { seen.insert($0.name).inserted ? $0 : nil } + } + } + + /// A compose project names the row even when it runs several containers. + var title: String { + primary.container?.composeProject ?? primary.inference.label + } /// What a swipe or ✕ closes: only offered when one closable PID owns every port in the row. func closeTarget(safetyProtectionEnabled: Bool) -> Activity? { @@ -980,6 +1092,10 @@ private struct ListenerGroup: Identifiable { var subtitle: String { if pids.count > 1 { return L("\(pids.count) processes", "\(pids.count) 个进程") } + if containers.count > 1 { + return L("\(containers.count) containers · PID \(primary.process.pid)", + "\(containers.count) 个容器 · PID \(primary.process.pid)") + } let pid = "PID \(primary.process.pid)" return primary.inference.label == primary.process.command ? pid : "\(primary.process.command) · \(pid)" } diff --git a/Sources/LeftOpenCore/CloseService.swift b/Sources/LeftOpenCore/CloseService.swift index f27cd27..1014ae2 100644 --- a/Sources/LeftOpenCore/CloseService.swift +++ b/Sources/LeftOpenCore/CloseService.swift @@ -118,6 +118,14 @@ public enum CloseService { guard uid == currentUID else { throw CloseError(L("PID \(target.pid) belongs to another user.", "PID \(target.pid) 属于其他用户。")) } guard let path = target.executablePath else { throw CloseError(L("PID \(target.pid) has no verified executable path.", "无法确认 PID \(target.pid) 的可执行文件路径。")) } if safetyProtectionEnabled { + if PortCategory.isContainerRuntime(activity) { + if let container = activity.container { + throw CloseError(L("PID \(target.pid) only forwards a port for container \(container.name). Stop it with `\(container.stopCommand)`.", + "PID \(target.pid) 只是为容器 \(container.name) 转发端口。请用 `\(container.stopCommand)` 停止该容器。")) + } + throw CloseError(L("PID \(target.pid) only forwards a port for some container. Find it with `docker ps` and stop it with `docker stop …`.", + "PID \(target.pid) 只是在为某个容器转发端口。请先用 `docker ps` 找到它,再用 `docker stop …` 停止。")) + } let protected = ["/System/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/bin/", "/sbin/"] guard !protected.contains(where: { path.hasPrefix($0) }) else { throw CloseError(L("PID \(target.pid) uses an operating-system executable; refusing to close it.", "PID \(target.pid) 是系统程序,拒绝关闭。")) diff --git a/Sources/LeftOpenCore/ContainerResolver.swift b/Sources/LeftOpenCore/ContainerResolver.swift new file mode 100644 index 0000000..0206fc5 --- /dev/null +++ b/Sources/LeftOpenCore/ContainerResolver.swift @@ -0,0 +1,408 @@ +import Foundation +import Network + +/// A host directory bind-mounted into a container (`docker run -v ~/code:/app`), the strongest +/// hint of where a non-compose container was started from. +public struct ContainerMount: Sendable, Equatable { + public let source: String + public let destination: String +} + +/// The container behind a port a runtime forwards, resolved through the Engine API or `docker ps`. +public struct ContainerInfo: Sendable, Equatable { + public let id: String + public let name: String + public let image: String + public let composeProject: String? + /// Directory holding the compose file; `nil` for plain `docker run` containers. + public let composeDir: String? + public let mounts: [ContainerMount] + + public init(id: String = "", name: String, image: String, + composeProject: String? = nil, composeDir: String? = nil, + mounts: [ContainerMount] = []) { + self.id = id + self.name = name + self.image = image + self.composeProject = composeProject + self.composeDir = composeDir + self.mounts = mounts + } + + /// The close that lasts: stopping the container, not the forwarder that merely maps its port. + public var stopCommand: String { "docker stop \(name)" } + + /// Rows show a name a human chose. Docker's generated names (`nostalgic_turing`) say less than + /// the image does, so a plain `docker run` without `--name` is listed by its image. + public var displayName: String { + guard composeProject == nil, Self.isGeneratedName(name) else { return name } + let last = (image as NSString).lastPathComponent + if let colon = last.firstIndex(of: ":") { return String(last[.. Bool { + let pieces = name.split(separator: "_") + guard pieces.count == 2 else { return false } + return pieces.allSatisfy { $0.allSatisfy(\.isLowercase) && $0.allSatisfy(\.isLetter) } + } +} + +/// What a stop review needs before asking the user to confirm: the plan is prepared on demand, +/// never during the polling scan. +public struct ContainerStopPlan: Sendable, Equatable { + public let activity: Activity + public let container: ContainerInfo + /// From `HostConfig.RestartPolicy`: an `always` container comes back when the Docker daemon + /// restarts even after `docker stop`. `nil` when the daemon would not say. + public let restartPolicy: String? + + public init(activity: Activity, container: ContainerInfo, restartPolicy: String?) { + self.activity = activity + self.container = container + self.restartPolicy = restartPolicy + } + + public var resurrectsOnDaemonRestart: Bool { restartPolicy == "always" } +} + +public enum ContainerResolver { + /// Skips the socket after a daemon failure, and the CLI after it failed too, so a hung daemon + /// costs one timeout per minute at most instead of one per scan. + private static let backoff = Backoff() + private static let retryDelay: TimeInterval = 60 + + private final class Backoff: @unchecked Sendable { + private let lock = NSLock() + private var socketFailedAt: Date? + private var cliFailedAt: Date? + + func socketBlocked(now: Date = Date()) -> Bool { isBlocked(&socketFailedAt, now: now) } + func cliBlocked(now: Date = Date()) -> Bool { isBlocked(&cliFailedAt, now: now) } + func markSocketFailure(_ date: Date = Date()) { mark(&socketFailedAt, date) } + func markCLIFailure(_ date: Date = Date()) { mark(&cliFailedAt, date) } + + private func isBlocked(_ stamp: inout Date?, now: Date) -> Bool { + lock.lock(); defer { lock.unlock() } + guard let stamp else { return false } + return now.timeIntervalSince(stamp) < ContainerResolver.retryDelay + } + + private func mark(_ stamp: inout Date?, _ date: Date) { + lock.lock(); defer { lock.unlock() } + stamp = date + } + } + + /// Resolves forwarded host ports to their containers once per scan. The Engine API over the + /// daemon's unix socket is tried first (~10 ms, no process spawn); the `docker ps` CLI, which + /// also understands contexts and remote daemons, is the fallback. Any failure simply leaves + /// the row naming the runtime instead. + public static func containers(for ports: Set) -> [Int: ContainerInfo] { + resolve(for: ports, socketFetch: fetchViaSocket, cliFetch: containersViaCLI) + } + + /// The socket's failure backoff never gates the CLI fallback: skipping it is what turns a + /// half-second socket hiccup into a minute of rows losing their containers. + static func resolve(for ports: Set, + socketFetch: (String) -> Data?, + cliFetch: (Set) -> [Int: ContainerInfo]) -> [Int: ContainerInfo] { + guard !ports.isEmpty else { return [:] } + if !backoff.socketBlocked(), let socket = daemonSocket() { + if let body = socketFetch(socket), let parsed = parseEngineList(body, ports: ports) { + return parsed + } + backoff.markSocketFailure() + } + return cliFetch(ports) + } + + private static func fetchViaSocket(_ socket: String) -> Data? { + UnixSocketHTTP.get(socket, path: "/containers/json", timeout: 2) + } + + /// Restart policy for the stop review, fetched once per action — the scan path never calls this. + public static func restartPolicy(of container: ContainerInfo) -> String? { + if !backoff.socketBlocked(), let socket = daemonSocket() { + if container.id.count == 12, + let body = UnixSocketHTTP.get(socket, path: "/containers/\(container.id)/json", timeout: 2), + let policy = parseRestartPolicy(body) { + return policy + } + backoff.markSocketFailure() + } + if let docker = dockerExecutable(), !backoff.cliBlocked(), + let output = try? CommandRunner.output(docker, + ["inspect", "--format", "{{.HostConfig.RestartPolicy.Name}}", container.name], + timeout: 3) { + return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : output.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + /// Stops the container through the CLI: a rare, user-confirmed write, which the CLI serves + /// with all its context and authentication handling. `docker stop` gives the container ~10 s + /// to exit gracefully, so the wait is generous. + public static func stop(_ container: ContainerInfo) throws -> String { + guard let docker = dockerExecutable() else { + throw CloseError(L("The `docker` command is not available on this Mac.", "这台 Mac 上没有 `docker` 命令。")) + } + return try CommandRunner.output(docker, ["stop", container.name], timeout: 20) + } + + // MARK: - Engine API + + /// One `GET /containers/json` entry per container: names, image, structured port mappings, + /// compose labels and bind mounts — everything except the restart policy. + static func parseEngineList(_ data: Data, ports: Set) -> [Int: ContainerInfo]? { + guard let containers = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return nil + } + var resolved: [Int: ContainerInfo] = [:] + for container in containers { + guard let names = container["Names"] as? [String], let name = names.first else { continue } + let labels = container["Labels"] as? [String: String] + let info = ContainerInfo( + id: String((container["Id"] as? String ?? "").prefix(12)), + name: name.hasPrefix("/") ? String(name.dropFirst()) : name, + image: container["Image"] as? String ?? "", + composeProject: labels?["com.docker.compose.project"], + composeDir: composeDirectory(labels: labels), + mounts: (container["Mounts"] as? [[String: Any]] ?? []).compactMap { mount in + guard let source = mount["Source"] as? String, + let destination = mount["Destination"] as? String else { return nil } + return ContainerMount(source: source, destination: destination) + }) + for port in container["Ports"] as? [[String: Any]] ?? [] { + guard let publicPort = port["PublicPort"] as? Int, + ports.contains(publicPort), resolved[publicPort] == nil else { continue } + resolved[publicPort] = info + } + } + return resolved + } + + static func parseRestartPolicy(_ data: Data) -> String? { + guard let container = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hostConfig = container["HostConfig"] as? [String: Any], + let policy = hostConfig["RestartPolicy"] as? [String: Any], + let name = policy["Name"] as? String, !name.isEmpty else { return nil } + return name + } + + /// The compose file's directory beats the label's `working_dir`, which is where compose was + /// invoked from (`-f` elsewhere is common enough to matter). + static func composeDirectory(labels: [String: String]?) -> String? { + if let configFile = labels?["com.docker.compose.project.config_files"]? + .split(separator: ",").first.map(String.init) { + let dir = (configFile as NSString).deletingLastPathComponent + if !dir.isEmpty { return dir } + } + return labels?["com.docker.compose.project.working_dir"] + } + + /// `DOCKER_HOST` first (it is what the CLI would use), then the sockets the bundled runtimes + /// create. Colima's context socket is left to the CLI fallback, which reads `~/.docker/config.json`. + static func daemonSocket() -> String? { + if let host = ProcessInfo.processInfo.environment["DOCKER_HOST"], + host.hasPrefix("unix://"), let path = host.dropFirst("unix://".count).split(separator: ",").first { + let socket = String(path) + return isSocket(socket) ? socket : nil + } + let home = NSHomeDirectory() + return [ + home + "/.docker/run/docker.sock", // Docker Desktop, user-owned + "/var/run/docker.sock", // Docker Desktop and OrbStack symlink here + home + "/.orbstack/run/docker.sock", + home + "/.colima/docker.sock", + ].first { isSocket($0) } + } + + private static func isSocket(_ path: String) -> Bool { + var stats = stat() + return stat(path, &stats) == 0 && (stats.st_mode & S_IFMT) == S_IFSOCK + } + + // MARK: - CLI fallback + + static func containersViaCLI(for ports: Set) -> [Int: ContainerInfo] { + guard !ports.isEmpty, !backoff.cliBlocked(), let docker = dockerExecutable() else { return [:] } + let output = try? CommandRunner.output(docker, + ["ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}"], timeout: 3) + guard let output else { + backoff.markCLIFailure() + return [:] + } + return parse(output, ports: ports) + } + + /// One `docker ps` row per line: name, image and the published mappings. + static func parse(_ output: String, ports: Set) -> [Int: ContainerInfo] { + var resolved: [Int: ContainerInfo] = [:] + for line in output.split(whereSeparator: \.isNewline) { + let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) + guard fields.count >= 3 else { continue } + for hostPort in publishedHostPorts(fields[2]) { + guard ports.contains(hostPort), resolved[hostPort] == nil else { continue } + resolved[hostPort] = ContainerInfo(name: fields[0], image: fields[1]) + } + } + return resolved + } + + /// `docker ps` renders mappings like `0.0.0.0:5432->5432/tcp, :::8080->80/tcp`; the host port is + /// the number left of `->`. Empty string when a container publishes nothing. + static func publishedHostPorts(_ portList: String) -> [Int] { + portList.split(separator: ",").compactMap { mapping in + let mapping = mapping.trimmingCharacters(in: .whitespaces) + guard let arrow = mapping.range(of: "->") else { return nil } + let hostSide = mapping[.. String? { + [ + "/usr/local/bin/docker", // Docker Desktop's symlink, Intel Homebrew + "/opt/homebrew/bin/docker", // Apple Silicon Homebrew + "/usr/bin/docker", + NSHomeDirectory() + "/.orbstack/bin/docker", + ].first { FileManager.default.isExecutableFile(atPath: $0) } + } +} + +/// The minimal HTTP/1.1 client the Engine API needs over its unix socket. Foundation's URLSession +/// cannot speak unix sockets; `Connection: close` keeps the response framing trivial — the daemon +/// closes the socket, which ends the read. +enum UnixSocketHTTP { + /// Blocking GET; safe off the main thread (the scan runs on a detached utility task). + static func get(_ socketPath: String, path: String, timeout: TimeInterval) -> Data? { + let connection = NWConnection(to: .unix(path: socketPath), using: .tcp) + let finished = DispatchSemaphore(value: 0) + let box = ResultBox() + let queue = DispatchQueue(label: "leftopen.docker.socket") + let deadline = Date().addingTimeInterval(timeout) + + queue.asyncAfter(deadline: .now() + timeout) { + box.cancelIfUnsettled(connection) + finished.signal() + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let request = "GET \(path) HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n" + connection.send(content: Data(request.utf8), completion: .contentProcessed { error in + if error != nil { + box.cancelIfUnsettled(connection) + finished.signal() + } else { + receive(connection, into: box, on: queue, deadline: deadline, done: finished) + } + }) + case .failed, .cancelled: + box.cancelIfUnsettled(connection) + finished.signal() + default: + break + } + } + connection.start(queue: queue) + _ = finished.wait() + guard let raw = box.data else { return nil } + return responseBody(raw) + } + + /// Splits status, headers and body, and decodes a chunked body — the Docker daemon chunks + /// `/containers/json` even when asked for HTTP/1.0, so the framing has to be undone before + /// the JSON can parse. + static func responseBody(_ raw: Data) -> Data? { + guard let headerEnd = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil } + let head = String(decoding: raw[raw.startIndex..= 2, fields[1] == "200" else { return nil } + var body = raw.subdata(in: headerEnd.upperBound..\r\n\r\n` chunks ending in a zero-size chunk. + static func dechunked(_ data: Data) -> Data? { + var payload = Data() + var index = data.startIndex + while index < data.endIndex { + guard let lineEnd = data.range(of: Data("\r\n".utf8), in: index..= 0 else { return nil } + index = lineEnd.upperBound + if size == 0 { return payload } + guard data.distance(from: index, to: data.endIndex) >= size else { return nil } + let chunkStart = index + index = data.index(index, offsetBy: size) + payload.append(data[chunkStart..= 2 else { return nil } + index = data.index(index, offsetBy: 2) + } + return nil + } + + private static func receive(_ connection: NWConnection, into box: ResultBox, on queue: DispatchQueue, + deadline: Date, done: DispatchSemaphore) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { data, _, isComplete, error in + if let data, !data.isEmpty { box.append(data) } + if error != nil || isComplete || Date() >= deadline { + box.settle(connection) + done.signal() + return + } + receive(connection, into: box, on: queue, deadline: deadline, done: done) + } + } + + /// One answer per request: the first terminal event wins and cancels the connection. + private final class ResultBox: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private var settled = false + + var data: Data? { + lock.lock(); defer { lock.unlock() } + return settled ? buffer : nil + } + + func append(_ chunk: Data) { + lock.lock(); defer { lock.unlock() } + guard !settled else { return } + buffer.append(chunk) + } + + func settle(_ connection: NWConnection) { + lock.lock() + if !settled { settled = true } else { lock.unlock(); return } + lock.unlock() + connection.cancel() + } + + /// A timeout or failure path for requests that never produced a complete response. + func cancelIfUnsettled(_ connection: NWConnection) { + lock.lock() + if settled { lock.unlock(); return } + buffer = Data() + settled = true + lock.unlock() + connection.cancel() + } + } +} diff --git a/Sources/LeftOpenCore/Models.swift b/Sources/LeftOpenCore/Models.swift index b4434bd..393e8ce 100644 --- a/Sources/LeftOpenCore/Models.swift +++ b/Sources/LeftOpenCore/Models.swift @@ -124,8 +124,10 @@ public struct Activity: Sendable, Equatable, Identifiable { public let projectMarker: ProjectMarker? public let applicationBundle: ApplicationBundle? public let scope: ListenerScope - public let inference: OwnerInference + public var inference: OwnerInference public var launchdJob: LaunchdJob? = nil + /// Set when a container runtime forwards this port and `docker ps` matched it to a container. + public var container: ContainerInfo? = nil public var id: String { "\(listener.port):\(listener.pid)" } } diff --git a/Sources/LeftOpenCore/PortCategory.swift b/Sources/LeftOpenCore/PortCategory.swift index d28163f..71c44ab 100644 --- a/Sources/LeftOpenCore/PortCategory.swift +++ b/Sources/LeftOpenCore/PortCategory.swift @@ -2,13 +2,16 @@ import Foundation /// Where a listening process comes from, which is also how it is closed for good. The same program /// can land in any category: Postgres started in a terminal is a dev server, from `brew services` -/// a background service, inside Docker Desktop an app. So the category rests on who started the -/// process, never on lists of program names or port numbers, and the row name says what it is. +/// a background service, inside Docker Desktop a container. So the category rests on who started +/// the process, never on lists of program names or port numbers, and the row name says what it is. +/// Container runtimes are the one name-based exception: they forward container ports through their +/// own host processes, so the runtime itself is the owner the row should name. /// /// A process is classified as a whole, so all of its ports land in the same section. public enum PortCategory: String, CaseIterable, Sendable { case devServer = "dev-server" case background + case container case app case system case other @@ -17,6 +20,7 @@ public enum PortCategory: String, CaseIterable, Sendable { switch self { case .devServer: L("Dev Servers", "开发服务器") case .background: L("Background Services", "后台服务") + case .container: L("Containers", "容器") case .app: L("Apps", "App") case .system: L("System", "系统") case .other: L("Other", "其他") @@ -30,6 +34,8 @@ public enum PortCategory: String, CaseIterable, Sendable { "从项目、终端、编辑器或 agent 启动。关闭时发送 SIGTERM,关掉后不会自己回来。") case .background: L("Run by launchd (brew services, login items). Stop the service, or launchd may start it again.", "由 launchd 管理(brew services、登录项)。请停止对应服务,否则 launchd 可能会再次启动它。") + case .container: L("Forwarded by a container runtime (Docker Desktop, OrbStack, colima). Open a port to stop its container with docker stop.", + "由容器运行时转发(Docker Desktop、OrbStack、colima)。点开端口详情,即可用 docker stop 停止对应容器。") case .app: L("Opened by a running app. Quit the app to free them.", "由正在运行的 App 打开。退出该 App 即可释放。") case .system: L("Belong to macOS or another user. Sharing features are turned off in System Settings.", "属于 macOS 或其他用户。共享类功能可在“系统设置”中关闭。") @@ -47,6 +53,7 @@ public enum PortCategory: String, CaseIterable, Sendable { public static func classify(_ activities: [Activity]) -> PortCategory { guard let activity = activities.first else { return .other } if isSystem(activity) { return .system } + if isContainerRuntime(activity) { return .container } if activity.launchdJob != nil { return .background } if activity.applicationBundle?.direct == true { return .app } if activity.projectMarker != nil || startedFromShell(activity) || isOrphan(activity) { return .devServer } @@ -62,6 +69,24 @@ public enum PortCategory: String, CaseIterable, Sendable { .contains(where: path.hasPrefix) } + /// Container runtimes forward container ports through their own host processes: Docker Desktop + /// via `com.docker.backend` inside its app bundle, OrbStack through helpers in its bundle, + /// colima or a standalone daemon via `docker-proxy`/`gvproxy`. The forwarder, not the container, + /// holds the port — killing it only breaks the mapping while the container keeps running, so + /// these rows name the runtime (or the container itself, once resolved) and close via + /// `docker stop`. The check looks at the listener and its ancestors, never at port numbers. + static func isContainerRuntime(_ activity: Activity) -> Bool { + if let bundle = activity.applicationBundle, + ["docker", "orbstack"].contains(bundle.name.lowercased()) { return true } + let forwarders: Set = [ + "com.docker.backend", "docker-proxy", "dockerd", "gvproxy", "colima", "lima", "limactl", + ] + let names = ([activity.process.executablePath] + activity.parentChain.map(\.executablePath)) + .compactMap { $0 } + .map { ($0 as NSString).lastPathComponent.lowercased() } + return names.contains { forwarders.contains($0) } + } + /// A shell among the ancestors means someone ran it: a terminal, an editor's terminal or task, /// tmux, or an agent's command. Login shells show up as `-zsh`. private static func startedFromShell(_ activity: Activity) -> Bool { diff --git a/Sources/LeftOpenCore/Scanner.swift b/Sources/LeftOpenCore/Scanner.swift index 5786f24..1a2f8ed 100644 --- a/Sources/LeftOpenCore/Scanner.swift +++ b/Sources/LeftOpenCore/Scanner.swift @@ -306,7 +306,28 @@ public enum Scanner { scope: listenerScope(listener.addresses), inference: inference, launchdJob: launchdJobs[listener.pid] ?? process.ppid.flatMap { launchdJobs[$0] }) } - return ScanSnapshot(activities: activities, limitations: limitations) + return ScanSnapshot(activities: resolveContainers(activities), limitations: limitations) + } + + /// Forwarded ports name their container once the Engine API or `docker ps` can match them; the + /// runtime keeps naming the row otherwise. + private static func resolveContainers(_ activities: [Activity]) -> [Activity] { + let forwarded = Set(activities.filter(PortCategory.isContainerRuntime).map(\.listener.port)) + let containers = ContainerResolver.containers(for: forwarded) + guard !containers.isEmpty else { return activities } + return activities.map { activity in + guard let container = containers[activity.listener.port] else { return activity } + let detail = container.composeProject.map { project in + L("compose project \(project)", "compose 项目 \(project)") + } ?? container.image + let inference = OwnerInference(label: container.displayName, category: .service, confidence: "high", + reason: L("Port \(activity.listener.port) is forwarded to container \(container.name) (\(detail)).", + "端口 \(activity.listener.port) 转发到容器 \(container.name)(\(detail))。")) + var updated = activity + updated.inference = inference + updated.container = container + return updated + } } /// launchd jobs whose PID is one of the listeners or their direct parents (a service may run a diff --git a/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift b/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift index 3d443ba..f13e752 100644 --- a/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift +++ b/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift @@ -438,12 +438,192 @@ final class LeftOpenCoreTests: XCTestCase { XCTAssertEqual(category(parents: ["some-daemon"]), .other) } + func testContainerRuntimesFormTheirOwnCategory() { + func runtimeActivity(path: String, bundleName: String? = nil, parents: [String] = []) -> Activity { + let listener = Listener(pid: 42, command: "x", uid: Int32(getuid()), user: nil, + port: 8080, addresses: ["127.0.0.1"]) + let process = ProcessFact(pid: 42, ppid: 500, command: "x", executablePath: path, + uid: Int32(getuid()), user: nil, cwd: nil) + let chain = parents.enumerated().map { index, name in + ProcessFact(pid: Int32(100 + index), ppid: nil, command: name, executablePath: nil, + uid: Int32(getuid()), user: nil, cwd: nil) + } + let bundle = bundleName.map { ApplicationBundle(name: $0, path: "/Applications/\($0).app", sourcePID: 99, direct: true) } + return Activity(listener: listener, process: process, parentChain: chain, + projectMarker: nil, applicationBundle: bundle, scope: .local, + inference: OwnerInference(label: bundleName ?? "x", category: .application, + confidence: "high", reason: "Fixture.")) + } + + // Docker Desktop forwards through com.docker.backend inside its app bundle. + let backend = runtimeActivity(path: "/Applications/Docker.app/Contents/MacOS/com.docker.backend", bundleName: "Docker") + XCTAssertEqual(PortCategory.classify([backend]), .container) + XCTAssertTrue(CloseService.protectionReason(for: backend)?.contains("docker ps") == true) + + // OrbStack's forwarders sit inside its bundle. + XCTAssertEqual(PortCategory.classify([runtimeActivity(path: "/Applications/OrbStack.app/Contents/MacOS/OrbStack-helper", bundleName: "OrbStack")]), .container) + + // colima / a standalone daemon forward via docker-proxy, a child of the daemon. + XCTAssertEqual(PortCategory.classify([runtimeActivity(path: "/Users/me/.colima/_lima/colima/docker-proxy", parents: ["dockerd", "colima"])]), .container) + + // Once the container is resolved, the refusal names the exact stop command. + var resolved = backend + resolved.container = ContainerInfo(name: "leftopen-db", image: "postgres:16") + XCTAssertTrue(CloseService.protectionReason(for: resolved)?.contains("docker stop leftopen-db") == true) + + // An ordinary app still lands in Apps. + XCTAssertEqual(PortCategory.classify([runtimeActivity(path: "/Applications/Editor.app/Contents/MacOS/Editor", bundleName: "Editor")]), .app) + } + + func testContainerResolverParsesDockerPsOutput() { + let output = """ + leftopen-db\tpostgres:16\t0.0.0.0:5432->5432/tcp, :::5432->5432/tcp + web\tnginx:alpine\t0.0.0.0:8080->80/tcp, [::]:8080->80/tcp + internal\tredis:7\t6379/tcp, 6379->6379/udp + """ + let resolved = ContainerResolver.parse(output, ports: [5432, 8080, 9999]) + XCTAssertEqual(resolved[5432], ContainerInfo(name: "leftopen-db", image: "postgres:16")) + XCTAssertEqual(resolved[8080]?.name, "web") + XCTAssertNil(resolved[9999], "an unpublishable lookup stays unresolved") + XCTAssertEqual(resolved.count, 2, "ports without a host mapping resolve to nothing") + } + + func testContainerResolverParsesEngineListJSON() { + let json = """ + [ + {"Id":"8dfafdbc3a40abcdef0123456789","Names":["/leftopen-db"],"Image":"postgres:16", + "Ports":[{"IP":"0.0.0.0","PrivatePort":5432,"PublicPort":5432,"Type":"tcp"}, + {"IP":"::","PrivatePort":5432,"PublicPort":5432,"Type":"tcp"}], + "Labels":{"com.docker.compose.project":"leftopen", + "com.docker.compose.project.working_dir":"/Users/me/leftopen", + "com.docker.compose.project.config_files":"/Users/me/leftopen/compose.yaml"}, + "Mounts":[{"Source":"/Users/me/leftopen/data","Destination":"/var/lib/postgresql/data","Type":"bind"}]}, + {"Id":"9cd87474be90","Names":["/nostalgic_turing"],"Image":"docker.io/library/redis:7-alpine", + "Ports":[{"IP":"0.0.0.0","PrivatePort":6379,"PublicPort":6379,"Type":"tcp"}], + "Labels":{},"Mounts":[{"Source":"/Users/me/side","Destination":"/data","Type":"bind"}]}, + {"Id":"aaa","Names":["/internal-only"],"Image":"redis:7","Ports":[{"PrivatePort":6379,"Type":"tcp"}], + "Labels":null,"Mounts":[]} + ] + """ + let resolved = ContainerResolver.parseEngineList(Data(json.utf8), ports: [5432, 6379])! + + let compose = resolved[5432] + XCTAssertEqual(compose?.name, "leftopen-db") + XCTAssertEqual(compose?.id, "8dfafdbc3a40") + XCTAssertEqual(compose?.composeProject, "leftopen") + XCTAssertEqual(compose?.composeDir, "/Users/me/leftopen", "the compose file's directory wins over working_dir") + XCTAssertEqual(compose?.mounts, [ContainerMount(source: "/Users/me/leftopen/data", destination: "/var/lib/postgresql/data")]) + XCTAssertEqual(compose?.displayName, "leftopen-db") + + let generated = resolved[6379] + XCTAssertEqual(generated?.name, "nostalgic_turing") + XCTAssertEqual(generated?.displayName, "redis", "a generated name falls back to the image") + XCTAssertNil(generated?.composeProject) + XCTAssertEqual(generated?.mounts.first?.source, "/Users/me/side") + } + + func testContainerResolverParsesRestartPolicy() { + let always = """ + {"HostConfig":{"RestartPolicy":{"Name":"always","MaximumRetryCount":0}}} + """ + XCTAssertEqual(ContainerResolver.parseRestartPolicy(Data(always.utf8)), "always") + + let dockerRun = """ + {"HostConfig":{"RestartPolicy":{"Name":"no","MaximumRetryCount":0}}} + """ + XCTAssertEqual(ContainerResolver.parseRestartPolicy(Data(dockerRun.utf8)), "no") + let activity = fixtureActivity(pid: 42, path: "/opt/homebrew/bin/docker-proxy") + let container = ContainerInfo(name: "db", image: "postgres:16") + XCTAssertTrue(ContainerStopPlan(activity: activity, container: container, + restartPolicy: "always").resurrectsOnDaemonRestart) + XCTAssertFalse(ContainerStopPlan(activity: activity, container: container, + restartPolicy: "no").resurrectsOnDaemonRestart) + XCTAssertFalse(ContainerStopPlan(activity: activity, container: container, + restartPolicy: nil).resurrectsOnDaemonRestart) + } + + func testContainerNamesPreferSomethingReadable() { + XCTAssertTrue(ContainerInfo.isGeneratedName("nostalgic_turing")) + XCTAssertFalse(ContainerInfo.isGeneratedName("leftopen-db"), "a chosen --name keeps the row") + XCTAssertFalse(ContainerInfo.isGeneratedName("leftopen_db_2"), "compose names have three parts") + XCTAssertFalse(ContainerInfo.isGeneratedName("LeftOpen")) + XCTAssertEqual(ContainerInfo(name: "nostalgic_turing", image: "redis:7-alpine").displayName, "redis") + XCTAssertEqual(ContainerInfo(name: "nostalgic_turing", image: "ghcr.io/acme/api:v2").displayName, "api") + XCTAssertEqual(ContainerInfo(name: "chosen", image: "redis:7").displayName, "chosen") + XCTAssertEqual(ContainerInfo(name: "proj-web-1", image: "nginx", + composeProject: "proj").displayName, "proj-web-1") + } + + func testSocketClientTalksToTheLocalDaemon() throws { + guard let socket = ContainerResolver.daemonSocket() else { + throw XCTSkip("No local Docker daemon socket on this machine.") + } + let body = UnixSocketHTTP.get(socket, path: "/version", timeout: 2) + let json = String(decoding: try XCTUnwrap(body), as: UTF8.self) + XCTAssertTrue(json.contains("ApiVersion") || json.contains("Version"), + "the daemon answered over the unix socket") + + // The production path against the daemon's real chunked response: a body that decodes + // and parses is the whole socket story end to end. + let list = UnixSocketHTTP.get(socket, path: "/containers/json", timeout: 2) + XCTAssertNotNil(ContainerResolver.parseEngineList(try XCTUnwrap(list), ports: [])) + } + + /// The Docker daemon chunks `/containers/json`; an undecoded body kills every socket resolve. + func testChunkedEngineResponseIsDecoded() throws { + let json = #"[{"Names":["/leftopen-db"],"Image":"postgres:16","Ports":[{"PublicPort":5432,"Type":"tcp"}],"Labels":null,"Mounts":[]}]"# + let pieces = [json.prefix(30), json.dropFirst(30).prefix(20), json.dropFirst(50)] + var raw = Data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".utf8) + for piece in pieces { + raw.append(Data(String(piece.count, radix: 16).utf8)) + raw.append(Data("\r\n".utf8)) + raw.append(Data(piece.utf8)) + raw.append(Data("\r\n".utf8)) + } + raw.append(Data("0\r\n\r\n".utf8)) + + let body = try XCTUnwrap(UnixSocketHTTP.responseBody(raw)) + XCTAssertEqual(String(decoding: body, as: UTF8.self), json) + XCTAssertEqual(ContainerResolver.parseEngineList(body, ports: [5432])?[5432]?.name, "leftopen-db") + + // A truncated stream has no zero-size terminator and must not parse halfway. + let truncated = raw.subdata(in: raw.startIndex..