diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift index d91bb3e4e..ca638e17e 100644 --- a/Sources/ContainerPersistence/PathUtils.swift +++ b/Sources/ContainerPersistence/PathUtils.swift @@ -58,4 +58,23 @@ public enum PathUtils { } } } + + /// Creates `url` and excludes that new directory from backups. + /// + /// Backup software that reads the item's exclusion attribute, including Time Machine, + /// skips the directory. The attribute is set only when this call creates `url`. + /// An existing directory is left unchanged, so a person who includes it in backups + /// again keeps that choice across later starts. Use this for data that can be + /// recreated, such as image snapshots and container root filesystems, not for volume data. + public static func createDirectoryExcludingFromBackup(_ url: URL, fileManager: FileManager = .default) throws { + let alreadyExists = fileManager.fileExists(atPath: url.path) + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + guard !alreadyExists else { + return + } + var values = URLResourceValues() + values.isExcludedFromBackup = true + var mutableURL = url + try mutableURL.setResourceValues(values) + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b947212f5..d92033796 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -73,7 +73,7 @@ public actor ContainersService { debugHelpers: Bool = false ) throws { let containerRoot = appRoot.appendingPathComponent("containers") - try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) + try PathUtils.createDirectoryExcludingFromBackup(containerRoot) self.exitMonitor = ExitMonitor(log: log) self.lock = AsyncLock(log: log) self.containerRoot = containerRoot diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift index 92198dc44..e600d2377 100644 --- a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import ContainerPersistence import ContainerResource import Containerization import ContainerizationEXT4 @@ -62,7 +63,7 @@ public actor SnapshotStore { self.ingestDir = self.path.appendingPathComponent(Self.ingestDirName) self.unpackStrategy = unpackStrategy self.log = log - try self.fm.createDirectory(at: root, withIntermediateDirectories: true) + try PathUtils.createDirectoryExcludingFromBackup(root, fileManager: self.fm) try self.fm.createDirectory(at: self.ingestDir, withIntermediateDirectories: true) } diff --git a/Tests/ContainerPersistenceTests/PathUtilsTests.swift b/Tests/ContainerPersistenceTests/PathUtilsTests.swift index c38f58a4e..56df35ce5 100644 --- a/Tests/ContainerPersistenceTests/PathUtilsTests.swift +++ b/Tests/ContainerPersistenceTests/PathUtilsTests.swift @@ -74,4 +74,33 @@ struct PathUtilsTests { let path = PathUtils.BaseConfigPath.installRoot.basePath(env: ["CONTAINER_INSTALL_ROOT": "/usr/local"]) #expect(path == FilePath("/usr/local")) } + + @Test func testNewDirectoryIsExcludedAndExistingChoiceIsKept() throws { + let parent = FileManager.default.temporaryDirectory + .appendingPathComponent("container-backup-exclusion-\(UUID().uuidString)", isDirectory: true) + let snapshots = parent.appendingPathComponent("snapshots", isDirectory: true) + let volumes = parent.appendingPathComponent("volumes", isDirectory: true) + defer { try? FileManager.default.removeItem(at: parent) } + + try PathUtils.createDirectoryExcludingFromBackup(snapshots) + try FileManager.default.createDirectory(at: volumes, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: snapshots.appendingPathComponent("snapshot").path, + contents: Data("sparse".utf8) + ) + #expect(try Self.isExcludedFromBackup(snapshots)) + #expect(try !Self.isExcludedFromBackup(volumes)) + + var included = URLResourceValues() + included.isExcludedFromBackup = false + var mutableSnapshots = snapshots + try mutableSnapshots.setResourceValues(included) + try PathUtils.createDirectoryExcludingFromBackup(snapshots) + #expect(try !Self.isExcludedFromBackup(snapshots)) + } + + private static func isExcludedFromBackup(_ url: URL) throws -> Bool { + let values = try url.resourceValues(forKeys: [.isExcludedFromBackupKey]) + return values.isExcludedFromBackup == true + } } diff --git a/docs/resource-usage.md b/docs/resource-usage.md index cb88c7e5b..8c3a6f5a8 100644 --- a/docs/resource-usage.md +++ b/docs/resource-usage.md @@ -114,6 +114,13 @@ data, these images grow; when a container process deletes a file, the freed bloc aren't automatically returned to the host filesystem, so image size doesn't shrink on its own. +Image snapshots and container root filesystems are excluded from backups when +those directories are first created. Backup software that honors this exclusion, +including Time Machine, skips them. Both are large sparse files that can be +recreated by pulling the image or creating the container again. Named volume data +under `volumes/` stays included. An exclusion removed from an existing directory +is left as it is. + Check overall usage with: ```bash