diff --git a/App/Views/StackSection.swift b/App/Views/StackSection.swift index b866836..a221730 100644 --- a/App/Views/StackSection.swift +++ b/App/Views/StackSection.swift @@ -87,7 +87,9 @@ struct StackSection: View { stackButton("folder", "Reveal compose file") { NSWorkspace.shared.activateFileViewerSelecting([file]) } - } else if stack.origin == .inferred { + } else if appState.composeAvailable { + // No compose file linked yet (inferred, or labeled but launched outside Consai): + // let the user point at one to enable up/down. stackButton("link", "Link compose file…") { if let file = ComposeFilePicker.pick() { appState.linkComposeFile(project: stack.projectName, file: file) diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift b/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift index 28b3c47..8c4ae46 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift @@ -44,6 +44,10 @@ public struct ProjectRegistry: Codable, Sendable, Equatable { // MARK: - Assembly + /// The de-facto-standard Docker Compose project label, written by compose tools (incl. + /// container-compose once Mcrich23/Container-Compose#110 lands). + public static let composeProjectLabel = "com.docker.compose.project" + /// Fold containers into stacks + standalone leftovers. /// /// - Parameter inferStacks: when true, containers Consai didn't launch are grouped by @@ -58,6 +62,24 @@ public struct ProjectRegistry: Codable, Sendable, Equatable { var remaining = containers var stacks: [Stack] = [] + // 0. Reliable grouping by the `com.docker.compose.project` label (always on — no + // guessing). Lets externally-launched stacks group correctly without the + // name-prefix heuristic, once the compose tool labels its containers. + let labeled = Dictionary(grouping: remaining.filter { $0.labels[Self.composeProjectLabel] != nil }) { + $0.labels[Self.composeProjectLabel]! + } + for (project, members) in labeled { + remaining.removeAll { m in members.contains { $0.id == m.id } } + stacks.append( + Stack( + projectName: project, + composeFilePath: knownProjects[project]?.path, + services: members, + origin: .composeLabeled + ) + ) + } + // 1. Known projects (authoritative), longest project name first so a more specific // project wins over a shorter one that is a prefix of it. for project in knownProjects.keys.sorted(by: { $0.count > $1.count }) { diff --git a/ConsaiCore/Sources/ConsaiCore/Models/Models.swift b/ConsaiCore/Sources/ConsaiCore/Models/Models.swift index 9318748..09ad95a 100644 --- a/ConsaiCore/Sources/ConsaiCore/Models/Models.swift +++ b/ConsaiCore/Sources/ConsaiCore/Models/Models.swift @@ -57,10 +57,14 @@ public func formatBytes(_ bytes: UInt64) -> String { return "\(Int(mb.rounded())) MB" } -/// Whether a stack was launched by Consai (authoritative, has compose file) or merely -/// inferred from container naming (best-effort, may lack a compose file). +/// How confident we are that a stack's grouping is real. public enum StackOrigin: Sendable, Equatable { + /// Consai launched it via compose; we hold its compose file (full up/down). case launchedByConsai + /// Grouped reliably by the `com.docker.compose.project` label (a real stack, possibly + /// launched outside Consai so we may not hold its compose file). + case composeLabeled + /// Best-effort grouping from the `-` name prefix (may mis-group). case inferred } diff --git a/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift index 2ae917e..c1f4d18 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift @@ -55,6 +55,20 @@ import Foundation #expect(result.standalone.map(\.name).sorted() == ["qa-cache", "qa-web"]) } + @Test func groupsByComposeProjectLabelEvenWithInferenceOff() { + let lbl = ProjectRegistry.composeProjectLabel + let labeledApi = Container(id: "1", name: "shop-api", image: "img", status: .running, labels: [lbl: "shop"]) + let labeledWorker = Container(id: "2", name: "shop-worker", image: "img", status: .running, labels: [lbl: "shop"]) + let result = ProjectRegistry().assemble(containers: [labeledApi, labeledWorker, c("3", "qa-web")]) + + #expect(result.stacks.count == 1) + let stack = try! #require(result.stacks.first) + #expect(stack.projectName == "shop") + #expect(stack.origin == .composeLabeled) + #expect(stack.services.count == 2) + #expect(result.standalone.map(\.name) == ["qa-web"]) // unlabeled stays standalone + } + @Test func singleHyphenatedContainerIsNotAStack() { let result = ProjectRegistry().assemble(containers: [c("1", "alone-web")]) #expect(result.stacks.isEmpty)