Files
container/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift
Kathryn Baldauf 386fd87b5d Enumerate using relative paths to avoid mismatch with symlink resolution of special paths like /tmp (#613)
## Type of Change
- [x] Bug fix
- [ ] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
Fixes https://github.com/apple/container/issues/588. This PR changes the
archiver compression file enumeration to use the
[enumerator(atPath:)](https://developer.apple.com/documentation/foundation/filemanager/enumerator(atpath:))
version. This version returns relative paths instead of full file paths
from the filesystem. /tmp is symlinked to /private/tmp and some swift
packages will handle that path differently. While a call to Foundation's
`URL.resolvingSymlinksInPath()` will return "/tmp", a call to
`FileManager.enumerator(at:)` will return "/private/tmp". This
difference causes a container image build to fail when the user is using
a path under /tmp or other special case paths as the context directory.

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [ ] Added/updated docs

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
2025-09-16 10:52:08 -07:00

439 lines
18 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
//
import ContainerizationOCI
import Foundation
import Testing
extension TestCLIBuildBase {
class CLIBuilderTest: TestCLIBuildBase {
override init() throws {
try super.init()
}
deinit {
try? builderDelete(force: true)
}
@Test func testBuildDotFileSucceeds() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch
ADD emptyFile /
"""
let context: [FileSystemEntry] = [
.file("emptyFile", content: .zeroFilled(size: 1)),
.file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)),
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "registry.local/dot-file:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildFromPreviousStage() throws {
let tempDir: URL = try createTempDir()
let dockerfile =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1
RUN sh -c "echo 'layer1' > /layer1.txt"
FROM layer1
CMD ["cat", "/layer1.txt"]
"""
try createContext(tempDir: tempDir, dockerfile: dockerfile)
let imageName = "registry.local/from-previous-layer:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully build \(imageName)")
}
@Test func testBuildFromLocalImage() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch
ADD emptyFile /
"""
let context: [FileSystemEntry] = [
.file("emptyFile", content: .zeroFilled(size: 0)),
.file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)),
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "local-only:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
let newTempDir: URL = try createTempDir()
let newDockerfile: String =
"""
FROM \(imageName)
"""
let newContext: [FileSystemEntry] = []
try createContext(tempDir: newTempDir, dockerfile: newDockerfile, context: newContext)
let newImageName = "from-local:\(UUID().uuidString)"
try self.build(tag: newImageName, tempDir: newTempDir)
#expect(try self.inspectImage(newImageName) == newImageName, "expected to have successfully built \(newImageName)")
}
@Test func testBuildAddFromSpecialDirs() throws {
let tempDir = URL(filePath: "/tmp/container/.clitests" + testUUID)
try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try! FileManager.default.removeItem(at: tempDir)
}
let dockerfile: String =
"""
FROM scratch
ADD emptyFile /
"""
let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "registry.local/scratch-add-special-dir:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildScratchAdd() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch
ADD emptyFile /
"""
let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "registry.local/scratch-add:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildAddAll() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
ADD . .
RUN cat emptyFile
RUN cat Test/testempty
"""
let context: [FileSystemEntry] = [
.directory("Test"),
.file("Test/testempty", content: .zeroFilled(size: 1)),
.file("emptyFile", content: .zeroFilled(size: 1)),
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName: String = "registry.local/add-all:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildArg() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
ARG TAG=unknown
FROM ghcr.io/linuxcontainers/alpine:${TAG}
"""
try createContext(tempDir: tempDir, dockerfile: dockerfile)
let imageName: String = "registry.local/build-arg:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["TAG=3.20"])
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildNetworkAccess() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
ARG ADDRESS
RUN nc -zv ${ADDRESS%:*} ${ADDRESS##*:} || exit 1
"""
try createContext(tempDir: tempDir, dockerfile: dockerfile)
let imageName = "registry.local/build-network-access:\(UUID().uuidString)"
let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"]
var address = "8.8.8.8:53"
if let proxyAddr = proxyEnv {
address = String(proxyAddr.trimmingPrefix("http://"))
}
try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["ADDRESS=\(address)"])
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildDockerfileKeywords() throws {
let tempDir: URL = try createTempDir()
let dockerfile =
"""
# stage 1 Meta ARG
ARG TAG=3.20
FROM ghcr.io/linuxcontainers/alpine:${TAG}
# stage 2 RUN
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN echo "Hello, World!" > /hello.txt
# stage 3 - RUN []
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"]
# stage 4 - CMD
FROM ghcr.io/linuxcontainers/alpine:3.20
CMD ["echo", "Exec default"]
# stage 5 - CMD []
FROM ghcr.io/linuxcontainers/alpine:3.20
CMD ["echo", "Exec'ing"]
#stage 6 - LABEL
FROM ghcr.io/linuxcontainers/alpine:3.20
LABEL version="1.0" description="Test image"
# stage 7 - EXPOSE
FROM ghcr.io/linuxcontainers/alpine:3.20
EXPOSE 8080
# stage 8 - ENV
FROM ghcr.io/linuxcontainers/alpine:3.20
ENV MY_ENV=hello
RUN echo $MY_ENV > /env.txt
# stage 9 - ADD
FROM ghcr.io/linuxcontainers/alpine:3.20
ADD emptyFile /
# stage 10 - COPY
FROM ghcr.io/linuxcontainers/alpine:3.20
COPY toCopy /toCopy
# stage 11 - ENTRYPOINT
FROM ghcr.io/linuxcontainers/alpine:3.20
ENTRYPOINT ["echo", "entrypoint!"]
# stage 12 - VOLUME
FROM ghcr.io/linuxcontainers/alpine:3.20
VOLUME /data
# stage 13 - USER
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN adduser -D myuser
USER myuser
CMD whoami
# stage 14 - WORKDIR
FROM ghcr.io/linuxcontainers/alpine:3.20
WORKDIR /app
RUN pwd > /pwd.out
# stage 15 - ARG
FROM ghcr.io/linuxcontainers/alpine:3.20
ARG MY_VAR=default
RUN echo $MY_VAR > /var.out
# stage 16 - ONBUILD
# FROM ghcr.io/linuxcontainers/alpine:3.20
# ONBUILD RUN echo "onbuild triggered" > /onbuild.out
# stage 17 - STOPSIGNAL
# FROM ghcr.io/linuxcontainers/alpine:3.20
# STOPSIGNAL SIGTERM
# stage 18 - HEALTHCHECK
# FROM ghcr.io/linuxcontainers/alpine:3.20
# HEALTHCHECK CMD echo "healthy" || exit 1
# stage 19 - SHELL
# FROM ghcr.io/linuxcontainers/alpine:3.20
# SHELL ["/bin/sh", "-c"]
# RUN echo $0 > /shell.txt
"""
let context: [FileSystemEntry] = [
.file("emptyFile", content: .zeroFilled(size: 1)),
.file("toCopy", content: .zeroFilled(size: 1)),
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "registry.local/dockerfile-keywords:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildSymlink() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
# Test 1: Test basic symlinking
FROM ghcr.io/linuxcontainers/alpine:3.20
ADD Test1Source Test1Source
ADD Test1Source2 Test1Source2
RUN cat Test1Source2/test.yaml
# Test2: Test symlinks in nested directories
FROM ghcr.io/linuxcontainers/alpine:3.20
ADD Test2Source Test2Source
ADD Test2Source2 Test2Source2
RUN cat Test2Source2/Test/test.txt
# Test 3: Test symlinks to directories work
FROM ghcr.io/linuxcontainers/alpine:3.20
ADD Test3Source Test3Source
ADD Test3Source2 Test3Source2
RUN cat Test3Source2/Dest/test.txt
"""
let context: [FileSystemEntry] = [
// test 1
.directory("Test1Source"),
.directory("Test1Source2"),
.file("Test1Source/test.yaml", content: .zeroFilled(size: 1)),
.symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"),
// test 2
.directory("Test2Source"),
.directory("Test2Source2"),
.file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 1)),
.symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"),
// test 3
.directory("Test3Source/Source"),
.directory("Test3Source2"),
.file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)),
.symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"),
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "registry.local/build-symlinks:\(UUID().uuidString)"
#expect(throws: Never.self) {
try self.build(tag: imageName, tempDir: tempDir)
}
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildAndRun() throws {
let name: String = "test-build-and-run"
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN echo "foobar" > /file
"""
let context: [FileSystemEntry] = []
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName = "\(name):latest"
let containerName = "\(name)-container"
try self.build(tag: imageName, tempDir: tempDir)
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
// Check if the image we built is actually in the image store, and can be used.
try self.doLongRun(name: containerName, image: imageName)
defer {
try? self.doStop(name: containerName)
}
var output = try doExec(name: containerName, cmd: ["cat", "/file"])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = "foobar"
try self.doStop(name: containerName)
#expect(output == expected, "expected file contents to be \(expected), instead got \(output)")
}
@Test func testBuildDifferentPaths() throws {
let dockerfileCtxDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN ls ./
COPY . /root
RUN cat /root/Test/test.txt
"""
let dockerfileCtx: [FileSystemEntry] = [
.directory(".git"),
.file(".git/FETCH", content: .zeroFilled(size: 1)),
]
try createContext(tempDir: dockerfileCtxDir, dockerfile: dockerfile, context: dockerfileCtx)
let buildContextDir: URL = try createTempDir()
let buildContext: [FileSystemEntry] = [
.directory("Test"),
.file("Test/test.txt", content: .zeroFilled(size: 1)),
]
try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext)
let imageName = "registry.local/build-diff-context:\(UUID().uuidString)"
#expect(throws: Never.self) {
try self.buildWithPaths(tag: imageName, tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir)
}
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
@Test func testBuildMultiArch() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
ADD . .
RUN cat emptyFile
RUN cat Test/testempty
"""
let context: [FileSystemEntry] = [
.directory("Test"),
.file("Test/testempty", content: .zeroFilled(size: 1)),
.file("emptyFile", content: .zeroFilled(size: 1)),
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
let imageName: String = "registry.local/multi-arch:\(UUID().uuidString)"
try self.build(tag: imageName, tempDir: tempDir, otherArgs: ["--arch", "amd64,arm64"])
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
let output = try doInspectImages(image: imageName)
#expect(output.count == 1, "expected a single image inspect output, got \(output)")
let expected = Set([
Platform(arch: "amd64", os: "linux", variant: nil),
Platform(arch: "arm64", os: "linux", variant: nil),
])
let actual = Set(
output[0].variants.map { v in
Platform(arch: v.platform.architecture, os: v.platform.os, variant: nil)
})
#expect(
actual == expected,
"expected platforms \(expected), got \(actual)"
)
}
}
}