Skip to content

Commit a69ed78

Browse files
authored
Add socket publishing functionality (#236)
Signed-off-by: renee chang <rchang25@apple.com>
1 parent a262d8f commit a69ed78

6 files changed

Lines changed: 187 additions & 0 deletions

File tree

Sources/ContainerClient/Core/ContainerConfiguration.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public struct ContainerConfiguration: Sendable, Codable {
2323
public var image: ImageDescription
2424
/// External mounts to add to the container.
2525
public var mounts: [Filesystem] = []
26+
/// Sockets to publish from container to host.
27+
public var publishedSockets: [PublishSocket] = []
2628
/// Key/Value labels for the container.
2729
public var labels: [String: String] = [:]
2830
/// System controls for the container.
@@ -44,6 +46,44 @@ public struct ContainerConfiguration: Sendable, Codable {
4446
/// Name of the runtime that supports the container
4547
public var runtimeHandler: String = "container-runtime-linux"
4648

49+
enum CodingKeys: String, CodingKey {
50+
case id
51+
case image
52+
case mounts
53+
case publishedSockets
54+
case labels
55+
case sysctls
56+
case networks
57+
case dns
58+
case rosetta
59+
case hostname
60+
case initProcess
61+
case platform
62+
case resources
63+
case runtimeHandler
64+
}
65+
66+
/// Create a configuration from the supplied Decoder, initializing missing
67+
/// values where possible to reasonable defaults.
68+
public init(from decoder: Decoder) throws {
69+
let container = try decoder.container(keyedBy: CodingKeys.self)
70+
71+
id = try container.decode(String.self, forKey: .id)
72+
image = try container.decode(ImageDescription.self, forKey: .image)
73+
mounts = try container.decodeIfPresent([Filesystem].self, forKey: .mounts) ?? []
74+
publishedSockets = try container.decodeIfPresent([PublishSocket].self, forKey: .publishedSockets) ?? []
75+
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
76+
sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:]
77+
networks = try container.decodeIfPresent([String].self, forKey: .networks) ?? []
78+
dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns)
79+
rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false
80+
hostname = try container.decodeIfPresent(String.self, forKey: .hostname)
81+
initProcess = try container.decode(ProcessConfiguration.self, forKey: .initProcess)
82+
platform = try container.decodeIfPresent(ContainerizationOCI.Platform.self, forKey: .platform) ?? .current
83+
resources = try container.decodeIfPresent(Resources.self, forKey: .resources) ?? .init()
84+
runtimeHandler = try container.decodeIfPresent(String.self, forKey: .runtimeHandler) ?? "container-runtime-linux"
85+
}
86+
4787
public struct DNSConfiguration: Sendable, Codable {
4888
public static let defaultNameservers = ["1.1.1.1"]
4989

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import SystemPackage
19+
20+
/// Represents a socket that should be published from container to host.
21+
public struct PublishSocket: Sendable, Codable {
22+
/// The path to the socket in the container.
23+
public var containerPath: URL
24+
25+
/// The path where the socket should appear on the host.
26+
public var hostPath: URL
27+
28+
/// File permissions for the socket on the host.
29+
public var permissions: FilePermissions?
30+
31+
public init(
32+
containerPath: URL,
33+
hostPath: URL,
34+
permissions: FilePermissions? = nil
35+
) {
36+
self.containerPath = containerPath
37+
self.hostPath = hostPath
38+
self.permissions = permissions
39+
}
40+
}

Sources/ContainerClient/Flags.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ public struct Flags {
9292
@Option(name: .customLong("mount"), help: "Add a mount to the container (type=<>,source=<>,target=<>,readonly)")
9393
public var mounts: [String] = []
9494

95+
@Option(name: .customLong("publish-socket"), help: "Publish a socket from container to host (format: host_path:container_path)")
96+
public var publishSockets: [String] = []
97+
9598
@Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path")
9699
public var tmpFs: [String] = []
97100

Sources/ContainerClient/Parser.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,4 +396,94 @@ public struct Parser {
396396
throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty")
397397
}
398398
}
399+
400+
// Parse --publish-socket arguments into PublishSocket objects
401+
// Format: "host_path:container_path" (e.g., "/tmp/docker.sock:/var/run/docker.sock")
402+
//
403+
// - Parameter rawPublishSockets: Array of socket specifications
404+
// - Returns: Array of PublishSocket objects
405+
// - Throws: ContainerizationError if parsing fails
406+
static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] {
407+
var sockets: [PublishSocket] = []
408+
409+
// Process each raw socket string
410+
for socket in rawPublishSockets {
411+
let parsedSocket = try Parser.publishSocket(socket)
412+
sockets.append(parsedSocket)
413+
}
414+
return sockets
415+
}
416+
417+
// Parse a single --publish-socket argument and validate paths
418+
// Format: "host_path:container_path" -> PublishSocket
419+
private static func publishSocket(_ socket: String) throws -> PublishSocket {
420+
// Split by colon to two parts: [host_path, container_path]
421+
let parts = socket.split(separator: ":")
422+
423+
switch parts.count {
424+
case 2:
425+
// Extract host and container paths
426+
let hostPath = String(parts[0])
427+
let containerPath = String(parts[1])
428+
429+
// Validate paths are not empty
430+
if hostPath.isEmpty {
431+
throw ContainerizationError(
432+
.invalidArgument, message: "host socket path cannot be empty")
433+
}
434+
if containerPath.isEmpty {
435+
throw ContainerizationError(
436+
.invalidArgument, message: "container socket path cannot be empty")
437+
}
438+
439+
// Ensure container path must start with /
440+
if !containerPath.hasPrefix("/") {
441+
throw ContainerizationError(
442+
.invalidArgument,
443+
message: "container socket path must be absolute: \(containerPath)")
444+
}
445+
446+
// Convert host path to absolute path for consistency
447+
let hostURL = URL(fileURLWithPath: hostPath)
448+
let absoluteHostPath = hostURL.absoluteURL.path
449+
450+
// Check if host socket already exists and might be in use
451+
if FileManager.default.fileExists(atPath: absoluteHostPath) {
452+
do {
453+
let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath)
454+
if let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSocket {
455+
throw ContainerizationError(
456+
.invalidArgument,
457+
message: "host socket \(absoluteHostPath) already exists and may be in use")
458+
}
459+
// If it exists but is not a socket, we can remove it and create socket
460+
try FileManager.default.removeItem(atPath: absoluteHostPath)
461+
} catch let error as ContainerizationError {
462+
throw error
463+
} catch {
464+
// For other file system errors, continue with creation
465+
}
466+
}
467+
468+
// Create host directory if it doesn't exist
469+
let hostDir = hostURL.deletingLastPathComponent()
470+
if !FileManager.default.fileExists(atPath: hostDir.path) {
471+
try FileManager.default.createDirectory(
472+
at: hostDir, withIntermediateDirectories: true)
473+
}
474+
475+
// Create and return PublishSocket object with validated paths
476+
return PublishSocket(
477+
containerPath: URL(fileURLWithPath: containerPath),
478+
hostPath: URL(fileURLWithPath: absoluteHostPath),
479+
permissions: nil
480+
)
481+
482+
default:
483+
throw ContainerizationError(
484+
.invalidArgument,
485+
message:
486+
"invalid publish-socket format \(socket). Expected: host_path:container_path")
487+
}
488+
}
399489
}

Sources/ContainerClient/Utility.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ public struct Utility {
182182

183183
config.labels = try Parser.labels(management.labels)
184184

185+
// Parse --publish-socket arguments and add to container configuration
186+
// to enable socket forwarding from container to host.
187+
config.publishedSockets = try Parser.publishSockets(management.publishSockets)
188+
185189
return (config, kernel)
186190
}
187191

Sources/Services/ContainerSandboxService/SandboxService.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,16 @@ public actor SandboxService {
620620
}
621621
}
622622

623+
for publishedSocket in config.publishedSockets {
624+
let socketConfig = UnixSocketConfiguration(
625+
source: publishedSocket.containerPath,
626+
destination: publishedSocket.hostPath,
627+
permissions: publishedSocket.permissions,
628+
direction: .outOf
629+
)
630+
container.sockets.append(socketConfig)
631+
}
632+
623633
container.hostname = config.hostname ?? config.id
624634

625635
if let dns = config.dns {

0 commit comments

Comments
 (0)