Skip to content

Commit 7da77a4

Browse files
authored
Group external stacks reliably via com.docker.compose.project label (#15)
Adds label-based grouping as step 0 of assembly (always on, no guessing): containers carrying com.docker.compose.project group into a .composeLabeled stack regardless of the name-prefix setting. This is reliable (no false 'qa' stack, #12) and groups externally- launched stacks once the compose tool labels them (upstream Mcrich23/Container-Compose#110). - StackOrigin.composeLabeled (trusted; no 'wild' badge; link a compose file to enable up/down) - ProjectRegistry.composeProjectLabel constant + label grouping before known/inferred - StackSection offers 'Link compose file' for any unlinked stack - Test: label grouping works with inference off; unlabeled containers stay standalone - Verified live: a patched-container-compose 'shop' stack groups with no 'wild' badge Closes #14
1 parent 16ffef4 commit 7da77a4

4 files changed

Lines changed: 45 additions & 3 deletions

File tree

App/Views/StackSection.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ struct StackSection: View {
8787
stackButton("folder", "Reveal compose file") {
8888
NSWorkspace.shared.activateFileViewerSelecting([file])
8989
}
90-
} else if stack.origin == .inferred {
90+
} else if appState.composeAvailable {
91+
// No compose file linked yet (inferred, or labeled but launched outside Consai):
92+
// let the user point at one to enable up/down.
9193
stackButton("link", "Link compose file…") {
9294
if let file = ComposeFilePicker.pick() {
9395
appState.linkComposeFile(project: stack.projectName, file: file)

ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ public struct ProjectRegistry: Codable, Sendable, Equatable {
4444

4545
// MARK: - Assembly
4646

47+
/// The de-facto-standard Docker Compose project label, written by compose tools (incl.
48+
/// container-compose once Mcrich23/Container-Compose#110 lands).
49+
public static let composeProjectLabel = "com.docker.compose.project"
50+
4751
/// Fold containers into stacks + standalone leftovers.
4852
///
4953
/// - Parameter inferStacks: when true, containers Consai didn't launch are grouped by
@@ -58,6 +62,24 @@ public struct ProjectRegistry: Codable, Sendable, Equatable {
5862
var remaining = containers
5963
var stacks: [Stack] = []
6064

65+
// 0. Reliable grouping by the `com.docker.compose.project` label (always on — no
66+
// guessing). Lets externally-launched stacks group correctly without the
67+
// name-prefix heuristic, once the compose tool labels its containers.
68+
let labeled = Dictionary(grouping: remaining.filter { $0.labels[Self.composeProjectLabel] != nil }) {
69+
$0.labels[Self.composeProjectLabel]!
70+
}
71+
for (project, members) in labeled {
72+
remaining.removeAll { m in members.contains { $0.id == m.id } }
73+
stacks.append(
74+
Stack(
75+
projectName: project,
76+
composeFilePath: knownProjects[project]?.path,
77+
services: members,
78+
origin: .composeLabeled
79+
)
80+
)
81+
}
82+
6183
// 1. Known projects (authoritative), longest project name first so a more specific
6284
// project wins over a shorter one that is a prefix of it.
6385
for project in knownProjects.keys.sorted(by: { $0.count > $1.count }) {

ConsaiCore/Sources/ConsaiCore/Models/Models.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ public func formatBytes(_ bytes: UInt64) -> String {
5757
return "\(Int(mb.rounded())) MB"
5858
}
5959

60-
/// Whether a stack was launched by Consai (authoritative, has compose file) or merely
61-
/// inferred from container naming (best-effort, may lack a compose file).
60+
/// How confident we are that a stack's grouping is real.
6261
public enum StackOrigin: Sendable, Equatable {
62+
/// Consai launched it via compose; we hold its compose file (full up/down).
6363
case launchedByConsai
64+
/// Grouped reliably by the `com.docker.compose.project` label (a real stack, possibly
65+
/// launched outside Consai so we may not hold its compose file).
66+
case composeLabeled
67+
/// Best-effort grouping from the `<project>-<service>` name prefix (may mis-group).
6468
case inferred
6569
}
6670

ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ import Foundation
5555
#expect(result.standalone.map(\.name).sorted() == ["qa-cache", "qa-web"])
5656
}
5757

58+
@Test func groupsByComposeProjectLabelEvenWithInferenceOff() {
59+
let lbl = ProjectRegistry.composeProjectLabel
60+
let labeledApi = Container(id: "1", name: "shop-api", image: "img", status: .running, labels: [lbl: "shop"])
61+
let labeledWorker = Container(id: "2", name: "shop-worker", image: "img", status: .running, labels: [lbl: "shop"])
62+
let result = ProjectRegistry().assemble(containers: [labeledApi, labeledWorker, c("3", "qa-web")])
63+
64+
#expect(result.stacks.count == 1)
65+
let stack = try! #require(result.stacks.first)
66+
#expect(stack.projectName == "shop")
67+
#expect(stack.origin == .composeLabeled)
68+
#expect(stack.services.count == 2)
69+
#expect(result.standalone.map(\.name) == ["qa-web"]) // unlabeled stays standalone
70+
}
71+
5872
@Test func singleHyphenatedContainerIsNotAStack() {
5973
let result = ProjectRegistry().assemble(containers: [c("1", "alone-web")])
6074
#expect(result.stacks.isEmpty)

0 commit comments

Comments
 (0)