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 COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,9 @@ mocker tag SOURCE TARGET

### `mocker rmi`

Remove one or more images.
Remove one or more images, by reference or by image ID (a full or truncated digest, as
printed by `mocker images -q`). An ID prefix that matches more than one image is an error
rather than a guess.

```
mocker rmi [OPTIONS] IMAGE [IMAGE...]
Expand Down
9 changes: 3 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ mocker exec -it my-app sh
## 功能特性

- **Docker CLI 兼容** — `run`、`ps`、`stop`、`rm`、`exec`、`logs`、`build`、`pull`、`push`、`images`、`tag`、`rmi`、`inspect`、`stats`
- **网络管理** — `network create/ls/rm/inspect/connect/disconnect`
- **网络管理** — `network create/ls/rm/inspect`,以及 Compose 的 `networks:`
- **卷管理** — `volume create/ls/rm/inspect`
- **Docker Compose v2** — `compose up/down/ps/logs/restart`,支持依赖顺序启动
- **MenuBar GUI** — 原生 SwiftUI 应用 *(即将推出)*
Expand Down Expand Up @@ -176,11 +176,8 @@ mocker network create mynet
# 列出网络
mocker network ls

# 将容器连接到网络
mocker network connect mynet myapp

# 断开连接
mocker network disconnect mynet myapp
# 启动容器时接入网络
mocker run --network mynet nginx

# 检查网络详情
mocker network inspect mynet
Expand Down
33 changes: 25 additions & 8 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,38 @@ struct ComposeOptions: ParsableArguments {
paths = files
}

let loaded = try paths.map { entry -> ComposeFile in
if entry == "-" {
let data = try FileHandle.standardInput.readToEnd() ?? Data()
let content = String(decoding: data, as: UTF8.self)
return try ComposeFile.load(content: content, projectDir: projectDir)
// The name must be known before parsing so the file can interpolate
// `${COMPOSE_PROJECT_NAME}`, but the file's own `name:` is one of its sources.
// Parse with what is known, and if the file named the project something else,
// parse again so the value it interpolates is the one actually used.
func parse(withProjectName name: String) throws -> ComposeFile {
let variables = ["COMPOSE_PROJECT_NAME": name]
let loaded = try paths.map { entry -> ComposeFile in
if entry == "-" {
let data = try FileHandle.standardInput.readToEnd() ?? Data()
let content = String(decoding: data, as: UTF8.self)
return try ComposeFile.load(content: content, projectDir: projectDir, variables: variables)
}
return try ComposeFile.load(from: entry, projectDir: projectDir, variables: variables)
}
return try ComposeFile.load(from: entry, projectDir: projectDir)
return ComposeFile.merge(loaded)
}
let composeFile = ComposeFile.merge(loaded)

let project = ComposeFile.resolveProjectName(
var project = ComposeFile.resolveProjectName(explicit: projectName, projectDir: projectDir)
var composeFile = try parse(withProjectName: project)

let resolved = ComposeFile.resolveProjectName(
explicit: projectName,
composeFileName: composeFile.name,
projectDir: projectDir
)
if resolved != project {
project = resolved
// stdin is consumed by the first read, so a piped file keeps the first parse.
if !paths.contains("-") {
composeFile = try parse(withProjectName: project)
}
}

return (composeFile, project, projectDir)
}
Expand Down
35 changes: 29 additions & 6 deletions Sources/MockerKit/Compose/ComposeFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ public struct ComposeFile: Sendable {

/// Parse a compose file at 'path'. '.env' auto-discovery uses 'projectDir/.env'.
/// Top-level `include:` entries are resolved relative to the file's own directory.
public static func load(from path: String, projectDir: URL) throws -> ComposeFile {
/// - Parameter variables: values the caller resolved before parsing, exposed to
/// `${VAR}` interpolation. Compose uses this for `COMPOSE_PROJECT_NAME`.
public static func load(
from path: String,
projectDir: URL,
variables: [String: String] = [:]
) throws -> ComposeFile {
let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else {
throw MockerError.composeFileNotFound(path)
Expand All @@ -76,6 +82,7 @@ public struct ComposeFile: Sendable {
content: content,
fileDir: url.standardizedFileURL.deletingLastPathComponent(),
envFiles: [projectDir.appendingPathComponent(".env").path],
variables: variables,
visited: [url.resolvingSymlinksInPath().path],
depth: 0
)
Expand All @@ -85,14 +92,19 @@ public struct ComposeFile: Sendable {
/// '.env' auto-discovery uses 'projectDir/.env'. Throws if content is empty.
/// `include:` paths resolve relative to `projectDir`, which is all the location
/// context a piped file has.
public static func load(content: String, projectDir: URL) throws -> ComposeFile {
public static func load(
content: String,
projectDir: URL,
variables: [String: String] = [:]
) throws -> ComposeFile {
if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw MockerError.composeParseError("compose file content is empty")
}
return try parseFile(
content: content,
fileDir: projectDir,
envFiles: [projectDir.appendingPathComponent(".env").path],
variables: variables,
visited: [],
depth: 0
)
Expand All @@ -111,6 +123,7 @@ public struct ComposeFile: Sendable {
content: String,
fileDir: URL,
envFiles: [String],
variables: [String: String] = [:],
visited: Set<String>,
depth: Int
) throws -> ComposeFile {
Expand All @@ -123,8 +136,9 @@ public struct ComposeFile: Sendable {
dotEnv.merge(loadDotEnv(from: file)) { _, new in new }
}


// Substitute ${VAR:-default} and $VAR patterns before YAML parsing
let substituted = substituteVariables(in: content, dotEnv: dotEnv)
let substituted = substituteVariables(in: content, dotEnv: dotEnv, resolved: variables)
guard let dict = try Yams.load(yaml: substituted) as? [String: Any] else {
throw MockerError.composeParseError("Invalid YAML structure")
}
Expand Down Expand Up @@ -159,6 +173,7 @@ public struct ComposeFile: Sendable {
content: body,
fileDir: url.deletingLastPathComponent(),
envFiles: entryEnvFiles,
variables: variables,
visited: visited.union([canonical]),
depth: depth + 1
)
Expand Down Expand Up @@ -283,10 +298,18 @@ public struct ComposeFile: Sendable {
}

/// Substitute ${VAR}, ${VAR:-default}, and $VAR patterns using env + dotEnv.
private static func substituteVariables(in yaml: String, dotEnv: [String: String]) -> String {
/// - Parameter resolved: values the caller worked out itself (the project name), which
/// outrank both `.env` and the environment because they are the effective values.
private static func substituteVariables(
in yaml: String,
dotEnv: [String: String],
resolved: [String: String] = [:]
) -> String {
let processEnv = ProcessInfo.processInfo.environment
// dotEnv takes lower priority than actual environment
let env = dotEnv.merging(processEnv) { _, new in new }
// .env is the weakest, then the shell, then whatever the caller resolved.
let env = dotEnv
.merging(processEnv) { _, new in new }
.merging(resolved) { _, new in new }

var result = yaml
// Match ${VAR:-default}, ${VAR-default}, ${VAR}
Expand Down
40 changes: 40 additions & 0 deletions Sources/MockerKit/Image/ImageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,9 +410,49 @@ public actor ImageManager {
return hit
}
}
// Nothing answered to the reference as a name. `mocker images -q` prints digests
// (truncated by default), and `mocker images -q | xargs mocker rmi` is the usual
// cleanup, so fall back to matching the stored digests by prefix.
if Self.looksLikeDigest(reference) {
let stored = (try? await imageStore.list()) ?? []
let distinct = Self.matchingDigests(reference, in: stored.map(\.digest))
if distinct.count > 1 {
throw MockerError.operationFailed(
"image reference \(reference) is ambiguous: it matches \(distinct.count) images")
}
if let digest = distinct.first,
let hit = stored.first(where: { $0.digest == digest }) {
return hit
}
}
throw MockerError.imageNotFound(reference)
}

/// Whether an argument should be tried as a digest: a full `sha256:...`, the truncated
/// form `mocker images -q` prints, or a bare hex prefix as `docker rmi` accepts.
static func looksLikeDigest(_ reference: String) -> Bool {
let body = digestBody(reference)
guard body.count >= 4 else { return false }
return body.allSatisfy(\.isHexDigit)
}

/// Digests matching `reference` as a prefix, deduplicated: several tags of one image
/// share a digest and are one image, not an ambiguity. Pure, so the matching and the
/// ambiguity rule can be tested without a store.
static func matchingDigests(_ reference: String, in digests: [String]) -> [String] {
// `docker rmi` takes the ID with or without the algorithm prefix, so compare hex.
let wanted = digestBody(reference).lowercased()
var seen: Set<String> = []
return digests
.filter { digestBody($0).lowercased().hasPrefix(wanted) }
.filter { seen.insert($0).inserted }
}

/// The hex part of a digest, with any `sha256:` prefix removed.
static func digestBody(_ reference: String) -> String {
reference.hasPrefix("sha256:") ? String(reference.dropFirst("sha256:".count)) : reference
}

/// Store keys to try, in order: exactly what the user typed, then the same with an
/// implied `:latest`, then the normalized registry-qualified form. Pure, so the
/// ordering that keeps `rmi` from deleting the wrong image is directly testable.
Expand Down
66 changes: 66 additions & 0 deletions Tests/MockerKitTests/ComposeProjectNameTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,70 @@ struct ComposeProjectNameTests {
#expect(ComposeFile.merge([base, overlay]).name == "second")
#expect(ComposeFile.merge([base, noName]).name == "first")
}

@Test("The resolved project name is available to ${COMPOSE_PROJECT_NAME}")
func projectNameIsInterpolated() throws {
let root = try ComposeTestHelpers.makeProjectDir([
.file("compose.yaml", """
services:
web:
image: nginx
environment:
- PROJECT=${COMPOSE_PROJECT_NAME}
"""),
])

let compose = try ComposeFile.load(
from: root.appendingPathComponent("compose.yaml").path,
projectDir: root,
variables: ["COMPOSE_PROJECT_NAME": "resolved-name"]
)

// Without this the variable expanded to an empty string.
#expect(compose.services["web"]?.environment["PROJECT"] == "resolved-name")
}

@Test("A caller-resolved project name wins over the environment")
func resolvedNameBeatsEnvironment() throws {
let root = try ComposeTestHelpers.makeProjectDir([
.file(".env", "COMPOSE_PROJECT_NAME=from-dotenv\n"),
.file("compose.yaml", """
services:
web:
image: nginx
environment:
- PROJECT=${COMPOSE_PROJECT_NAME}
"""),
])

let compose = try ComposeFile.load(
from: root.appendingPathComponent("compose.yaml").path,
projectDir: root,
variables: ["COMPOSE_PROJECT_NAME": "from-flag"]
)

#expect(compose.services["web"]?.environment["PROJECT"] == "from-flag")
}

@Test("The environment still outranks .env for ordinary variables")
func environmentStillBeatsDotEnv() throws {
let root = try ComposeTestHelpers.makeProjectDir([
.file(".env", "COMPOSE_PROJECT_NAME=from-dotenv\n"),
.file("compose.yaml", """
services:
web:
image: nginx
environment:
- PROJECT=${COMPOSE_PROJECT_NAME}
"""),
])

// No caller-resolved value: the previous precedence (.env below the shell) holds.
let compose = try ComposeFile.load(
from: root.appendingPathComponent("compose.yaml").path, projectDir: root
)

let expected = ProcessInfo.processInfo.environment["COMPOSE_PROJECT_NAME"] ?? "from-dotenv"
#expect(compose.services["web"]?.environment["PROJECT"] == expected)
}
}
61 changes: 61 additions & 0 deletions Tests/MockerKitTests/ImageResolutionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,65 @@ struct ImageResolutionTests {
#expect(known.sizeString.contains("MB"))
#expect(known.createdAgo != "N/A")
}

@Test("An argument that looks like a digest is recognized", arguments: [
"sha256:d9e853e87e55", "d9e853e87e55", "sha256:d9e8", "abcd",
])
func digestFormsAreRecognized(reference: String) {
// `mocker images -q` prints a truncated digest, and `images -q | xargs rmi` is
// the usual cleanup, so these must be tried against stored digests.
#expect(ImageManager.looksLikeDigest(reference))
}

@Test("Ordinary references are not mistaken for digests", arguments: [
"alpine", "alpine:3.20", "docker.io/library/nginx:1.25", "sha256:", "abc", "",
])
func namesAreNotDigests(reference: String) {
#expect(!ImageManager.looksLikeDigest(reference))
}

@Test("A digest matches with or without the algorithm prefix")
func digestBodyIgnoresAlgorithmPrefix() {
// `docker rmi` takes the bare hex, `images -q` prints the prefixed form; both must
// match the same stored digest.
#expect(ImageManager.digestBody("sha256:d9e853") == "d9e853")
#expect(ImageManager.digestBody("d9e853") == "d9e853")
#expect(ImageManager.digestBody("sha256:d9e853").hasPrefix(ImageManager.digestBody("d9e8")))
}

private static let digests = [
"sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc",
"sha256:d9e8ff0000000000000000000000000000000000000000000000000000000000",
"sha256:edf820e05c3374485390e7fe3669f1b6b429eda502a6d174a456647fb9ed26fe",
]

@Test("A full digest matches exactly one image")
func fullDigestMatchesOne() {
#expect(ImageManager.matchingDigests(Self.digests[0], in: Self.digests) == [Self.digests[0]])
}

@Test("A bare hex prefix matches, with or without the algorithm prefix")
func bareHexPrefixMatches() {
// `mocker rmi d9e853e87e55` — the form `docker rmi` takes — used to match nothing.
#expect(ImageManager.matchingDigests("d9e853e87e55", in: Self.digests) == [Self.digests[0]])
#expect(ImageManager.matchingDigests("sha256:d9e853e87e55", in: Self.digests) == [Self.digests[0]])
}

@Test("A prefix shared by two images is reported as ambiguous, never picked")
func ambiguousPrefix() {
// `rmi` deletes; guessing between two images is the one thing it must not do.
#expect(ImageManager.matchingDigests("d9e8", in: Self.digests).count == 2)
}

@Test("Several tags of one image are one match, not an ambiguity")
func repeatedDigestIsOneImage() {
let sameImageTwice = [Self.digests[0], Self.digests[0]]

#expect(ImageManager.matchingDigests("d9e853", in: sameImageTwice) == [Self.digests[0]])
}

@Test("A prefix matching nothing yields nothing")
func unknownPrefixMatchesNothing() {
#expect(ImageManager.matchingDigests("ffff", in: Self.digests).isEmpty)
}
}
3 changes: 2 additions & 1 deletion docs/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ The project name is resolved in this order, first match wins:
5. the project directory's name

The resolved name is lowercased, and any character outside `[a-z0-9_-]` becomes a dash.
`mocker compose config` prints the name it resolved.
`mocker compose config` prints the name it resolved, and the file can read it back as
`${COMPOSE_PROJECT_NAME}`.

```bash
mocker compose -p staging up -d
Expand Down
Loading
Loading