mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user