diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 49112ae8..85c0e267 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -42,7 +42,8 @@ extension EXT4 { blockSize / groupDescriptorSize } - private var blockCount: UInt32 { + // internally accessed by journal setup + var blockCount: UInt32 { ((size - 1) / blockSize) + 1 } @@ -64,7 +65,9 @@ extension EXT4 { /// - devicePath: The path to the block device where the ext4 filesystem will be created. /// - blockSize: The filesystem block size in bytes. Must be a power of two in the set /// {1024, 2048, 4096}. Defaults to 4096. + /// - blockSize: The filesystem block size. /// - minDiskSize: The minimum disk size required for the formatted filesystem. + /// - journal: The JBD2 journal size and mode, or nil for an unjournalled filesystem. /// /// - Note: This ext4 formatter is designed for creating block devices out of container images and does not support all the /// features and options available in the full ext4 filesystem implementation. It focuses @@ -72,7 +75,7 @@ extension EXT4 { /// /// - Important: Ensure that the destination block device is accessible and has sufficient permissions /// for formatting. The formatting process will erase all existing data on the device. - public init(_ devicePath: FilePath, blockSize: UInt32 = 4096, minDiskSize: UInt64 = 256.kib()) throws { + public init(_ devicePath: FilePath, blockSize: UInt32 = 4096, minDiskSize: UInt64 = 256.kib(), journal: JournalConfig? = nil) throws { /// The constructor performs the following steps: /// /// 1. Creates the first 10 inodes: @@ -128,6 +131,7 @@ extension EXT4 { } // step #2 self.tree = FileTree(EXT4.RootInode, "/") + self.journalConfig = journal // skip past the superblock and block descriptor table try self.seek(block: self.groupDescriptorBlocks + 1) // lost+found directory is required for e2fsck to pass @@ -610,6 +614,19 @@ extension EXT4 { } breadthWiseChildTree.append(contentsOf: child.pointee.children.map { (child, $0) }) } + + // Generate UUID once; shared by filesystem superblock and JBD2 superblock. + let filesystemUUID = UUID().uuid + + // Journal init MUST precede optimizeBlockGroupLayout() and commitInodeTable(). + // Reason 1: optimizeBlockGroupLayout reads self.currentBlock — journal blocks + // must already be written to be counted in the layout calculation. + // Reason 2: commitInodeTable writes inode 8 to disk — setupJournalInode must + // have updated self.inodes[7] in memory first. + if let config = journalConfig { + try initializeJournal(config: config, filesystemUUID: filesystemUUID) + } + let blockGroupSize = optimizeBlockGroupLayout(blocks: self.currentBlock, inodes: UInt32(self.inodes.count)) let inodeTableOffset = try self.commitInodeTable( blockGroups: blockGroupSize.blockGroups, @@ -867,7 +884,6 @@ extension EXT4 { superblock.firstInode = EXT4.FirstInode superblock.lpfInode = EXT4.LostAndFoundInode superblock.inodeSize = UInt16(EXT4.InodeSize) - superblock.featureCompat = CompatFeature.sparseSuper2 | CompatFeature.extAttr superblock.featureIncompat = IncompatFeature.filetype | IncompatFeature.extents | IncompatFeature.flexBg superblock.featureRoCompat = @@ -875,31 +891,78 @@ extension EXT4 { superblock.minExtraIsize = EXT4.ExtraIsize superblock.wantExtraIsize = EXT4.ExtraIsize superblock.logGroupsPerFlex = 31 - superblock.uuid = UUID().uuid + superblock.uuid = filesystemUUID + var compatFeatures: UInt32 = CompatFeature.sparseSuper2 | CompatFeature.extAttr + if let config = journalConfig { + compatFeatures |= CompatFeature.hasJournal.rawValue + superblock.journalInum = EXT4.JournalInode + superblock.journalUUID = filesystemUUID + superblock.journalBlocks = journalInodeBlockBackup() + superblock.journalBackupType = 1 // s_jnl_backup_type: 1 = s_jnl_blocks[] holds a valid inode backup + if let mode = config.defaultMode { + switch mode { + case .writeback: superblock.defaultMountOpts = DefaultMountOpts.journalWriteback + case .ordered: superblock.defaultMountOpts = DefaultMountOpts.journalOrdered + case .journal: superblock.defaultMountOpts = DefaultMountOpts.journalData + } + } + } + superblock.featureCompat = compatFeatures + + // Fields intentionally left at zero: + // s_r_blocks_count_lo: no blocks reserved for root + // s_mtime / s_wtime: never mounted/written; kernel updates on first access + // s_mnt_count / s_max_mnt_count: no forced-fsck-after-N-mounts policy + // s_lastcheck / s_checkinterval: no time-based fsck scheduling + // s_def_resuid / s_def_resgid: reserved blocks owned by uid/gid 0 (root) + // s_block_group_nr: this superblock resides in group 0 + // s_volume_name: no volume label + // s_last_mounted: no recorded prior mount path + // s_algorithm_usage_bitmap: obsolete compression field, not used + // s_prealloc_blocks / s_prealloc_dir_blocks: block preallocation not enabled + // s_reserved_gdt_blocks: online resize not supported + // s_journal_dev: journal is internal (inode 8), not on an external device + // s_last_orphan: fresh filesystem, no pending orphan cleanup + // s_hash_seed / s_def_hash_version: kernel initialises htree hash seed at first mount + // s_first_meta_bg: meta block group feature not enabled + // s_mkfs_time: creation timestamp not recorded + // s_raid_stride / s_mmp_interval / s_mmp_block / s_raid_stripe_width: no RAID or MMP + // s_checksum_type / s_checksum_seed: metadata checksums not enabled (no csum feature bit) + // s_snapshot_*: snapshot feature not enabled + // s_error_count / s_first_error_* / s_last_error_*: fresh filesystem, no recorded errors + // s_usr_quota_inum / s_grp_quota_inum / s_prj_quota_inum: quotas not enabled + // s_overhead_clusters: kernel computes dynamically; zero is always safe + // s_backup_bgs: sparse_super2 active but no secondary backup groups requested + // s_encrypt_algos / s_encrypt_pw_salt: encryption not enabled + // s_checksum: superblock checksum not enabled (no metadata_csum feature bit) + try withUnsafeLittleEndianBytes(of: superblock) { bytes in try self.handle.write(contentsOf: bytes) } try self.handle.write(contentsOf: Array.init(repeating: 0, count: 2048)) } - // MARK: Private Methods and Properties - private var handle: FileHandle - private var inodes: [Ptr] + // MARK: Private and internal methods and properties private var tree: FileTree private var deletedBlocks: [(start: UInt32, end: UInt32)] = [] - private var pos: UInt64 { + // internally accessed by journal setup + var handle: FileHandle + var inodes: [Ptr] + let journalConfig: JournalConfig? + + var pos: UInt64 { guard let offset = try? self.handle.offset() else { return 0 } return offset } - private var currentBlock: UInt32 { + var currentBlock: UInt32 { self.pos / self.blockSize } - private func seek(block: UInt32) throws { + func seek(block: UInt32) throws { try self.handle.seek(toOffset: UInt64(block) * blockSize) } @@ -1028,7 +1091,7 @@ extension EXT4 { } } - private func writeExtents(_ inode: Inode, _ blocks: (start: UInt32, end: UInt32)) throws -> Inode { + func writeExtents(_ inode: Inode, _ blocks: (start: UInt32, end: UInt32)) throws -> Inode { var inode = inode // rest of code assumes that extents MUST go into a new block if self.pos % self.blockSize != 0 { @@ -1233,6 +1296,9 @@ extension EXT4 { case cannotCreateSparseFile(_ path: FilePath) case cannotResizeFS(_ size: UInt64) case invalidBlockSize(_ size: UInt32) + case journalTooSmall(_ size: UInt64) + case journalTooLarge(_ size: UInt64) + case filesystemTooSmallForJournal public var description: String { switch self { case .notDirectory(let path): @@ -1267,6 +1333,12 @@ extension EXT4 { return "cannot resize fs to \(size) bytes" case .invalidBlockSize(let size): return "invalid block size \(size): must be 1024, 2048, or 4096" + case .journalTooSmall(let size): + return "requested journal size \(size) bytes is too small; minimum is \(EXT4.MinJournalBlocks) blocks (JBD2_MIN_JOURNAL_BLOCKS)" + case .journalTooLarge(let size): + return "requested journal size \(size) bytes exceeds half the filesystem size; a journal this large is unlikely to be useful" + case .filesystemTooSmallForJournal: + return "filesystem is too small to accommodate a minimum-sized journal; increase minDiskSize to at least \(2 * EXT4.MinJournalBlocks) blocks" } } } diff --git a/Sources/ContainerizationEXT4/EXT4+Journal.swift b/Sources/ContainerizationEXT4/EXT4+Journal.swift new file mode 100644 index 00000000..5394d27d --- /dev/null +++ b/Sources/ContainerizationEXT4/EXT4+Journal.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization 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 ContainerizationOS +import Foundation + +// JBD2 on-disk format reference: +// https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html + +extension EXT4.Formatter { + /// Entry point called from close() when journaling is enabled. + func initializeJournal( + config: EXT4.JournalConfig, + filesystemUUID: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + ) throws { + let journalBlocks = try calculateJournalSize(requestedSize: config.size, totalBlocks: blockCount) + // Align to block boundary before recording start. + if self.pos % self.blockSize != 0 { + try self.seek(block: self.currentBlock + 1) + } + let journalStartBlock = self.currentBlock + try writeJournalSuperblock(journalBlocks: journalBlocks, filesystemUUID: filesystemUUID) + try zeroJournalBlocks(count: journalBlocks - 1) + try setupJournalInode(startBlock: journalStartBlock, blockCount: journalBlocks) + } + + // MARK: - Private helpers + + private func calculateJournalSize(requestedSize: UInt64?, totalBlocks: UInt32) throws -> UInt32 { + if let size = requestedSize { + let blocks = size / UInt64(self.blockSize) + // JBD2_MIN_JOURNAL_BLOCKS: the kernel refuses to mount with fewer. + // blocks == 0 would also cause a UInt32 underflow in the caller. + guard blocks >= EXT4.MinJournalBlocks else { + throw EXT4.Formatter.Error.journalTooSmall(size) + } + // A journal larger than half the filesystem is unlikely to be useful. In + // writeback or ordered mode only metadata is journaled, so even the 1 GiB + // default ceiling is generous. Only data=journal mode journals data blocks + // too, and even then sizing beyond half the filesystem would be wasteful. + // This is a policy limit, not a kernel hard limit; exceeding it would not + // cause a mount failure. + // Note: totalBlocks derives from minDiskSize; close() may expand the filesystem + // slightly for block group alignment. The check is conservative — the final + // filesystem can only be larger, so this guard never permits an oversized journal. + guard blocks <= UInt64(totalBlocks) / 2 else { + throw EXT4.Formatter.Error.journalTooLarge(size) + } + // Safe: blocks ≤ totalBlocks / 2 ≤ UInt32.max / 2, so narrowing cannot trap. + return UInt32(blocks) + } + // Default sizing: scale with the filesystem, with a floor determined by JBD2_MIN_JOURNAL_BLOCKS + // and a ceiling that follows e2fsprogs convention: 128 MiB for filesystems up to 128 GiB, + // and 1 GiB for larger filesystems. The larger ceiling was introduced in e2fsprogs 1.43.2: + // https://e2fsprogs.sourceforge.net/e2fsprogs-release.html#1.43.2 + let fsBytes = UInt64(totalBlocks) * UInt64(self.blockSize) + let halfFsBytes = fsBytes / 2 + let minBytes: UInt64 = UInt64(EXT4.MinJournalBlocks) * UInt64(self.blockSize) + // Note: totalBlocks derives from minDiskSize; close() may expand the filesystem + // substantially if minDiskSize is small relative to content. This check is + // conservative — the final filesystem can only be larger, so false positives + // (rejecting a journal that would have fit) are possible but false negatives are not. + guard minBytes <= halfFsBytes else { + throw EXT4.Formatter.Error.filesystemTooSmallForJournal + } + let scaledBytes = fsBytes / 64 // 1/64th of the filesystem, matching e2fsprogs defaults + let maxBytes: UInt64 = min(fsBytes > 128.gib() ? 1.gib() : 128.mib(), halfFsBytes) + let clampedBytes = min(max(scaledBytes, minBytes), maxBytes) + // Safe: clampedBytes ≤ 1 GiB and blockSize ≥ 1, so the quotient fits in UInt32. + return UInt32(clampedBytes / UInt64(self.blockSize)) + } + + private func writeJournalSuperblock( + journalBlocks: UInt32, + filesystemUUID: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + ) throws { + // Safe: blockSize is UInt32; widening to Int (64-bit on all supported platforms) never truncates. + var buf = [UInt8](repeating: 0, count: Int(self.blockSize)) + + func writeU32(_ value: UInt32, at offset: Int) { + buf[offset] = UInt8((value >> 24) & 0xFF) + buf[offset + 1] = UInt8((value >> 16) & 0xFF) + buf[offset + 2] = UInt8((value >> 8) & 0xFF) + buf[offset + 3] = UInt8(value & 0xFF) + } + + // JBD2 block header (§3.6.3): https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html#block-header + writeU32(EXT4.JournalMagic, at: 0x00) // h_magic + writeU32(4, at: 0x04) // h_blocktype = superblock v2 + writeU32(1, at: 0x08) // h_sequence + + // JBD2 superblock body (§3.6.4): https://www.kernel.org/doc/html/latest/filesystems/ext4/journal.html#super-block + writeU32(self.blockSize, at: 0x0C) // s_blocksize + writeU32(journalBlocks, at: 0x10) // s_maxlen + writeU32(1, at: 0x14) // s_first (first usable block) + writeU32(1, at: 0x18) // s_sequence + // 0x1C s_start: left zero — kernel treats zero as "journal empty, begin at s_first" + // 0x20 s_errno: left zero — no prior abort error + // 0x24 s_feature_compat: left zero — no optional features (e.g. data-block checksums) + // 0x28 s_feature_incompat: left zero — non-zero unrecognised flags would cause mount refusal + // 0x2C s_feature_ro_compat: left zero — no flags defined by the spec + + // s_uuid at 0x30 (16 bytes) + let uuidBytes = [ + filesystemUUID.0, filesystemUUID.1, filesystemUUID.2, filesystemUUID.3, + filesystemUUID.4, filesystemUUID.5, filesystemUUID.6, filesystemUUID.7, + filesystemUUID.8, filesystemUUID.9, filesystemUUID.10, filesystemUUID.11, + filesystemUUID.12, filesystemUUID.13, filesystemUUID.14, filesystemUUID.15, + ] + buf[0x30..<0x40] = uuidBytes[...] + + writeU32(1, at: 0x40) // s_nr_users + + let maxTrans = min(journalBlocks / 4, 32768) + writeU32(maxTrans, at: 0x48) // s_max_transaction + writeU32(maxTrans, at: 0x4C) // s_max_trans_data + + // s_users[0] at 0x100 (first entry of 768-byte users array) + buf[0x100..<0x110] = uuidBytes[...] + + try self.handle.write(contentsOf: buf) + } + + private func zeroJournalBlocks(count: UInt32) throws { + guard count > 0 else { return } + let chunkSize = 1.mib() + // Safe: both operands are UInt32, so their product peaks at ~17 TiB, which fits + // in Int64 (the width of Int on all 64-bit Apple platforms). + let totalBytes = Int(count) * Int(self.blockSize) + let zeroBuf = [UInt8](repeating: 0, count: min(Int(chunkSize), totalBytes)) + var remaining = totalBytes + while remaining > 0 { + let toWrite = min(zeroBuf.count, remaining) + try self.handle.write(contentsOf: zeroBuf[0.. ( + UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, + UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, + UInt32 + ) { + let ji = self.inodes[Int(EXT4.JournalInode) - 1].pointee + // s_jnl_blocks layout (§4.1.2): first 15 words = i_block[] extent-tree data, + // 16th word (index 15) = i_size_high, 17th word (index 16) = i_size. + var words = [UInt32](repeating: 0, count: 17) + withUnsafeBytes(of: ji.block) { bytes in + for i in 0..<15 { + words[i] = bytes.load(fromByteOffset: i * 4, as: UInt32.self) + } + } + words[15] = ji.sizeHigh // i_size_high (16th element per spec) + words[16] = ji.sizeLow // i_size (17th element per spec) + return ( + words[0], words[1], words[2], words[3], + words[4], words[5], words[6], words[7], + words[8], words[9], words[10], words[11], + words[12], words[13], words[14], words[15], + words[16] + ) + } +} diff --git a/Sources/ContainerizationEXT4/EXT4+Types.swift b/Sources/ContainerizationEXT4/EXT4+Types.swift index be4da74a..12eb7b68 100644 --- a/Sources/ContainerizationEXT4/EXT4+Types.swift +++ b/Sources/ContainerizationEXT4/EXT4+Types.swift @@ -245,6 +245,16 @@ extension EXT4 { public var checksum: UInt32 = 0 } + static let JournalMagic: UInt32 = 0xC03B_3998 + static let JournalInode: InodeNumber = 8 + static let MinJournalBlocks: UInt32 = 1024 // JBD2_MIN_JOURNAL_BLOCKS + + struct DefaultMountOpts { + static let journalData: UInt32 = 0x0020 // data=journal + static let journalOrdered: UInt32 = 0x0040 // data=ordered + static let journalWriteback: UInt32 = 0x0060 // data=writeback + } + struct CompatFeature { let rawValue: UInt32 diff --git a/Sources/ContainerizationEXT4/EXT4.swift b/Sources/ContainerizationEXT4/EXT4.swift index 9e7b3d61..d32bb8f1 100644 --- a/Sources/ContainerizationEXT4/EXT4.swift +++ b/Sources/ContainerizationEXT4/EXT4.swift @@ -293,6 +293,24 @@ public enum EXT4 { static let MaxBlocksPerExtent: UInt32 = 0x8000 static let MaxFileSize: UInt64 = 128.gib() static let SuperBlockOffset: UInt64 = 1024 + + public struct JournalConfig: Sendable { + public var size: UInt64? + public var defaultMode: JournalMode? + + public enum JournalMode: Sendable { + case writeback + case ordered + case journal + } + + public init(size: UInt64? = nil, defaultMode: JournalMode? = nil) { + self.size = size + self.defaultMode = defaultMode + } + + public static let `default` = JournalConfig() + } } extension EXT4 { diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 84b86356..748d6816 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -3490,6 +3490,132 @@ extension IntegrationSuite { } } + func testWritableLayerJournalWriteback() async throws { + let id = "test-writable-layer-journal-writeback" + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter( + FilePath(writableLayerPath.absolutePath()), + minDiskSize: 512.mib(), + journal: .init(defaultMode: .writeback) + ) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sh", "-c", "echo 'journal writeback' > /tmp/testfile && cat /tmp/testfile"] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "process failed with status \(status)") + } + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal writeback" else { + throw IntegrationError.assert(msg: "unexpected output: \(output)") + } + } + + func testWritableLayerJournalOrdered() async throws { + let id = "test-writable-layer-journal-ordered" + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter( + FilePath(writableLayerPath.absolutePath()), + minDiskSize: 512.mib(), + journal: .init(defaultMode: .ordered) + ) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sh", "-c", "echo 'journal ordered' > /tmp/testfile && cat /tmp/testfile"] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "process failed with status \(status)") + } + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal ordered" else { + throw IntegrationError.assert(msg: "unexpected output: \(output)") + } + } + + func testWritableLayerJournalData() async throws { + let id = "test-writable-layer-journal-data" + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter( + FilePath(writableLayerPath.absolutePath()), + minDiskSize: 512.mib(), + journal: .init(defaultMode: .journal) + ) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sh", "-c", "echo 'journal data' > /tmp/testfile && cat /tmp/testfile"] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "process failed with status \(status)") + } + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal data" else { + throw IntegrationError.assert(msg: "unexpected output: \(output)") + } + } + func testWritableLayerPreservesLowerLayer() async throws { let id = "test-writable-layer-preserves-lower" diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 85341144..4352d2aa 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -341,6 +341,9 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten), Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured), Test("container writable layer", testWritableLayer), + Test("container writable layer journal writeback", testWritableLayerJournalWriteback), + Test("container writable layer journal ordered", testWritableLayerJournalOrdered), + Test("container writable layer journal data", testWritableLayerJournalData), Test("container writable layer preserves lower", testWritableLayerPreservesLowerLayer), Test("container writable layer reads from lower", testWritableLayerReadsFromLower), Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower),