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/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/Sources/MockerKit/Image/ImageManager.swift b/Sources/MockerKit/Image/ImageManager.swift index 102f5d6..822ffae 100644 --- a/Sources/MockerKit/Image/ImageManager.swift +++ b/Sources/MockerKit/Image/ImageManager.swift @@ -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 = [] + 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. 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) + } } diff --git a/Tests/MockerKitTests/ImageResolutionTests.swift b/Tests/MockerKitTests/ImageResolutionTests.swift index f05d39e..65a25e2 100644 --- a/Tests/MockerKitTests/ImageResolutionTests.swift +++ b/Tests/MockerKitTests/ImageResolutionTests.swift @@ -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) + } } 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