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
2 changes: 1 addition & 1 deletion COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1167,7 +1167,7 @@ mocker compose rm [OPTIONS] [SERVICE...]
|------|-------|-------------|
| `--force` | | Don't ask to confirm removal |
| `--stop` | `-s` | Stop the containers, if required, before removing |
| `--volumes` | `-v` | Remove any anonymous volumes attached to containers |
| `--volumes` | `-v` | No effect: mocker does not create anonymous volumes, so there is nothing to remove. Warns rather than reporting a cleanup it did not do |
| `--dry-run` | | Execute command in dry run mode |

### `mocker compose kill`
Expand Down
12 changes: 4 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ See the **[CHANGELOG](CHANGELOG.md)** for the full, always-current release histo
## Features

- **Docker CLI compatible** — `run`, `ps`, `stop`, `rm`, `exec`, `logs`, `build`, `pull`, `push`, `images`, `tag`, `rmi`, `inspect`, `stats`
- **Network management** — `network create/ls/rm/inspect/connect/disconnect`
- **Network management** — `network create/ls/rm/inspect`, and compose `networks:`
- **Volume management** — `volume create/ls/rm/inspect`
- **Docker Compose v2** — `compose up/down/ps/logs/restart` with dependency ordering
- **MenuBar GUI** — Native SwiftUI app *(coming soon)*
Expand Down Expand Up @@ -226,11 +226,8 @@ mocker network create mynet
# List networks
mocker network ls

# Connect a container
mocker network connect mynet myapp

# Disconnect
mocker network disconnect mynet myapp
# Attach a container at run time
mocker run --network mynet nginx

# Inspect
mocker network inspect mynet
Expand Down Expand Up @@ -347,7 +344,7 @@ mocker/
| Concern | Approach |
|---------|----------|
| Thread safety | All engines/managers are `actor` types |
| Persistence | JSON files in `~/.mocker/{containers,images,networks,volumes}/` |
| Persistence | JSON files in `~/.mocker/{containers,images,volumes}/`; networks live in the container runtime itself |
| CLI parsing | `swift-argument-parser` with `AsyncParsableCommand` |
| YAML parsing | `Yams` library |
| Compose naming | Docker v2 convention: `projectName-serviceName-1` (hyphen separator) |
Expand All @@ -361,7 +358,6 @@ Mocker stores all state in `~/.mocker/`:
~/.mocker/
├── containers/ # Container metadata (one JSON file per container)
├── images/ # Image metadata
├── networks/ # Network metadata
└── volumes/ # Volume metadata + actual data directories
└── pgdata/
└── _data/
Expand Down
40 changes: 37 additions & 3 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ enum ComposeFormatter {
case .containerRemoved(let name): ("Container \(name)", "Removed")
case .networkRemoved(let name): ("Network \(name)", "Removed")
case .volumeRemoved(let name): ("Volume \(name)", "Removed")
case .imageRemoved(let name): ("Image \(name)", "Removed")
}
}
}
Expand Down Expand Up @@ -315,12 +316,18 @@ struct ComposeDown: AsyncParsableCommand {
func run() async throws {
let (composeFile, project, projectDir) = try options.loadCompose()
let config = MockerConfig()
let removeImages = try Self.parseRemoveImages(rmi)

if options.dryRun {
var targets = composeFile.services.keys.sorted().map { "\(project)-\($0)-1" }
if volumes {
targets += ComposeOrchestrator.volumesToRemove(composeFile: composeFile, projectName: project)
}
if let removeImages {
targets += ComposeOrchestrator.imagesToRemove(
composeFile: composeFile, projectName: project, mode: removeImages
)
}
ComposeDryRun.report("down", targets: targets)
return
}
Expand All @@ -339,10 +346,24 @@ struct ComposeDown: AsyncParsableCommand {
volumeManager: volumeManager
)

let events = try await orchestrator.down(composeFile: composeFile, removeVolumes: volumes)
let events = try await orchestrator.down(
composeFile: composeFile, removeVolumes: volumes,
removeImages: removeImages, timeout: timeout
)
let totalResources = events.count
ComposeFormatter.printEvents(events, total: totalResources)
}

/// Validate `--rmi`. An unrecognized value used to be accepted and ignored, which
/// looks identical to a teardown that removed the images.
static func parseRemoveImages(_ value: String?) throws -> ComposeImageRemoval? {
guard let value else { return nil }
guard let mode = ComposeImageRemoval(rawValue: value) else {
let valid = ComposeImageRemoval.allCases.map(\.rawValue).joined(separator: "|")
throw MockerError.operationFailed("invalid --rmi value: \(value) (expected \(valid))")
}
return mode
}
}

struct ComposePS: AsyncParsableCommand {
Expand Down Expand Up @@ -1084,8 +1105,11 @@ struct ComposeStop: AsyncParsableCommand {
: containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } }

for c in targets {
_ = try? await engine.stop(c.id)
print("Container \(c.name) Stopped")
if (try? await engine.stop(c.id, timeout: timeout)) != nil {
print("Container \(c.name) Stopped")
} else {
FileHandle.standardError.write(Data("WARNING: could not stop \(c.name)\n".utf8))
}
}
}

Expand Down Expand Up @@ -1179,6 +1203,16 @@ struct ComposeRm: AsyncParsableCommand {
let config = MockerConfig()
try composeFile.validateServiceNames(services)

// Upstream removes the anonymous volumes attached to the removed containers.
// mocker never creates one — an anonymous mount is dropped before it reaches the
// runtime — so there is nothing to remove. Say so, but still remove the containers
// the user asked for rather than failing the whole command over the flag.
if volumes {
FileHandle.standardError.write(Data(
("WARNING: --volumes has no effect — mocker does not create anonymous volumes, "
+ "so there is nothing to remove\n").utf8))
}

if options.dryRun {
ComposeDryRun.report("rm", targets: ComposeStop.dryRunTargets(composeFile, project, services))
return
Expand Down
16 changes: 11 additions & 5 deletions Sources/Mocker/Commands/Network.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ struct NetworkList: AsyncParsableCommand {
func run() async throws {
let config = MockerConfig()
let manager = try NetworkManager(config: config)
var networks = await manager.list()
var networks = try await manager.list()

for f in filter {
let parts = f.split(separator: "=", maxSplits: 1)
Expand Down Expand Up @@ -253,12 +253,18 @@ struct NetworkPrune: AsyncParsableCommand {

let config = MockerConfig()
let manager = try NetworkManager(config: config)
let networks = await manager.list()
let networks = try await manager.list()

var removed = 0
for n in networks where n.name != "bridge" && n.name != "host" && n.name != "none" {
_ = try? await manager.remove(n.name)
removed += 1
// `list()` now returns the runtime's real networks, which include its built-in
// one — pruning that would break every container that does not name a network.
for n in networks where !NetworkManager.isBuiltIn(n)
&& n.name != "bridge" && n.name != "host" && n.name != "none" {
// Count what was actually removed: a network with containers attached stays,
// and reporting it as deleted sends people looking for a network that is still there.
if (try? await manager.remove(n.name)) != nil {
removed += 1
}
}
print("Deleted \(removed) networks")
}
Expand Down
47 changes: 44 additions & 3 deletions Sources/MockerKit/Compose/ComposeFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,15 @@ public struct ComposeFile: Sendable {
var networks: [String: ComposeNetwork] = [:]
for (name, value) in dict {
let netDict = value as? [String: Any] ?? [:]
// `external` is either a bool or, in the legacy long form, a mapping
// (`external: {name: shared}`) — both mean "not owned by this project".
let externalDict = netDict["external"] as? [String: Any]
let external = netDict["external"] as? Bool ?? (netDict["external"] != nil)
networks[name] = ComposeNetwork(
name: name,
driver: netDict["driver"] as? String ?? "bridge"
driver: netDict["driver"] as? String ?? "bridge",
external: external,
customName: netDict["name"] as? String ?? externalDict?["name"] as? String
)
}
return networks
Expand Down Expand Up @@ -407,6 +413,17 @@ public struct ComposeFile: Sendable {
return ComposeFile(services: filteredServices, networks: networks, volumes: volumes, name: self.name)
}

/// Throw when a service joins a network the file never declares — the container
/// would otherwise be started against a network nothing creates.
public func validateNetworkReferences() throws {
for service in services.values.sorted(by: { $0.name < $1.name }) {
for network in service.networks where networks[network] == nil {
throw MockerError.composeParseError(
"service \(service.name) refers to undefined network \(network)")
}
}
}

/// Throw `no such service: <name>` for any requested name absent from the project,
/// matching `docker compose`'s behaviour of erroring instead of silently doing nothing.
/// Only literal, user-typed names are checked — transitive dependencies are resolved
Expand Down Expand Up @@ -516,7 +533,16 @@ public struct ComposeService: Sendable {
let environment = parseEnvironment(dict["environment"])
let ports = (dict["ports"] as? [Any])?.compactMap { "\($0)" } ?? []
let volumes = (dict["volumes"] as? [Any])?.compactMap { "\($0)" } ?? []
let networks = (dict["networks"] as? [Any])?.compactMap { "\($0)" } ?? []
// Both spellings are valid: a list of names, or a mapping whose keys are the
// names and whose values carry per-network options such as `aliases`.
let networks: [String]
if let list = dict["networks"] as? [Any] {
networks = list.compactMap { "\($0)" }
} else if let mapping = dict["networks"] as? [String: Any] {
networks = mapping.keys.sorted()
} else {
networks = []
}
let dependsOn = parseDependsOn(dict["depends_on"])
let command = parseCommand(dict["command"])
let labels = (dict["labels"] as? [String: String]) ?? [:]
Expand Down Expand Up @@ -844,12 +870,27 @@ public enum ComposeImageSource: Sendable, Equatable {

/// Network definition in a compose file.
public struct ComposeNetwork: Sendable {
/// The key this network is declared under in the compose file.
public var name: String
public var driver: String
/// `external: true` — the network lives outside the project lifecycle: it is
/// neither created nor removed by the project, only joined.
public var external: Bool
/// Explicit `name:` override — used verbatim, without the project prefix.
public var customName: String?

public init(name: String, driver: String = "bridge") {
public init(name: String, driver: String = "bridge", external: Bool = false, customName: String? = nil) {
self.name = name
self.driver = driver
self.external = external
self.customName = customName
}

/// The network's real name in the runtime: an explicit `name:` wins, an external
/// network keeps its declared key, and everything else is project-namespaced.
public func runtimeName(projectName: String) -> String {
if let customName { return customName }
return external ? name : "\(projectName)-\(name)"
}
}

Expand Down
Loading
Loading