Skip to content

Commit 7dfe27d

Browse files
authored
CLI: Add support for rlimits (apple#1129)
Closes apple#1097.
1 parent b3b5c3e commit 7dfe27d

File tree

4 files changed

+368
-1
lines changed

4 files changed

+368
-1
lines changed

‎Sources/Services/ContainerAPIService/Client/Flags.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ public struct Flags {
6161
)
6262
)
6363
public var cwd: String?
64+
65+
@Option(
66+
name: .customLong("ulimit"),
67+
help: .init(
68+
"Set resource limits (format: <type>=<soft>[:<hard>])",
69+
valueName: "limit"
70+
)
71+
)
72+
public var ulimits: [String] = []
6473
}
6574

6675
public struct Resource: ParsableArguments {

‎Sources/Services/ContainerAPIService/Client/Parser.swift‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,17 @@ public struct Parser {
281281
user: processFlags.user, uid: processFlags.uid,
282282
gid: processFlags.gid, defaultUser: defaultUser)
283283

284+
let rlimits = try Parser.rlimits(processFlags.ulimits)
285+
284286
return .init(
285287
executable: commandToRun.first!,
286288
arguments: [String](commandToRun.dropFirst()),
287289
environment: envvars,
288290
workingDirectory: workingDir,
289291
terminal: processFlags.tty,
290292
user: user,
291-
supplementalGroups: additionalGroups
293+
supplementalGroups: additionalGroups,
294+
rlimits: rlimits
292295
)
293296
}
294297

@@ -867,6 +870,114 @@ public struct Parser {
867870
return !label.ranges(of: pattern).isEmpty
868871
}
869872

873+
// TODO: When containerization supports all 16 (minus AS as it's not great) add
874+
// them here.
875+
private static let ulimitNameToRlimit: [String: String] = [
876+
"core": "RLIMIT_CORE",
877+
"cpu": "RLIMIT_CPU",
878+
"data": "RLIMIT_DATA",
879+
"fsize": "RLIMIT_FSIZE",
880+
"memlock": "RLIMIT_MEMLOCK",
881+
"nofile": "RLIMIT_NOFILE",
882+
"nproc": "RLIMIT_NPROC",
883+
"rss": "RLIMIT_RSS",
884+
"stack": "RLIMIT_STACK",
885+
]
886+
887+
/// Parse ulimit specifications into Rlimit objects
888+
/// Format: <type>=<soft>[:<hard>]
889+
/// Examples:
890+
/// - nofile=1024:2048 (soft=1024, hard=2048)
891+
/// - nofile=1024 (soft=hard=1024)
892+
/// - nofile=unlimited (soft=hard=UINT64_MAX)
893+
/// - nofile=1024:unlimited (soft=1024, hard=UINT64_MAX)
894+
public static func rlimits(_ rawUlimits: [String]) throws -> [ProcessConfiguration.Rlimit] {
895+
var rlimits: [ProcessConfiguration.Rlimit] = []
896+
var seenTypes: Set<String> = []
897+
898+
for ulimit in rawUlimits {
899+
let rlimit = try Parser.rlimit(ulimit)
900+
if seenTypes.contains(rlimit.limit) {
901+
throw ContainerizationError(
902+
.invalidArgument,
903+
message: "duplicate ulimit type: \(ulimit.split(separator: "=").first ?? "")"
904+
)
905+
}
906+
seenTypes.insert(rlimit.limit)
907+
rlimits.append(rlimit)
908+
}
909+
910+
return rlimits
911+
}
912+
913+
/// Parse a single ulimit specification
914+
public static func rlimit(_ ulimit: String) throws -> ProcessConfiguration.Rlimit {
915+
let parts = ulimit.split(separator: "=", maxSplits: 1)
916+
guard parts.count == 2 else {
917+
throw ContainerizationError(
918+
.invalidArgument,
919+
message: "invalid ulimit format '\(ulimit)': expected <type>=<soft>[:<hard>]"
920+
)
921+
}
922+
923+
let typeName = String(parts[0]).lowercased()
924+
let valuesPart = String(parts[1])
925+
926+
guard let rlimitType = ulimitNameToRlimit[typeName] else {
927+
let validTypes = ulimitNameToRlimit.keys.sorted().joined(separator: ", ")
928+
throw ContainerizationError(
929+
.invalidArgument,
930+
message: "unsupported ulimit type '\(typeName)': valid types are \(validTypes)"
931+
)
932+
}
933+
934+
let valueParts = valuesPart.split(separator: ":", maxSplits: 1)
935+
let soft: UInt64
936+
let hard: UInt64
937+
938+
switch valueParts.count {
939+
case 1:
940+
// Single value: use for both soft and hard
941+
soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName)
942+
hard = soft
943+
case 2:
944+
// Two values: soft:hard
945+
soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName)
946+
hard = try parseRlimitValue(String(valueParts[1]), typeName: typeName)
947+
default:
948+
throw ContainerizationError(
949+
.invalidArgument,
950+
message: "invalid ulimit format '\(ulimit)': expected <type>=<soft>[:<hard>]"
951+
)
952+
}
953+
954+
if soft > hard {
955+
throw ContainerizationError(
956+
.invalidArgument,
957+
message: "ulimit '\(typeName)' soft limit (\(soft)) cannot exceed hard limit (\(hard))"
958+
)
959+
}
960+
961+
return ProcessConfiguration.Rlimit(limit: rlimitType, soft: soft, hard: hard)
962+
}
963+
964+
private static func parseRlimitValue(_ value: String, typeName: String) throws -> UInt64 {
965+
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
966+
967+
if trimmed == "unlimited" || trimmed == "-1" {
968+
return UInt64.max
969+
}
970+
971+
guard let parsed = UInt64(trimmed) else {
972+
throw ContainerizationError(
973+
.invalidArgument,
974+
message: "invalid ulimit value '\(value)' for '\(typeName)': must be a non-negative integer or 'unlimited'"
975+
)
976+
}
977+
978+
return parsed
979+
}
980+
870981
// MARK: Miscellaneous
871982

872983
public static func parseBool(string: String) -> Bool? {

‎Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,92 @@ class TestCLIRunCommand1: CLITest {
201201
return
202202
}
203203
}
204+
205+
@Test func testRunCommandUlimitNofile() throws {
206+
do {
207+
let name = getTestName()
208+
let softLimit = "1024"
209+
let hardLimit = "2048"
210+
try doLongRun(name: name, args: ["--ulimit", "nofile=\(softLimit):\(hardLimit)"])
211+
defer {
212+
try? doStop(name: name)
213+
}
214+
215+
let inspectResp = try inspectContainer(name)
216+
let rlimits = inspectResp.configuration.initProcess.rlimits
217+
let nofileRlimit = rlimits.first { $0.limit == "RLIMIT_NOFILE" }
218+
#expect(nofileRlimit != nil, "expected RLIMIT_NOFILE to be set")
219+
#expect(nofileRlimit?.soft == UInt64(softLimit), "expected soft limit \(softLimit), got \(nofileRlimit?.soft ?? 0)")
220+
#expect(nofileRlimit?.hard == UInt64(hardLimit), "expected hard limit \(hardLimit), got \(nofileRlimit?.hard ?? 0)")
221+
222+
var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -n"])
223+
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
224+
#expect(output == softLimit, "expected ulimit -n to return \(softLimit), got \(output)")
225+
226+
try doStop(name: name)
227+
} catch {
228+
Issue.record("failed to run container \(error)")
229+
return
230+
}
231+
}
232+
233+
@Test func testRunCommandUlimitNproc() throws {
234+
do {
235+
let name = getTestName()
236+
let limit = "256"
237+
try doLongRun(name: name, args: ["--ulimit", "nproc=\(limit)"])
238+
defer {
239+
try? doStop(name: name)
240+
}
241+
let inspectResp = try inspectContainer(name)
242+
let rlimits = inspectResp.configuration.initProcess.rlimits
243+
let nprocRlimit = rlimits.first { $0.limit == "RLIMIT_NPROC" }
244+
#expect(nprocRlimit != nil, "expected RLIMIT_NPROC to be set")
245+
#expect(nprocRlimit?.soft == UInt64(limit), "expected soft limit \(limit), got \(nprocRlimit?.soft ?? 0)")
246+
#expect(nprocRlimit?.hard == UInt64(limit), "expected hard limit \(limit), got \(nprocRlimit?.hard ?? 0)")
247+
248+
var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -u"])
249+
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
250+
#expect(output == limit, "expected ulimit -u to return \(limit), got \(output)")
251+
252+
try doStop(name: name)
253+
} catch {
254+
Issue.record("failed to run container \(error)")
255+
return
256+
}
257+
}
258+
259+
@Test func testRunCommandMultipleUlimits() throws {
260+
do {
261+
let name = getTestName()
262+
try doLongRun(
263+
name: name,
264+
args: [
265+
"--ulimit", "nofile=1024:2048",
266+
"--ulimit", "nproc=512",
267+
"--ulimit", "stack=8388608",
268+
])
269+
defer {
270+
try? doStop(name: name)
271+
}
272+
let inspectResp = try inspectContainer(name)
273+
let rlimits = inspectResp.configuration.initProcess.rlimits
274+
#expect(rlimits.count == 3, "expected 3 rlimits, got \(rlimits.count)")
275+
276+
let nofile = rlimits.first { $0.limit == "RLIMIT_NOFILE" }
277+
let nproc = rlimits.first { $0.limit == "RLIMIT_NPROC" }
278+
let stack = rlimits.first { $0.limit == "RLIMIT_STACK" }
279+
280+
#expect(nofile != nil && nofile?.soft == 1024 && nofile?.hard == 2048)
281+
#expect(nproc != nil && nproc?.soft == 512 && nproc?.hard == 512)
282+
#expect(stack != nil && stack?.soft == 8_388_608 && stack?.hard == 8_388_608)
283+
284+
try doStop(name: name)
285+
} catch {
286+
Issue.record("failed to run container \(error)")
287+
return
288+
}
289+
}
204290
}
205291

206292
class TestCLIRunCommand2: CLITest {

0 commit comments

Comments
 (0)