Adds journal option to container volume create. (#1412)

- Closes #1411.
- Default behavior doesn't change today - with no `journal` option, no
journal gets created.
This commit is contained in:
J Logan
2026-04-22 09:34:24 -07:00
committed by GitHub
parent 2e029c82ce
commit 86611f588b
4 changed files with 248 additions and 4 deletions
@@ -275,14 +275,36 @@ public actor VolumesService {
try fm.createDirectory(atPath: volumePath, withIntermediateDirectories: true, attributes: nil)
}
private func createVolumeImage(for name: String, sizeInBytes: UInt64 = VolumeStorage.defaultVolumeSizeBytes) throws {
static func parseJournalConfig(_ value: String) throws -> EXT4.JournalConfig {
let parts = value.split(separator: ":", maxSplits: 1)
guard let modeSubstring = parts.first else {
throw VolumeError.storageError("invalid journal configuration: expected 'mode' or 'mode:size'")
}
let modeString = String(modeSubstring)
let mode: EXT4.JournalConfig.JournalMode
switch modeString {
case "writeback": mode = .writeback
case "ordered": mode = .ordered
case "journal": mode = .journal
default:
throw VolumeError.storageError("invalid journal mode '\(modeString)': must be writeback, ordered, or journal")
}
let size: UInt64? =
try parts.count > 1
? UInt64(Measurement.parse(parsing: String(parts[1])).converted(to: .bytes).value)
: nil
return EXT4.JournalConfig(size: size, defaultMode: mode)
}
private func createVolumeImage(for name: String, sizeInBytes: UInt64 = VolumeStorage.defaultVolumeSizeBytes, journal: EXT4.JournalConfig? = nil) throws {
let blockPath = blockPath(for: name)
// Use the containerization library's EXT4 formatter
let formatter = try EXT4.Formatter(
FilePath(blockPath),
blockSize: 4096,
minDiskSize: sizeInBytes
minDiskSize: sizeInBytes,
journal: journal
)
try formatter.close()
@@ -323,7 +345,9 @@ public actor VolumesService {
sizeInBytes = VolumeStorage.defaultVolumeSizeBytes
}
try createVolumeImage(for: name, sizeInBytes: sizeInBytes)
let journalConfig = try driverOpts["journal"].map { try Self.parseJournalConfig($0) }
try createVolumeImage(for: name, sizeInBytes: sizeInBytes, journal: journalConfig)
let volume = Volume(
name: name,
@@ -452,4 +452,91 @@ class TestCLIVolumes: CLITest {
#expect(statusFinal == 0)
#expect(!listFinal.contains(volumeName), "volume should be pruned after container is deleted")
}
// MARK: - Journal option tests
@Test func testVolumeCreateWithJournalOrdered() throws {
let testName = getTestName()
let volumeName = "\(testName)_vol"
doVolumeDeleteIfExists(name: volumeName)
defer { doVolumeDeleteIfExists(name: volumeName) }
let (_, _, error, status) = try run(arguments: [
"volume", "create", "--opt", "journal=ordered", volumeName,
])
#expect(status == 0, "volume create with journal=ordered should succeed: \(error)")
let (_, listOutput, _, listStatus) = try run(arguments: ["volume", "list", "--quiet"])
#expect(listStatus == 0)
#expect(listOutput.contains(volumeName), "journaled volume should appear in list")
}
@Test func testVolumeCreateWithJournalAndSize() throws {
let testName = getTestName()
let volumeName = "\(testName)_vol"
doVolumeDeleteIfExists(name: volumeName)
defer { doVolumeDeleteIfExists(name: volumeName) }
let (_, _, error, status) = try run(arguments: [
"volume", "create", "--opt", "journal=writeback:64m", volumeName,
])
#expect(status == 0, "volume create with journal=writeback:64m should succeed: \(error)")
}
@Test func testVolumeCreateWithInvalidJournalModeErrors() throws {
let testName = getTestName()
let volumeName = "\(testName)_vol"
doVolumeDeleteIfExists(name: volumeName)
defer { doVolumeDeleteIfExists(name: volumeName) }
let (_, _, _, status) = try run(arguments: [
"volume", "create", "--opt", "journal=none", volumeName,
])
#expect(status != 0, "volume create with journal=none should fail")
}
@Test func testJournaledVolumeDataPersistence() throws {
let testName = getTestName()
let volumeName = "\(testName)_vol"
let container1Name = "\(testName)_c1"
let container2Name = "\(testName)_c2"
let testData = "journaled-data"
let testFile = "/data/test.txt"
doVolumeDeleteIfExists(name: volumeName)
doRemoveIfExists(name: container1Name, force: true)
doRemoveIfExists(name: container2Name, force: true)
defer {
try? doStop(name: container1Name)
doRemoveIfExists(name: container1Name, force: true)
try? doStop(name: container2Name)
doRemoveIfExists(name: container2Name, force: true)
doVolumeDeleteIfExists(name: volumeName)
}
let (_, _, createError, createStatus) = try run(arguments: [
"volume", "create", "--opt", "journal=ordered", volumeName,
])
guard createStatus == 0 else {
throw CLIError.executionFailed("volume create failed: \(createError)")
}
try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"])
try waitForContainerRunning(container1Name)
_ = try doExec(name: container1Name, cmd: ["sh", "-c", "echo '\(testData)' > \(testFile)"])
try doStop(name: container1Name)
try doLongRun(name: container2Name, args: ["-v", "\(volumeName):/data"])
try waitForContainerRunning(container2Name)
var output = try doExec(name: container2Name, cmd: ["cat", testFile])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == testData, "expected '\(testData)', got '\(output)'")
try doStop(name: container2Name)
try doVolumeDelete(name: volumeName)
}
}
@@ -0,0 +1,108 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 ContainerAPIService
struct VolumeJournalConfigTests {
// MARK: - Valid mode-only inputs
@Test("Parse ordered mode without size")
func parseOrderedModeOnly() throws {
let config = try VolumesService.parseJournalConfig("ordered")
#expect(config.defaultMode == .ordered)
#expect(config.size == nil)
}
@Test("Parse writeback mode without size")
func parseWritebackModeOnly() throws {
let config = try VolumesService.parseJournalConfig("writeback")
#expect(config.defaultMode == .writeback)
#expect(config.size == nil)
}
@Test("Parse journal mode without size")
func parseJournalModeOnly() throws {
let config = try VolumesService.parseJournalConfig("journal")
#expect(config.defaultMode == .journal)
#expect(config.size == nil)
}
// MARK: - Valid mode:size inputs
@Test("Parse ordered mode with mebibyte size")
func parseOrderedWithMebibyteSize() throws {
let config = try VolumesService.parseJournalConfig("ordered:128m")
#expect(config.defaultMode == .ordered)
#expect(config.size == 128 * 1024 * 1024)
}
@Test("Parse writeback mode with gibibyte size")
func parseWritebackWithGibibyteSize() throws {
let config = try VolumesService.parseJournalConfig("writeback:1g")
#expect(config.defaultMode == .writeback)
#expect(config.size == 1024 * 1024 * 1024)
}
@Test("Parse journal mode with kibibyte size")
func parseJournalWithKibibyteSize() throws {
let config = try VolumesService.parseJournalConfig("journal:64m")
#expect(config.defaultMode == .journal)
#expect(config.size == 64 * 1024 * 1024)
}
// MARK: - Invalid mode
@Test("Invalid mode 'none' throws")
func parseNoneModeThrows() {
#expect(throws: (any Error).self) {
_ = try VolumesService.parseJournalConfig("none")
}
}
@Test("Unrecognised mode throws")
func parseUnrecognisedModeThrows() {
#expect(throws: (any Error).self) {
_ = try VolumesService.parseJournalConfig("badmode")
}
}
@Test("Empty string throws")
func parseEmptyStringThrows() {
#expect(throws: (any Error).self) {
_ = try VolumesService.parseJournalConfig("")
}
}
// MARK: - Invalid size
@Test("Non-numeric size throws")
func parseInvalidSizeThrows() {
#expect(throws: (any Error).self) {
_ = try VolumesService.parseJournalConfig("ordered:abc")
}
}
@Test("Unknown size unit throws")
func parseUnknownSizeUnitThrows() {
#expect(throws: (any Error).self) {
_ = try VolumesService.parseJournalConfig("ordered:128x")
}
}
}
+26 -1
View File
@@ -820,7 +820,32 @@ container volume create [--label <label> ...] [--opt <opt> ...] [-s <s>] [--debu
* `--label <label>`: Set metadata for a volume
* `--opt <opt>`: Set driver specific options
* `-s <s>`: Size of the volume in bytes, with optional K, M, G, T, or P suffix
* `-s <s>`: Size of the volume in bytes, with optional K, M, G, T, or P suffix. Takes precedence over `--opt size=` if both are specified.
**Driver Options**
Driver options are passed with `--opt key=value`. The following options are supported for the default `local` driver:
* `size=<value>`: Volume size with optional unit suffix (K, M, G, T, P). Minimum 1 MiB. Equivalent to `-s`; if `-s` is also specified, `-s` takes precedence.
* `journal=<mode>[:<size>]`: Configure ext4 journaling on the volume. `<mode>` must be one of:
* `ordered` — journals metadata only; data is written to disk before its metadata is committed (default kernel behavior, good balance of safety and performance)
* `writeback` — journals metadata only; data ordering relative to metadata commits is not guaranteed (fastest, least safe)
* `journal` — journals both metadata and data (safest, highest write amplification)
An optional `:<size>` suffix sets the journal size (same unit suffixes as `size`). If omitted, the kernel selects a default journal size.
**Examples**
```bash
# create a volume with ordered journaling
container volume create --opt journal=ordered myvolume
# create a volume with writeback journaling and a 64 MiB journal
container volume create --opt journal=writeback:64m myvolume
# create a volume with full data journaling and an explicit volume size
container volume create --opt journal=journal --opt size=10g myvolume
```
**Anonymous Volumes**