Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
108 changes: 108 additions & 0 deletions Tests/ContainerResourceTests/VolumeJournalConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
27 changes: 26 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
Loading