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: 1 addition & 3 deletions Sources/Mocker/Commands/ContainerInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ struct ContainerInspect: AsyncParsableCommand {
try config.ensureDirectories()
let engine = try ContainerEngine(config: config)
let results = try await inspectContainers(targets: containers, engine: engine)
for container in results {
try TableFormatter.printJSONArray(container)
}
try TableFormatter.printJSONArray(results, escapeSlashes: false)
}
}
6 changes: 2 additions & 4 deletions Sources/Mocker/Commands/Inspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,12 @@ struct Inspect: AsyncParsableCommand {

case .container:
let results = try await inspectContainers(targets: targets, engine: engine)
for container in results {
try TableFormatter.printJSONArray(container)
}
try TableFormatter.printJSONArray(results, escapeSlashes: false)

case .auto:
for target in targets {
if let container = try? await engine.inspect(target) {
try TableFormatter.printJSONArray(container)
try TableFormatter.printJSONArray(mapToContainerInspect(container), escapeSlashes: false)
} else {
do {
let image = try await imageManager.inspect(target, platform: platform)
Expand Down
208 changes: 208 additions & 0 deletions Sources/MockerKit/Models/ContainerInspect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import Foundation

// MARK: - Docker-Compatible ContainerInspect DTOs

/// Docker-compatible `container inspect` output (mirrors `docker container inspect`).
/// Serializes with Docker PascalCase JSON keys; optional fields are omitted when absent.
///
/// Populated from the data mocker tracks in `ContainerInfo`. Fields that Apple's
/// Containerization runtime does not surface (HostConfig, Mounts, RestartCount, env,
/// exit code, start/finish timestamps, …) are omitted rather than fabricated.
public struct ContainerInspect: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case id = "Id"
case created = "Created"
case name = "Name"
case image = "Image"
case state = "State"
case config = "Config"
case networkSettings = "NetworkSettings"
}

public let id: String
/// RFC3339 timestamp string — Docker emits a string, not a number.
public let created: String
/// Docker prefixes the container name with "/".
public let name: String
public let image: String
public let state: ContainerInspectState
public let config: ContainerInspectConfig
public let networkSettings: ContainerInspectNetworkSettings

public init(
id: String,
created: String,
name: String,
image: String,
state: ContainerInspectState,
config: ContainerInspectConfig,
networkSettings: ContainerInspectNetworkSettings
) {
self.id = id
self.created = created
self.name = name
self.image = image
self.state = state
self.config = config
self.networkSettings = networkSettings
}
}

/// Docker-compatible `State` sub-object.
public struct ContainerInspectState: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case status = "Status"
case running = "Running"
case paused = "Paused"
case restarting = "Restarting"
case oomKilled = "OOMKilled"
case dead = "Dead"
case pid = "Pid"
}

/// One of Docker's status values: created, running, paused, restarting, removing, exited, dead.
public let status: String
public let running: Bool
public let paused: Bool
public let restarting: Bool
public let oomKilled: Bool
public let dead: Bool
/// 0 when the container is not running (Docker parity).
public let pid: Int

public init(
status: String,
running: Bool,
paused: Bool,
restarting: Bool,
oomKilled: Bool,
dead: Bool,
pid: Int
) {
self.status = status
self.running = running
self.paused = paused
self.restarting = restarting
self.oomKilled = oomKilled
self.dead = dead
self.pid = pid
}
}

/// Docker-compatible `Config` sub-object. Optional fields are omitted when absent.
public struct ContainerInspectConfig: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case hostname = "Hostname"
case image = "Image"
case cmd = "Cmd"
case labels = "Labels"
}

public let hostname: String?
public let image: String
public let cmd: [String]?
public let labels: [String: String]?

public init(hostname: String? = nil, image: String, cmd: [String]? = nil, labels: [String: String]? = nil) {
self.hostname = hostname
self.image = image
self.cmd = cmd
self.labels = labels
}
}

/// Docker-compatible `NetworkSettings` sub-object.
public struct ContainerInspectNetworkSettings: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case ipAddress = "IPAddress"
case ports = "Ports"
}

public let ipAddress: String
/// Docker shape: `"80/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "8080" } ]`.
public let ports: [String: [ContainerInspectPortBinding]]?

public init(ipAddress: String, ports: [String: [ContainerInspectPortBinding]]? = nil) {
self.ipAddress = ipAddress
self.ports = ports
}
}

/// One host binding inside Docker's `NetworkSettings.Ports` map.
public struct ContainerInspectPortBinding: Codable, Sendable, Equatable {
enum CodingKeys: String, CodingKey {
case hostIp = "HostIp"
case hostPort = "HostPort"
}

public let hostIp: String
public let hostPort: String

public init(hostIp: String, hostPort: String) {
self.hostIp = hostIp
self.hostPort = hostPort
}
}

// MARK: - Pure Mapping Function

/// Maps mocker's internal `ContainerInfo` to a Docker-compatible `ContainerInspect`. Pure, no I/O.
public func mapToContainerInspect(_ info: ContainerInfo) -> ContainerInspect {
let state = ContainerInspectState(
status: dockerStatus(info.state),
running: info.state == .running,
paused: info.state == .paused,
restarting: false,
oomKilled: false,
dead: info.state == .dead,
pid: info.pid ?? 0
)

let config = ContainerInspectConfig(
image: info.image,
// ponytail: ContainerInfo keeps only the joined command, so argv quoting isn't
// preserved — split on whitespace for the common case. Thread real argv if needed.
cmd: info.command.isEmpty ? nil : info.command.split(separator: " ").map(String.init),
labels: info.labels.isEmpty ? nil : info.labels
)

let ports: [String: [ContainerInspectPortBinding]]? = info.ports.isEmpty ? nil : Dictionary(
info.ports.map { port in
(
"\(port.containerPort)/\(port.portProtocol.rawValue)",
[ContainerInspectPortBinding(hostIp: "0.0.0.0", hostPort: String(port.hostPort))]
)
},
uniquingKeysWith: { first, _ in first }
)

let networkSettings = ContainerInspectNetworkSettings(
ipAddress: info.networkAddress,
ports: ports
)

return ContainerInspect(
id: info.id,
created: rfc3339String(info.created),
name: "/\(info.name)",
image: info.image,
state: state,
config: config,
networkSettings: networkSettings
)
}

/// Maps mocker's `ContainerState` to a Docker `State.Status` string.
/// Docker has no `stopped` status; the closest equivalent is `exited`.
private func dockerStatus(_ state: ContainerState) -> String {
switch state {
case .stopped: return "exited"
default: return state.rawValue
}
}

private func rfc3339String(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
10 changes: 5 additions & 5 deletions Sources/MockerKit/Models/InspectOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,23 @@ public func inspectImages(
return results
}

/// Inspects each target container and returns the per-target `ContainerInfo` results.
/// Inspects each target container and returns Docker-compatible `ContainerInspect` results.
///
/// - Parameters:
/// - targets: Container names or IDs to inspect.
/// - engine: The `ContainerEngine` used to resolve each target.
/// - Returns: One `ContainerInfo` per target, preserving input order.
/// - Returns: One `ContainerInspect` per target, preserving input order.
/// - Throws: `MockerError.containerNotFound` when a target cannot be resolved.
public func inspectContainers(
targets: [String],
engine: ContainerEngine
) async throws -> [ContainerInfo] {
var results: [ContainerInfo] = []
) async throws -> [ContainerInspect] {
var results: [ContainerInspect] = []
for target in targets {
guard let container = try? await engine.inspect(target) else {
throw MockerError.containerNotFound(target)
}
results.append(container)
results.append(mapToContainerInspect(container))
}
return results
}
98 changes: 98 additions & 0 deletions Tests/MockerKitTests/ContainerInspectMappingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Foundation
import Testing

@testable import MockerKit

@Suite("ContainerInspect Mapping Tests")
struct ContainerInspectMappingTests {

/// A representative running container with a published port and labels.
private func sampleInfo() -> ContainerInfo {
ContainerInfo(
id: "abc123def456",
name: "web",
image: "nginx:latest",
state: .running,
status: "Up",
created: Date(timeIntervalSince1970: 1_700_000_000),
ports: [PortMapping(hostPort: 8080, containerPort: 80, portProtocol: .tcp)],
labels: ["com.example": "x"],
command: "nginx -g daemon off;",
pid: 4242,
networkAddress: "192.168.64.3"
)
}

@Test("Docker-shaped top-level fields")
func topLevelFields() {
let out = mapToContainerInspect(sampleInfo())
#expect(out.id == "abc123def456")
#expect(out.name == "/web") // Docker prefixes with "/"
#expect(out.image == "nginx:latest")
#expect(out.created.hasPrefix("2023-11-14T")) // RFC3339 string, not a number
}

@Test("State maps running container")
func stateRunning() {
let out = mapToContainerInspect(sampleInfo())
#expect(out.state.status == "running")
#expect(out.state.running == true)
#expect(out.state.paused == false)
#expect(out.state.dead == false)
#expect(out.state.pid == 4242)
}

@Test("stopped state maps to Docker 'exited'")
func stoppedMapsToExited() {
var info = sampleInfo()
info.state = .stopped
info.pid = nil
let out = mapToContainerInspect(info)
#expect(out.state.status == "exited") // Docker has no 'stopped'
#expect(out.state.running == false)
#expect(out.state.pid == 0) // 0 when not running
}

@Test("Ports map uses Docker '<port>/<proto>' shape")
func portsShape() {
let out = mapToContainerInspect(sampleInfo())
let binding = out.networkSettings.ports?["80/tcp"]?.first
#expect(binding == ContainerInspectPortBinding(hostIp: "0.0.0.0", hostPort: "8080"))
#expect(out.networkSettings.ipAddress == "192.168.64.3")
}

@Test("Config carries image, cmd and labels")
func configFields() {
let out = mapToContainerInspect(sampleInfo())
#expect(out.config.image == "nginx:latest")
#expect(out.config.cmd == ["nginx", "-g", "daemon", "off;"])
#expect(out.config.labels == ["com.example": "x"])
}

@Test("Empty ports/labels/command are omitted, not empty objects")
func absentFieldsOmitted() {
var info = sampleInfo()
info.ports = []
info.labels = [:]
info.command = ""
let out = mapToContainerInspect(info)
#expect(out.networkSettings.ports == nil)
#expect(out.config.labels == nil)
#expect(out.config.cmd == nil)
}

@Test("Serializes as a JSON array with Docker PascalCase keys")
func jsonShape() throws {
let out = mapToContainerInspect(sampleInfo())
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let json = String(data: try encoder.encode([out]), encoding: .utf8)!
#expect(json.hasPrefix("[") && json.hasSuffix("]")) // single array, Docker parity
#expect(json.contains("\"Id\":\"abc123def456\""))
#expect(json.contains("\"Name\":\"/web\""))
#expect(json.contains("\"State\":"))
#expect(json.contains("\"NetworkSettings\":"))
#expect(json.contains("\"80/tcp\""))
#expect(!json.contains("\"status\"")) // not the internal lowercase shape
}
}
2 changes: 1 addition & 1 deletion Tests/MockerKitTests/InspectOperationsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct InspectOperationsTests {
@Test("inspectContainers exists with documented signature")
func inspectContainersSignatureExists() async throws {
let engine = try ContainerEngine(config: MockerConfig())
let _: [ContainerInfo] = try await inspectContainers(
let _: [ContainerInspect] = try await inspectContainers(
targets: [],
engine: engine
)
Expand Down
Loading