//===----------------------------------------------------------------------===// // 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 Foundation import Testing @testable import ContainerBuildIR struct OperationTests { // MARK: - ExecOperation Tests @Test func execOperationBasic() throws { let operation = ExecOperation( command: .shell("echo 'Hello, World!'") ) #expect(operation.operationKind == .exec) #expect(operation.command.displayString == "echo 'Hello, World!'") #expect(operation.environment.variables.isEmpty) #expect(operation.mounts.isEmpty) #expect(operation.workingDirectory == nil) #expect(operation.user == nil) #expect(operation.network == .default) #expect(!operation.security.privileged) } @Test func execOperationWithEnvironment() throws { let environment = Environment([ (key: "NODE_ENV", value: .literal("production")), (key: "PORT", value: .buildArg("HTTP_PORT")), (key: "DEBUG", value: .literal("false")), ]) let operation = ExecOperation( command: .exec(["node", "server.js"]), environment: environment ) #expect(operation.environment.variables.count == 3) let effectiveEnv = operation.environment.effectiveEnvironment #expect(effectiveEnv["NODE_ENV"] == "production") #expect(effectiveEnv["DEBUG"] == "false") // BUILD_ARG references don't resolve without context } @Test func execOperationWithMounts() throws { let mounts = [ Mount( type: .cache, target: "/var/cache/apt", source: .local("apt-cache"), options: MountOptions(sharing: .shared) ), Mount( type: .secret, target: "/run/secrets/github-token", source: .secret("github-token"), options: MountOptions(readOnly: true, mode: 0o400) ), Mount( type: .tmpfs, target: "/tmp", source: nil, options: MountOptions(size: 100 * 1024 * 1024) // 100MB ), ] let operation = ExecOperation( command: .shell("apt-get update && apt-get install -y git"), mounts: mounts ) #expect(operation.mounts.count == 3) let cacheMount = operation.mounts[0] #expect(cacheMount.type == .cache) #expect(cacheMount.target == "/var/cache/apt") #expect(cacheMount.options.sharing == .shared) let secretMount = operation.mounts[1] #expect(secretMount.type == .secret) #expect(secretMount.options.readOnly == true) #expect(secretMount.options.mode == 0o400) let tmpfsMount = operation.mounts[2] #expect(tmpfsMount.type == .tmpfs) #expect(tmpfsMount.options.size == UInt32(100 * 1024 * 1024)) } @Test func execOperationWithUser() throws { let users = [ User.named("appuser"), User.uid(1000), User.userGroup(user: "app", group: "app"), User.uidGid(uid: 1000, gid: 1000), ] for user in users { let operation = ExecOperation( command: .shell("whoami"), user: user ) #expect(operation.user == user) } } @Test func execOperationWithSecurity() throws { let capabilities = SecurityCapabilities( add: ["NET_ADMIN", "SYS_TIME"], drop: ["ALL"] ) let security = SecurityOptions( privileged: true, capabilities: capabilities, seccompProfile: "custom.json", apparmorProfile: "custom-profile", noNewPrivileges: false ) let operation = ExecOperation( command: .shell("mount /dev/sda1 /mnt"), security: security ) #expect(operation.security.privileged == true) #expect(operation.security.capabilities?.add.contains("NET_ADMIN") == true) #expect(operation.security.capabilities?.drop.contains("ALL") == true) #expect(operation.security.seccompProfile == "custom.json") #expect(operation.security.apparmorProfile == "custom-profile") #expect(operation.security.noNewPrivileges == false) } @Test func execOperationWithNetwork() throws { let networkModes: [NetworkMode] = [.default, .none, .host] for networkMode in networkModes { let operation = ExecOperation( command: .shell("curl https://example.com"), network: networkMode ) #expect(operation.network == networkMode) } } @Test func commandTypes() throws { let shellCommand = Command.shell("echo 'test' && ls -la") let execCommand = Command.exec(["ls", "-la", "/app"]) #expect(shellCommand.displayString == "echo 'test' && ls -la") #expect(execCommand.displayString == "ls -la /app") // Test with empty exec array let emptyExecCommand = Command.exec([]) #expect(emptyExecCommand.displayString == "") // Test with single command let singleExecCommand = Command.exec(["whoami"]) #expect(singleExecCommand.displayString == "whoami") } // MARK: - FilesystemOperation Tests @Test func filesystemOperationBasic() throws { let operation = FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["src/"])), destination: "/app/src/" ) #expect(operation.operationKind == .filesystem) #expect(operation.action == .copy) #expect(operation.destination == "/app/src/") if case .context(let contextSource) = operation.source { #expect(contextSource.paths == ["src/"]) } else { Issue.record("Expected context source") } } @Test func filesystemOperationFromStage() throws { let operation = FilesystemOperation( action: .copy, source: .stage(.named("builder"), paths: ["/app/dist"]), destination: "/usr/share/nginx/html/" ) if case .stage(let stageRef, let paths) = operation.source { if case .named(let name) = stageRef { #expect(name == "builder") } else { Issue.record("Expected named stage reference") } #expect(paths == ["/app/dist"]) } else { Issue.record("Expected stage source") } } @Test func filesystemOperationWithMetadata() throws { let expectedOwnership = Ownership(user: .numeric(id: 1000), group: .numeric(id: 1000)) let fileMetadata = FileMetadata( ownership: expectedOwnership, permissions: .mode(0o644), timestamps: Timestamps( created: Date(), modified: Date() ) ) let operation = FilesystemOperation( action: .copy, source: .context( ContextSource( paths: ["config.json"] )), destination: "/etc/app/config.json", fileMetadata: fileMetadata ) #expect(operation.fileMetadata.ownership == expectedOwnership) if case .mode(let mode) = operation.fileMetadata.permissions { #expect(mode == 0o644) } else { Issue.record("Expected mode permissions") } } @Test func filesystemSourceTypes() throws { let contextSource = FilesystemSource.context( ContextSource( paths: ["*.txt", "docs/"] )) let stageSource = FilesystemSource.stage(.index(0), paths: ["/app/binary"]) let imageSource = FilesystemSource.image( ImageReference(parsing: "alpine:latest")!, paths: ["/etc/ssl/certs/"] ) let urlSource = FilesystemSource.url(URL(string: "https://releases.example.com/v1.0.0/app.tar.gz")!) // Verify each source type can be created let sources = [contextSource, stageSource, imageSource, urlSource] for source in sources { let operation = FilesystemOperation( action: .copy, source: source, destination: "/test/" ) #expect(operation.source == source) } } // MARK: - MetadataOperation Tests @Test func metadataOperationEnvironment() throws { let setEnvOperation = MetadataOperation( action: .setEnv(key: "NODE_ENV", value: .literal("production")) ) #expect(setEnvOperation.operationKind == .metadata) if case .setEnv(let key, let value) = setEnvOperation.action { #expect(key == "NODE_ENV") if case .literal(let literalValue) = value { #expect(literalValue == "production") } else { Issue.record("Expected literal value") } } else { Issue.record("Expected setEnv action") } } @Test func metadataOperationBatchEnvironment() throws { let envVars = [ (key: "NODE_ENV", value: EnvironmentValue.literal("production")), (key: "PORT", value: EnvironmentValue.buildArg("HTTP_PORT")), (key: "DEBUG", value: EnvironmentValue.literal("false")), ] let operation = MetadataOperation( action: .setEnvBatch(envVars) ) if case .setEnvBatch(let vars) = operation.action { #expect(vars.count == 3) #expect(vars[0].key == "NODE_ENV") #expect(vars[1].key == "PORT") #expect(vars[2].key == "DEBUG") } else { Issue.record("Expected setEnvBatch action") } } @Test func metadataOperationLabels() throws { let setLabelOperation = MetadataOperation( action: .setLabel(key: "version", value: "1.0.0") ) if case .setLabel(let key, let value) = setLabelOperation.action { #expect(key == "version") #expect(value == "1.0.0") } else { Issue.record("Expected setLabel action") } let batchLabels = [ "version": "1.0.0", "maintainer": "team@example.com", "description": "Sample application", ] let batchLabelOperation = MetadataOperation( action: .setLabelBatch(batchLabels) ) if case .setLabelBatch(let labels) = batchLabelOperation.action { #expect(labels.count == 3) #expect(labels["version"] == "1.0.0") #expect(labels["maintainer"] == "team@example.com") } else { Issue.record("Expected setLabelBatch action") } } @Test func metadataOperationArguments() throws { let operation = MetadataOperation( action: .declareArg(name: "BUILD_VERSION", defaultValue: "dev") ) if case .declareArg(let name, let defaultValue) = operation.action { #expect(name == "BUILD_VERSION") #expect(defaultValue == "dev") } else { Issue.record("Expected declareArg action") } } @Test func metadataOperationExpose() throws { let operation = MetadataOperation( action: .expose(ports: [PortSpec(port: 8080, protocol: .tcp)]) ) if case .expose(let ports) = operation.action { #expect(!ports.isEmpty) let port = ports[0] #expect(port.port == 8080) #expect(port.protocol == .tcp) } else { Issue.record("Expected expose action") } } @Test func metadataOperationWorkdir() throws { let operation = MetadataOperation( action: .setWorkdir(path: "/app") ) if case .setWorkdir(let path) = operation.action { #expect(path == "/app") } else { Issue.record("Expected setWorkdir action") } } @Test func metadataOperationUser() throws { let userOperations = [ MetadataOperation(action: .setUser(user: .named("appuser"))), MetadataOperation(action: .setUser(user: .uid(1000))), MetadataOperation(action: .setUser(user: .uidGid(uid: 1000, gid: 1000))), ] for operation in userOperations { if case .setUser(let user) = operation.action { // Just verify the user is preserved switch user { case .named(let name): #expect(name == "appuser") case .uid(let uid): #expect(uid == 1000) case .uidGid(let uid, let gid): #expect(uid == 1000) #expect(gid == 1000) default: Issue.record("Unexpected user type") } } else { Issue.record("Expected setUser action") } } } @Test func metadataOperationCommands() throws { let entrypointOperation = MetadataOperation( action: .setEntrypoint(command: .exec(["/app/server"])) ) let cmdOperation = MetadataOperation( action: .setCmd(command: .shell("./start.sh")) ) if case .setEntrypoint(let command) = entrypointOperation.action { if case .exec(let args) = command { #expect(args == ["/app/server"]) } else { Issue.record("Expected exec command") } } else { Issue.record("Expected setEntrypoint action") } if case .setCmd(let command) = cmdOperation.action { if case .shell(let cmd) = command { #expect(cmd == "./start.sh") } else { Issue.record("Expected shell command") } } else { Issue.record("Expected setCmd action") } } @Test func metadataOperationHealthcheck() throws { let healthcheck = Healthcheck( test: .command(.exec(["curl", "-f", "http://localhost:8080/health"])), interval: 30, timeout: 5, startPeriod: 10, retries: 3 ) let operation = MetadataOperation( action: .setHealthcheck(healthcheck: healthcheck) ) if case .setHealthcheck(let hc) = operation.action { #expect(hc?.interval == 30) #expect(hc?.timeout == 5) #expect(hc?.startPeriod == 10) #expect(hc?.retries == 3) if case .command(let cmd) = hc!.test, case .exec(let args) = cmd { #expect(args == ["curl", "-f", "http://localhost:8080/health"]) } else { Issue.record("Expected command healthcheck test") } } else { Issue.record("Expected setHealthcheck action") } } @Test func metadataOperationMiscellaneous() throws { let stopSignalOp = MetadataOperation(action: .setStopSignal(signal: "SIGTERM")) let volumeOp = MetadataOperation(action: .addVolume(path: "/data")) let shellOp = MetadataOperation(action: .setShell(shell: ["/bin/bash", "-c"])) let onBuildOp = MetadataOperation(action: .addOnBuild(instruction: "RUN npm install")) let operations = [stopSignalOp, volumeOp, shellOp, onBuildOp] for operation in operations { #expect(operation.operationKind == .metadata) switch operation.action { case .setStopSignal(let signal): #expect(signal == "SIGTERM") case .addVolume(let path): #expect(path == "/data") case .setShell(let shell): #expect(shell == ["/bin/bash", "-c"]) case .addOnBuild(let instruction): #expect(instruction == "RUN npm install") default: Issue.record("Unexpected action type") } } } // MARK: - ImageOperation Tests @Test func imageOperationFromRegistry() throws { let imageRef = ImageReference(parsing: "alpine:3.18")! let operation = ImageOperation( source: .registry(imageRef), platform: .linuxAMD64 ) #expect(operation.operationKind == .image) #expect(operation.platform == .linuxAMD64) if case .registry(let ref) = operation.source { #expect(ref.stringValue == "alpine:3.18") } else { Issue.record("Expected registry source") } } @Test func imageOperationFromScratch() throws { let operation = ImageOperation(source: .scratch) #expect(operation.platform == nil) // Default platform if case .scratch = operation.source { // Expected } else { Issue.record("Expected scratch source") } } @Test func imageOperationFromOCILayout() throws { let operation = ImageOperation( source: .ociLayout(path: "/path/to/build/context", tag: "custom"), platform: .linuxARM64 ) if case .ociLayout(let path, let tag) = operation.source { #expect(path == "/path/to/build/context") #expect(tag == "custom") } else { Issue.record("Expected ociLayout source") } } // MARK: - Operation Visitor Pattern Tests class TestOperationVisitor: OperationVisitor { typealias Result = String var visitedOperations: [String] = [] func visit(_ operation: ExecOperation) throws -> String { visitedOperations.append("exec") return "Exec: \(operation.command.displayString)" } func visit(_ operation: FilesystemOperation) throws -> String { visitedOperations.append("filesystem") return "Filesystem: \(operation.action) to \(operation.destination)" } func visit(_ operation: MetadataOperation) throws -> String { visitedOperations.append("metadata") return "Metadata: \(operation.action)" } func visit(_ operation: ImageOperation) throws -> String { visitedOperations.append("image") return "Image: \(operation.source)" } func visitUnknown(_ operation: any ContainerBuildIR.Operation) throws -> String { visitedOperations.append("unknown") return "Unknown: \(operation.operationKind)" } } @Test func operationVisitorPattern() throws { let operations: [any ContainerBuildIR.Operation] = [ ExecOperation(command: .shell("echo test")), FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["file.txt"])), destination: "/app/" ), MetadataOperation(action: .setEnv(key: "TEST", value: .literal("value"))), ImageOperation(source: .registry(ImageReference(parsing: "alpine")!)), ] let visitor = TestOperationVisitor() var results: [String] = [] for operation in operations { let result = try operation.accept(visitor) results.append(result) } #expect(visitor.visitedOperations == ["exec", "filesystem", "metadata", "image"]) #expect(results.count == 4) #expect(results[0].hasPrefix("Exec:")) #expect(results[1].hasPrefix("Filesystem:")) #expect(results[2].hasPrefix("Metadata:")) #expect(results[3].hasPrefix("Image:")) } // MARK: - Operation Serialization Tests @Test func operationSerialization() throws { let operations: [any ContainerBuildIR.Operation] = [ ExecOperation( command: .exec(["npm", "install"]), environment: Environment([(key: "NODE_ENV", value: .literal("production"))]), user: .uid(1000) ), FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["src/"])), destination: "/app/src/", fileMetadata: FileMetadata( ownership: Ownership(user: .numeric(id: 1000), group: .numeric(id: 1000)), permissions: .mode(0o755) ) ), MetadataOperation( action: .setLabel(key: "version", value: "1.0.0") ), ImageOperation( source: .registry(ImageReference(parsing: "node:18")!), platform: .linuxAMD64 ), ] let encoder = JSONEncoder() let decoder = JSONDecoder() for operation in operations { // Test that each operation type can be encoded and decoded let data: Data switch operation { case let execOp as ExecOperation: data = try encoder.encode(execOp) let decoded = try decoder.decode(ExecOperation.self, from: data) #expect(decoded.command.displayString == execOp.command.displayString) #expect(decoded.user == execOp.user) case let fsOp as FilesystemOperation: data = try encoder.encode(fsOp) let decoded = try decoder.decode(FilesystemOperation.self, from: data) #expect(decoded.action == fsOp.action) #expect(decoded.destination == fsOp.destination) case let metaOp as MetadataOperation: data = try encoder.encode(metaOp) _ = try decoder.decode(MetadataOperation.self, from: data) // Note: MetadataAction comparison would need custom implementation case let imageOp as ImageOperation: data = try encoder.encode(imageOp) let decoded = try decoder.decode(ImageOperation.self, from: data) #expect(decoded.platform == imageOp.platform) default: Issue.record("Unexpected operation type") } } } // MARK: - Operation Metadata Tests @Test func operationMetadata() throws { let sourceLocation = SourceLocation( file: "Dockerfile", line: 15, column: 5 ) let metadata = OperationMetadata( comment: "Install application dependencies", sourceLocation: sourceLocation ) let operation = ExecOperation( command: .shell("npm install"), metadata: metadata ) #expect(operation.metadata.comment == "Install application dependencies") #expect(operation.metadata.sourceLocation?.file == "Dockerfile") #expect(operation.metadata.sourceLocation?.line == 15) } // MARK: - Complex Operation Tests @Test func complexExecOperation() throws { let complexOperation = ExecOperation( command: .shell("cd /app && npm ci --only=production && npm run build"), environment: Environment([ (key: "NODE_ENV", value: .literal("production")), (key: "BUILD_TARGET", value: .buildArg("TARGET")), (key: "CACHE_DIR", value: .literal("/tmp/cache")), ]), mounts: [ Mount( type: .cache, target: "/tmp/cache", source: .local("build-cache"), options: MountOptions(sharing: .shared) ), Mount( type: .secret, target: "/run/secrets/npmrc", source: .secret("npmrc"), options: MountOptions(readOnly: true, mode: 0o600) ), ], workingDirectory: "/app", user: .uidGid(uid: 1000, gid: 1000), network: .default, security: SecurityOptions( privileged: false, noNewPrivileges: true ), metadata: OperationMetadata( comment: "Build application with caching and secrets" ) ) #expect(complexOperation.command.displayString.contains("npm ci")) #expect(complexOperation.environment.variables.count == 3) #expect(complexOperation.mounts.count == 2) #expect(complexOperation.workingDirectory == "/app") #expect(complexOperation.user != nil) #expect(complexOperation.security.noNewPrivileges == true) #expect(complexOperation.metadata.comment?.contains("Build application") == true) } @Test func complexFilesystemOperation() throws { let complexOperation = FilesystemOperation( action: .copy, source: .stage(.named("builder"), paths: ["/app/dist/**/*", "/app/package.json"]), destination: "/usr/share/nginx/html/", fileMetadata: FileMetadata( ownership: Ownership(user: .named(id: "nginx"), group: .named(id: "nginx")), permissions: .mode(0o644), timestamps: Timestamps( created: Date(), modified: Date() ) ), metadata: OperationMetadata( comment: "Copy built assets from builder stage", sourceLocation: SourceLocation(file: "Dockerfile", line: 25, column: 1) ) ) if case .stage(let stageRef, let paths) = complexOperation.source { if case .named(let name) = stageRef { #expect(name == "builder") } #expect(paths.count == 2) #expect(paths.contains("/app/dist/**/*")) } else { Issue.record("Expected stage source") } #expect(complexOperation.destination == "/usr/share/nginx/html/") // Check fileMetadata exists and has expected values #expect(complexOperation.metadata.comment?.contains("built assets") == true) } // MARK: - Edge Cases and Error Conditions @Test func emptyOperations() throws { // Test operations with minimal/empty configurations let minimalExec = ExecOperation(command: .exec([])) #expect(minimalExec.command.displayString == "") #expect(minimalExec.mounts.isEmpty) let minimalFilesystem = FilesystemOperation( action: .copy, source: .context(ContextSource(paths: [])), destination: "" ) #expect(minimalFilesystem.destination == "") if case .context(let ctx) = minimalFilesystem.source { #expect(ctx.paths.isEmpty) } } @Test func operationEquality() throws { let exec1 = ExecOperation(command: .shell("echo test")) let exec2 = ExecOperation(command: .shell("echo test")) let exec3 = ExecOperation(command: .shell("echo different")) #expect(exec1 == exec2) #expect(exec1 != exec3) let fs1 = FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["file.txt"])), destination: "/app/" ) let fs2 = FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["file.txt"])), destination: "/app/" ) #expect(fs1 == fs2) } @Test func operationHashing() throws { let exec1 = ExecOperation(command: .shell("echo test")) let exec2 = ExecOperation(command: .shell("echo different")) let fs1 = FilesystemOperation( action: .copy, source: .context(ContextSource(paths: ["file.txt"])), destination: "/app/" ) let operations: Set = Set([ AnyHashable(exec1), AnyHashable(exec2), AnyHashable(fs1), ]) #expect(operations.count == 3) // Test that identical operations hash to the same value let exec1_copy = ExecOperation(command: .shell("echo test")) let exec2_copy = ExecOperation(command: .shell("echo test")) var hasher1 = Hasher() var hasher2 = Hasher() exec1_copy.hash(into: &hasher1) exec2_copy.hash(into: &hasher2) #expect(hasher1.finalize() == hasher2.finalize()) } }