Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion App/Views/StackSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }) {
Expand Down
8 changes: 6 additions & 2 deletions ConsaiCore/Sources/ConsaiCore/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>-<service>` name prefix (may mis-group).
case inferred
}

Expand Down
14 changes: 14 additions & 0 deletions ConsaiCore/Tests/ConsaiCoreTests/ProjectRegistryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading