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
6 changes: 3 additions & 3 deletions Sources/Mocker/Commands/ContainerInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ struct ContainerInspect: AsyncParsableCommand {
var containers: [String]

@Option(name: .shortAndLong, help: "Format output using a custom template")
var format: String? // --format accepted for Docker surface parity but not yet applied; Go-template formatting will be wired up in the follow-up (PR 2)
var format: String?

@Flag(name: .shortAndLong, help: "Display total file sizes")
var size = false // --size accepted for Docker compatibility but no-op; wire up when ContainerEngine surfaces size data (PR 2)
var size = false // --size accepted for Docker compatibility but no-op; wire up when ContainerEngine surfaces size data

func run() async throws {
let config = MockerConfig()
try config.ensureDirectories()
let engine = try ContainerEngine(config: config)
let results = try await inspectContainers(targets: containers, engine: engine)
try TableFormatter.printJSONArray(results, escapeSlashes: false)
try InspectFormat.emitArray(results, format: format)
}
}
2 changes: 1 addition & 1 deletion Sources/Mocker/Commands/ImageInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ struct ImageInspect: AsyncParsableCommand {
func run() async throws {
let manager = try ImageManager(config: MockerConfig())
let results = try await inspectImages(targets: images, platform: platform, manager: manager)
try TableFormatter.printJSONArray(results, escapeSlashes: false)
try InspectFormat.emitArray(results, format: format)
}
}
8 changes: 4 additions & 4 deletions Sources/Mocker/Commands/Inspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,20 @@ struct Inspect: AsyncParsableCommand {
switch Self.resolveKind(type: type) {
case .image:
let results = try await inspectImages(targets: targets, platform: platform, manager: imageManager)
try TableFormatter.printJSONArray(results, escapeSlashes: false)
try InspectFormat.emitArray(results, format: format)

case .container:
let results = try await inspectContainers(targets: targets, engine: engine)
try TableFormatter.printJSONArray(results, escapeSlashes: false)
try InspectFormat.emitArray(results, format: format)

case .auto:
for target in targets {
if let container = try? await engine.inspect(target) {
try TableFormatter.printJSONArray(mapToContainerInspect(container), escapeSlashes: false)
try InspectFormat.emitOne(mapToContainerInspect(container), format: format)
} else {
do {
let image = try await imageManager.inspect(target, platform: platform)
try TableFormatter.printJSONArray(image, escapeSlashes: false)
try InspectFormat.emitOne(image, format: format)
} catch {
if platform != nil {
throw error
Expand Down
137 changes: 137 additions & 0 deletions Sources/Mocker/Formatters/GoTemplate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import Foundation

/// Minimal Go `text/template` field-path evaluator for `inspect --format`.
///
/// Why this exists
/// ---------------
/// `mocker inspect` mirrors `docker inspect`, and real-world callers drive it with
/// `-f '{{.State.Running}}'` style templates (dev stands, CI health gates, e.g.
/// `docker inspect -f '{{.State.Running}}' c | grep -q true`). Docker evaluates the
/// template and prints a bare scalar. Until now `--format` was parsed but ignored and
/// the full JSON dumped, breaking every such caller.
///
/// Scope (intentionally small, and fails loudly outside it)
/// --------------------------------------------------------
/// We support the `{{ .Dotted.Path }}` field-access subset that container/image
/// inspect callers actually use — not the full Go template language (no `if`,
/// `range`, pipelines, or functions). A path is resolved against the record's
/// JSON object (which is already Docker-shaped: `Id`, `State.Running`, `Config.Image`,
/// `NetworkSettings.IPAddress`, …); an unknown or null path renders empty (Docker
/// prints `<no value>`, but empty is safe for the boolean/string checks these
/// templates feed and avoids a surprising literal in scripted output).
///
/// Any `{{ … }}` block that is NOT a supported field access (`{{if}}`, `{{range}}`,
/// `{{json .X}}`, `{{index …}}`, a bare `{{.}}`, …) is rejected with a thrown
/// `GoTemplateError`, not emitted as literal text. A silently-unevaluated template
/// would feed false data to the `grep`/`jq` pipelines these outputs drive, so the
/// unsupported case must surface as a nonzero exit rather than wrong output.

/// Raised when an `inspect --format` template uses a construct outside the supported
/// `{{ .Dotted.Path }}` field-access subset.
enum GoTemplateError: Error, LocalizedError, CustomStringConvertible {
case unsupportedAction(String)

var description: String {
switch self {
case .unsupportedAction(let block):
return "inspect --format: unsupported template action \(block); only field access "
+ "like {{ .State.Running }} is supported (no if/range/pipelines/functions)"
}
}

// ArgumentParser prints thrown errors via `localizedDescription`, which only honors
// LocalizedError — without this the helpful message above is replaced by a generic
// "The operation couldn't be completed" string (matches MockerError's pattern).
var errorDescription: String? { description }
}

enum GoTemplate {
/// Render `template` against one inspect record, supplied as its encoded JSON object.
///
/// - Parameters:
/// - template: A Go template string containing `{{ .Path }}` field tokens.
/// - object: The record's JSON, decoded to a dictionary (one element of the
/// inspect array).
/// - Returns: The template with every field token replaced by its resolved value.
/// - Throws: `GoTemplateError.unsupportedAction` if a `{{ … }}` block is not a
/// supported field access.
static func render(_ template: String, object: [String: Any]) throws -> String {
let full = NSRange(template.startIndex..., in: template)

// Single left-to-right pass: copy the literal text between `{{…}}` blocks verbatim
// and splice each field path's resolved value in at its ORIGINAL position.
// Substituting by range (never by a global string replacement on a mutated buffer)
// means a resolved value that itself contains `{{…}}` is emitted as literal output
// and can never re-trigger substitution, and repeated tokens cannot contaminate
// each other's output (the earlier `replacingOccurrences` approach rendered
// `{{.A}} {{.B}}` with `A == "{{.B}}"` as `valueB valueB`, not `{{.B}} valueB`).
var result = ""
var cursor = template.startIndex
for match in actionRegex.matches(in: template, range: full) {
guard let blockRange = Range(match.range, in: template) else { continue }
let block = String(template[blockRange])
let path = try fieldPath(of: block)
result += String(template[cursor..<blockRange.lowerBound])
result += resolveValue(path: path, in: object)
cursor = blockRange.upperBound
}
result += String(template[cursor...])
return result
}

/// Extract the dotted field path from one `{{…}}` block, or throw if the whole block is
/// not a supported `{{ .Path }}` field access.
private static func fieldPath(of block: String) throws -> [String] {
let whole = NSRange(block.startIndex..., in: block)
guard let match = fieldRegex.firstMatch(in: block, range: whole),
match.range.location == whole.location, match.range.length == whole.length,
let pathRange = Range(match.range(at: 1), in: block) else {
throw GoTemplateError.unsupportedAction(block)
}
return block[pathRange].split(separator: ".").map(String.init)
}

// Patterns are constant and valid, so compile once at first access. `[\s\S]` (not `.`)
// lets a `{{ … }}` block span newlines; the pattern is linear, so no ReDoS.
private static let actionRegex = try! NSRegularExpression(pattern: #"\{\{[\s\S]*?\}\}"#)
/// A supported `{{ .Dotted.Path }}` field access: optional surrounding whitespace, a
/// leading dot, then a dotted identifier path.
private static let fieldRegex = try! NSRegularExpression(pattern: #"\{\{\s*\.([A-Za-z0-9_.]+)\s*\}\}"#)

// MARK: - Path resolution

/// Walk a dotted path through nested dictionaries, rendering the leaf as a scalar.
private static func resolveValue(path: [String], in object: [String: Any]) -> String {
var current: Any = object
for key in path {
guard let dict = current as? [String: Any], let next = dict[key] else {
return ""
}
current = next
}
return scalar(current)
}

/// Render a JSON leaf the way Go's template prints it: bare `true`/`false`,
/// integers without a decimal point, strings verbatim; containers/null → empty.
private static func scalar(_ value: Any) -> String {
if value is NSNull { return "" }
// Both `JSONSerialization` output and bridged Swift `Bool`/`Int` arrive as
// NSNumber here; the CFBoolean type id is the ONLY reliable bool discriminator.
// Check it first so an integer field whose value is 0/1 (e.g. `{{.State.Pid}}`)
// doesn't get mis-rendered as `false`/`true`.
if let number = value as? NSNumber {
if CFGetTypeID(number) == CFBooleanGetTypeID() {
return number.boolValue ? "true" : "false"
}
if number.doubleValue == Double(number.int64Value) {
return String(number.int64Value)
}
return number.stringValue
}
if let string = value as? String {
return string
}
return "" // arrays / objects / unknown render empty, like a bare {{.Field}} on a map
}
}
40 changes: 40 additions & 0 deletions Sources/Mocker/Formatters/InspectFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// Shared `inspect --format` output path for the top-level `inspect` command and the
/// dedicated `container inspect` / `image inspect` subcommands.
///
/// When `format` is nil -- or the literal `json`, which Docker documents as a special
/// `--format` value, not a template -- the records print as the existing JSON (a single
/// object for `emitOne`, an array for `emitArray`). Otherwise each record is rendered
/// through `GoTemplate`, one line per record, the way `docker inspect -f` does.
enum InspectFormat {
/// Emit a single inspect record. Slashes are never escaped, matching Docker's
/// `inspect` JSON (`docker.io/library/...`, not `docker.io\/library\/...`).
static func emitOne<T: Encodable>(_ value: T, format: String?) throws {
guard let format, format != "json" else {
try TableFormatter.printJSONArray(value, escapeSlashes: false)
return
}
print(try GoTemplate.render(format, object: try jsonObject(value)))
}

/// Emit a collection of inspect records (one rendered line each under `--format`).
static func emitArray<T: Encodable>(_ values: [T], format: String?) throws {
guard let format, format != "json" else {
try TableFormatter.printJSONArray(values, escapeSlashes: false)
return
}
for value in values {
print(try GoTemplate.render(format, object: try jsonObject(value)))
}
}

/// Encode `value` to JSON and decode it back to a dictionary for path resolution.
/// Going through JSON guarantees the template sees the exact keys mocker emits.
private static func jsonObject<T: Encodable>(_ value: T) throws -> [String: Any] {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(value)
return (try JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
}
}
163 changes: 163 additions & 0 deletions Tests/MockerTests/GoTemplateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import Foundation
import Testing
@testable import Mocker

@Suite("GoTemplate inspect --format Tests")
struct GoTemplateTests {
/// A Docker-shaped container inspect record, mirroring `mapToContainerInspect`
/// output (PascalCase keys, nested `State`/`Config`/`NetworkSettings`).
private func runningContainer() -> [String: Any] {
[
"Id": "probe",
"Name": "/probe",
"Image": "postgres:16-alpine",
"Config": ["Image": "postgres:16-alpine"],
"NetworkSettings": ["IPAddress": "192.168.64.3"],
"State": [
"Running": true,
"Paused": false,
"Status": "running",
"Pid": 1234,
],
]
}

// MARK: - The dev-stand health gate

@Test("{{.State.Running}} renders bare true for a running container")
func stateRunningTrue() throws {
#expect(try GoTemplate.render("{{.State.Running}}", object: runningContainer()) == "true")
}

@Test("{{.State.Running}} renders bare false for a stopped container")
func stateRunningFalse() throws {
var c = runningContainer()
c["State"] = ["Running": false, "Status": "exited"]
#expect(try GoTemplate.render("{{.State.Running}}", object: c) == "false")
}

@Test("{{.State.Status}} renders the lifecycle string")
func stateStatus() throws {
#expect(try GoTemplate.render("{{.State.Status}}", object: runningContainer()) == "running")
}

@Test("{{.State.Pid}} renders an integer without a decimal point")
func statePid() throws {
#expect(try GoTemplate.render("{{.State.Pid}}", object: runningContainer()) == "1234")
}

@Test("{{.Id}} resolves the container id")
func idResolves() throws {
#expect(try GoTemplate.render("{{.Id}}", object: runningContainer()) == "probe")
}

@Test("{{.Name}} resolves the slash-prefixed name")
func nameResolves() throws {
#expect(try GoTemplate.render("{{.Name}}", object: runningContainer()) == "/probe")
}

@Test("{{.Config.Image}} resolves the image reference")
func configImage() throws {
#expect(try GoTemplate.render("{{.Config.Image}}", object: runningContainer()) == "postgres:16-alpine")
}

@Test("{{.NetworkSettings.IPAddress}} resolves the container IP")
func networkIP() throws {
#expect(try GoTemplate.render("{{.NetworkSettings.IPAddress}}", object: runningContainer()) == "192.168.64.3")
}

// MARK: - Whitespace, literals, multiple tokens

@Test("whitespace inside the braces is tolerated")
func whitespaceTolerated() throws {
#expect(try GoTemplate.render("{{ .State.Running }}", object: runningContainer()) == "true")
}

@Test("surrounding literal text is preserved")
func literalTextPreserved() throws {
#expect(try GoTemplate.render("status={{.State.Status}}!", object: runningContainer()) == "status=running!")
}

@Test("multiple tokens in one template all resolve")
func multipleTokens() throws {
#expect(try GoTemplate.render("{{.Id}} {{.State.Running}}", object: runningContainer()) == "probe true")
}

@Test("a template with no tokens is returned verbatim")
func noTokensVerbatim() throws {
#expect(try GoTemplate.render("just literal text", object: runningContainer()) == "just literal text")
}

// MARK: - Edge cases

@Test("unknown path renders empty, not the literal token")
func unknownPathEmpty() throws {
#expect(try GoTemplate.render("{{.Nope.Missing}}", object: runningContainer()) == "")
}

@Test("a value containing braces does not retrigger substitution")
func noReentrantSubstitution() throws {
var c = runningContainer()
c["Name"] = "{{.State.Running}}"
#expect(try GoTemplate.render("{{.Name}}", object: c) == "{{.State.Running}}")
}

// MARK: - Real encode path (JSONSerialization bridges bools to NSNumber)

@Test("booleans survive the JSON encode/decode round-trip as true/false")
func boolRoundTripThroughJSON() throws {
// The CLI feeds GoTemplate a dict produced by JSONSerialization, where JSON
// booleans arrive as NSNumber. Exercise that exact path, not just native Bool.
let json = #"{"State":{"Running":true,"Status":"running"}}"#
let data = Data(json.utf8)
let object = try #require(
try JSONSerialization.jsonObject(with: data) as? [String: Any]
)
#expect(try GoTemplate.render("{{.State.Running}}", object: object) == "true")
#expect(try GoTemplate.render("{{.State.Status}}", object: object) == "running")
}

// MARK: - Multi-token isolation and scalar edge cases

@Test("a token's resolved value never re-triggers a later token's substitution")
func multiTokenNoCrossSubstitution() throws {
// The first token's value is the *literal text* of the second token. A naive
// global replace on a mutated buffer would re-substitute it; range-splicing
// emits it verbatim.
let object: [String: Any] = ["A": "{{.B}}", "B": "x"]
#expect(try GoTemplate.render("{{.A}}-{{.B}}", object: object) == "{{.B}}-x")
}

@Test("integer 0 (stopped container Pid) renders 0, and Running renders false")
func statePidZeroOnStoppedContainer() throws {
// The CFBoolean type-id check must run before the integer branch so that an
// integer 0 is not mis-rendered as `false` (and a bool false not as `0`).
let json = #"{"State":{"Running":false,"Pid":0,"Status":"exited"}}"#
let object = try #require(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
#expect(try GoTemplate.render("{{.State.Pid}}", object: object) == "0")
#expect(try GoTemplate.render("{{.State.Running}}", object: object) == "false")
}

@Test("an array/object leaf renders empty (documented scope limit, not Docker's [..])")
func arrayLeafRendersEmpty() throws {
// Unlike `docker inspect -f '{{.RepoTags}}'` (which prints `[nginx:latest]`),
// this subset renders a container/array leaf as empty. Pin the behavior so the
// intentional limitation can't silently regress.
#expect(try GoTemplate.render("{{.RepoTags}}", object: ["RepoTags": ["nginx:latest"]]) == "")
}

// MARK: - Unsupported constructs fail loudly (never silent wrong output)

@Test("unsupported template actions throw instead of passing through as literal")
func unsupportedActionsThrow() {
let c = runningContainer()
#expect(throws: GoTemplateError.self) { try GoTemplate.render("{{if .State.Running}}y{{end}}", object: c) }
#expect(throws: GoTemplateError.self) { try GoTemplate.render("{{range .X}}{{end}}", object: c) }
#expect(throws: GoTemplateError.self) { try GoTemplate.render("{{json .Config}}", object: c) }
#expect(throws: GoTemplateError.self) { try GoTemplate.render(#"{{index .Config "Image"}}"#, object: c) }
// A bare `{{.}}` (whole-object identity) is outside the field-path subset.
#expect(throws: GoTemplateError.self) { try GoTemplate.render("{{.}}", object: c) }
// A valid field token sitting next to an unsupported action still fails the whole render.
#expect(throws: GoTemplateError.self) { try GoTemplate.render("{{.Id}} {{range .X}}{{end}}", object: c) }
}
}