From 77fa0dae91525c4a5e82ca85e8355141982584bc Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 22:17:31 +0300 Subject: [PATCH 1/4] feat(image): remove images by ID or digest prefix `mocker images -q | xargs mocker rmi` failed: references resolved only as names, so the IDs that command prints matched nothing. Resolution now falls back to matching stored digests, accepting the full `sha256:...`, the truncated form `images -q` prints, and a bare hex prefix, comparing the hex on both sides. The fallback runs only after every name candidate has missed, so a repository that happens to look like hex still wins as a name. A prefix matching more than one distinct image is an error naming the count rather than a deletion of whichever came first. Every command sharing the resolver gains the same. --- Sources/MockerKit/Image/ImageManager.swift | 32 +++++++++++++++++++ .../MockerKitTests/ImageResolutionTests.swift | 25 +++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/Sources/MockerKit/Image/ImageManager.swift b/Sources/MockerKit/Image/ImageManager.swift index 102f5d6..cb77717 100644 --- a/Sources/MockerKit/Image/ImageManager.swift +++ b/Sources/MockerKit/Image/ImageManager.swift @@ -410,9 +410,41 @@ 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) { + // `docker rmi` accepts the ID with or without the algorithm prefix, so compare + // the hex on both sides rather than the raw strings. + let wanted = Self.digestBody(reference) + let matches = ((try? await imageStore.list()) ?? []) + .filter { Self.digestBody($0.digest).hasPrefix(wanted) } + // Distinct images can share a digest through several tags; that is one image. + let distinct = Set(matches.map(\.digest)) + if distinct.count > 1 { + throw MockerError.operationFailed( + "image reference \(reference) is ambiguous: it matches \(distinct.count) images") + } + if let hit = matches.first { + 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) + } + + /// 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. diff --git a/Tests/MockerKitTests/ImageResolutionTests.swift b/Tests/MockerKitTests/ImageResolutionTests.swift index f05d39e..27455df 100644 --- a/Tests/MockerKitTests/ImageResolutionTests.swift +++ b/Tests/MockerKitTests/ImageResolutionTests.swift @@ -66,4 +66,29 @@ 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"))) + } } From 80cfcebdc7816cb8b542a6625e5468b245417297 Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 22:17:31 +0300 Subject: [PATCH 2/4] fix(compose): expose the resolved project name to interpolation A file interpolating `${COMPOSE_PROJECT_NAME}` got an empty string: the name was resolved after parsing. It is resolved first now and passed in as a variable that outranks `.env` and the environment, since it is the name the project actually runs under. The file's own `name:` is one of the sources, so it cannot be known before parsing; the file is parsed again when the resolved name turns out to differ, which keeps what the file reads equal to what its resources are called. Values the caller did not resolve keep their previous precedence, `.env` below the shell. --- Sources/Mocker/Commands/Compose.swift | 33 +++++++--- Sources/MockerKit/Compose/ComposeFile.swift | 35 ++++++++-- .../ComposeProjectNameTests.swift | 66 +++++++++++++++++++ 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index a86be39..a1c9ab2 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -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) } diff --git a/Sources/MockerKit/Compose/ComposeFile.swift b/Sources/MockerKit/Compose/ComposeFile.swift index 09be117..9685e4d 100644 --- a/Sources/MockerKit/Compose/ComposeFile.swift +++ b/Sources/MockerKit/Compose/ComposeFile.swift @@ -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) @@ -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 ) @@ -85,7 +92,11 @@ 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") } @@ -93,6 +104,7 @@ public struct ComposeFile: Sendable { content: content, fileDir: projectDir, envFiles: [projectDir.appendingPathComponent(".env").path], + variables: variables, visited: [], depth: 0 ) @@ -111,6 +123,7 @@ public struct ComposeFile: Sendable { content: String, fileDir: URL, envFiles: [String], + variables: [String: String] = [:], visited: Set, depth: Int ) throws -> ComposeFile { @@ -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") } @@ -159,6 +173,7 @@ public struct ComposeFile: Sendable { content: body, fileDir: url.deletingLastPathComponent(), envFiles: entryEnvFiles, + variables: variables, visited: visited.union([canonical]), depth: depth + 1 ) @@ -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} diff --git a/Tests/MockerKitTests/ComposeProjectNameTests.swift b/Tests/MockerKitTests/ComposeProjectNameTests.swift index bb8beb9..f861572 100644 --- a/Tests/MockerKitTests/ComposeProjectNameTests.swift +++ b/Tests/MockerKitTests/ComposeProjectNameTests.swift @@ -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) + } } From fa5942a9a260042708b5c73dbac2baafd6c2f9bc Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 22:17:31 +0300 Subject: [PATCH 3/4] docs: bring the Chinese docs up to date with v0.9.0 They still advertised `network connect`/`disconnect` and described the project name as the directory name. Both now match the code: the five-step precedence, `external:`/`name:` for networks and volumes, the project-scoped default network, and one network per container. Also documents that `rmi` accepts an image ID, and that a compose file can read the resolved project name back. --- COMMANDS.md | 4 +++- README.zh-CN.md | 9 +++------ docs/compose.md | 3 ++- docs/zh-CN/compose.md | 22 +++++++++++++++++++--- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index 979f674..ecef820 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -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...] diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f13f75..c38620b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 应用 *(即将推出)* @@ -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 diff --git a/docs/compose.md b/docs/compose.md index 2afd924..313762a 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -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 diff --git a/docs/zh-CN/compose.md b/docs/zh-CN/compose.md index b220c9d..ee486fd 100644 --- a/docs/zh-CN/compose.md +++ b/docs/zh-CN/compose.md @@ -32,13 +32,20 @@ services: networks: <网络名>: - driver: bridge # 可选 + driver: bridge # 可选 + external: true # 已在别处创建:只接入,不创建也不删除 + name: <实际名称> # 直接使用该名称,不加项目名前缀 volumes: <卷名>: - driver: local # 可选 + driver: local # 可选 + external: true # 同上:不由本项目创建或删除 + name: <实际名称> ``` +未声明 `networks:` 的服务会接入项目专属的 `<项目名>-default` 网络,因此不同项目之间 +彼此隔离。运行时只能为容器接入一个网络:服务列出多个网络时会接入第一个并给出提示。 + ## 服务定义 每个服务定义一种容器类型,支持以下字段: @@ -296,7 +303,16 @@ mocker compose down - 项目 `myapp`,网络 `frontend` → `myapp-frontend` - 项目 `myapp`,卷 `pgdata` → `myapp-pgdata` -项目名默认为 Compose 文件所在目录名: +项目名按以下顺序解析,取第一个匹配项: + +1. `-p` / `--project-name` +2. 环境变量 `COMPOSE_PROJECT_NAME` +3. 项目目录下 `.env` 中的 `COMPOSE_PROJECT_NAME` +4. Compose 文件顶层的 `name:` +5. 项目目录名 + +解析结果会转为小写,`[a-z0-9_-]` 之外的字符会替换为连字符。`mocker compose config` +会打印解析后的项目名,文件中也可以用 `${COMPOSE_PROJECT_NAME}` 读回该值。 ```bash # 文件位于 /home/user/myapp/docker-compose.yml From f3c834c0abb6833d32ab5fcc16fb5a8d3c8f9714 Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 22:20:34 +0300 Subject: [PATCH 4/4] test(image): pin the digest matching and ambiguity rules The matching itself was only exercised through the CLI. Extracting it as a pure helper puts the cases that matter under test: a bare hex prefix (the form that was broken), a prefix shared by two images, and several tags of one image counting as one match rather than an ambiguity. --- Sources/MockerKit/Image/ImageManager.swift | 24 ++++++++----- .../MockerKitTests/ImageResolutionTests.swift | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/Sources/MockerKit/Image/ImageManager.swift b/Sources/MockerKit/Image/ImageManager.swift index cb77717..822ffae 100644 --- a/Sources/MockerKit/Image/ImageManager.swift +++ b/Sources/MockerKit/Image/ImageManager.swift @@ -414,18 +414,14 @@ public actor ImageManager { // (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) { - // `docker rmi` accepts the ID with or without the algorithm prefix, so compare - // the hex on both sides rather than the raw strings. - let wanted = Self.digestBody(reference) - let matches = ((try? await imageStore.list()) ?? []) - .filter { Self.digestBody($0.digest).hasPrefix(wanted) } - // Distinct images can share a digest through several tags; that is one image. - let distinct = Set(matches.map(\.digest)) + 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 hit = matches.first { + if let digest = distinct.first, + let hit = stored.first(where: { $0.digest == digest }) { return hit } } @@ -440,6 +436,18 @@ public actor ImageManager { 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 = [] + 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 diff --git a/Tests/MockerKitTests/ImageResolutionTests.swift b/Tests/MockerKitTests/ImageResolutionTests.swift index 27455df..65a25e2 100644 --- a/Tests/MockerKitTests/ImageResolutionTests.swift +++ b/Tests/MockerKitTests/ImageResolutionTests.swift @@ -91,4 +91,40 @@ struct ImageResolutionTests { #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) + } }