Switch NetworksService, VolumesService, EntityStore to FilePath. (#1493)

- Closes #1485.
This commit is contained in:
J Logan
2026-05-01 14:39:03 -07:00
committed by GitHub
parent e57c755911
commit 9f4e779421
8 changed files with 286 additions and 50 deletions
+30 -14
View File
@@ -189,6 +189,14 @@ let package = Package(
],
path: "Sources/Services/ContainerAPIService/Server"
),
.testTarget(
name: "ContainerAPIServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerResource",
"ContainerSandboxServiceClient",
]
),
.target(
name: "ContainerAPIClient",
dependencies: [
@@ -392,10 +400,20 @@ let package = Package(
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "SystemPackage", package: "swift-system"),
"CVersion",
"ContainerVersion",
]
),
.testTarget(
name: "ContainerPersistenceTests",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerPersistence",
"ContainerTestSupport",
]
),
.target(
name: "ContainerPlugin",
dependencies: [
@@ -412,14 +430,6 @@ let package = Package(
"ContainerPlugin"
]
),
.testTarget(
name: "ContainerSandboxServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerResource",
"ContainerSandboxServiceClient",
]
),
.target(
name: "ContainerXPC",
dependencies: [
@@ -436,6 +446,12 @@ let package = Package(
],
path: "Sources/ContainerOS"
),
.testTarget(
name: "ContainerOSTests",
dependencies: [
"ContainerOS"
]
),
.target(
name: "TerminalProgress",
dependencies: [
@@ -462,12 +478,6 @@ let package = Package(
"DNSServer"
]
),
.testTarget(
name: "ContainerOSTests",
dependencies: [
"ContainerOS"
]
),
.target(
name: "SocketForwarder",
dependencies: [
@@ -506,5 +516,11 @@ let package = Package(
.linkedLibrary("bsm")
]
),
.target(
name: "ContainerTestSupport",
dependencies: [
.product(name: "SystemPackage", package: "swift-system")
]
),
]
)
+6 -2
View File
@@ -297,7 +297,9 @@ extension APIServer {
) async throws -> NetworksService {
log.info("initializing networks service")
let resourceRoot = appRoot.appendingPathComponent("networks")
// TODO: This goes away when we convert our roots to FilePath
let appPath = FilePath(appRoot.absolutePath())
let resourceRoot = appPath.appending("networks")
let service = try await NetworksService(
pluginLoader: pluginLoader,
resourceRoot: resourceRoot,
@@ -341,7 +343,9 @@ extension APIServer {
) throws -> VolumesService {
log.info("initializing volume service")
let resourceRoot = appRoot.appendingPathComponent("volumes")
// TODO: This goes away when we convert our roots to FilePath
let appPath = FilePath(appRoot.absolutePath())
let resourceRoot = appPath.appending("volumes")
let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
let harness = VolumesHarness(service: service, log: log)
+32 -25
View File
@@ -17,8 +17,9 @@
import ContainerizationError
import Foundation
import Logging
import SystemPackage
let metadataFilename: String = "entity.json"
private let metadataFilename: String = "entity.json"
public protocol EntityStore<T> {
associatedtype T: Codable & Identifiable<String> & Sendable
@@ -34,13 +35,13 @@ public protocol EntityStore<T> {
public actor FilesystemEntityStore<T>: EntityStore where T: Codable & Identifiable<String> & Sendable {
typealias Index = [String: T]
private let path: URL
private let path: FilePath
private let type: String
private var index: Index
private let log: Logger
private let encoder = JSONEncoder()
public init(path: URL, type: String, log: Logger) throws {
public init(path: FilePath, type: String, log: Logger) throws {
self.path = path
self.type = type
self.log = log
@@ -52,14 +53,15 @@ public actor FilesystemEntityStore<T>: EntityStore where T: Codable & Identifiab
}
public func create(_ entity: T) async throws {
let metadataUrl = metadataUrl(entity.id)
guard !FileManager.default.fileExists(atPath: metadataUrl.path) else {
let metadataPath = try metadataPath(entity.id)
guard !FileManager.default.fileExists(atPath: metadataPath.string) else {
throw ContainerizationError(.exists, message: "entity \(entity.id) already exist")
}
try FileManager.default.createDirectory(at: entityUrl(entity.id), withIntermediateDirectories: true)
let entityPath = try entityPath(entity.id)
try FileManager.default.createDirectory(atPath: entityPath.string, withIntermediateDirectories: true)
let data = try encoder.encode(entity)
try data.write(to: metadataUrl)
try data.write(to: URL(filePath: metadataPath.string))
index[entity.id] = entity
}
@@ -68,52 +70,57 @@ public actor FilesystemEntityStore<T>: EntityStore where T: Codable & Identifiab
}
public func update(_ entity: T) async throws {
let metadataUrl: URL = metadataUrl(entity.id)
guard FileManager.default.fileExists(atPath: metadataUrl.path) else {
let metadataPath = try metadataPath(entity.id)
guard FileManager.default.fileExists(atPath: metadataPath.string) else {
throw ContainerizationError(.notFound, message: "entity \(entity.id) not found")
}
let data = try encoder.encode(entity)
try data.write(to: metadataUrl)
try data.write(to: URL(filePath: metadataPath.string))
index[entity.id] = entity
}
public func upsert(_ entity: T) async throws {
let metadataUrl: URL = metadataUrl(entity.id)
let entityPath = try entityPath(entity.id)
try FileManager.default.createDirectory(atPath: entityPath.string, withIntermediateDirectories: true)
let metadataPath = try metadataPath(entity.id)
let data = try encoder.encode(entity)
try data.write(to: metadataUrl)
try data.write(to: URL(filePath: metadataPath.string))
index[entity.id] = entity
}
public func delete(_ id: String) async throws {
let metadataUrl = entityUrl(id)
guard FileManager.default.fileExists(atPath: metadataUrl.path) else {
let metadataPath = try entityPath(id)
guard FileManager.default.fileExists(atPath: metadataPath.string) else {
throw ContainerizationError(.notFound, message: "entity \(id) not found")
}
try FileManager.default.removeItem(at: metadataUrl)
try FileManager.default.removeItem(atPath: metadataPath.string)
index.removeValue(forKey: id)
}
public func entityUrl(_ id: String) -> URL {
path.appendingPathComponent(id)
public nonisolated func entityPath(_ id: String) throws -> FilePath {
guard let component = FilePath.Component(id) else {
throw ContainerizationError(.invalidArgument, message: "entity ID \(id) cannot be a path component")
}
return path.appending(component)
}
private static func load(path: URL, log: Logger) throws -> Index {
let directories = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
private static func load(path: FilePath, log: Logger) throws -> Index {
let directories = try FileManager.default.contentsOfDirectory(atPath: path.string)
var index: FilesystemEntityStore<T>.Index = Index()
let decoder = JSONDecoder()
for entityUrl in directories {
for filename in directories {
let metadataPath = path.appending(filename).appending(metadataFilename)
do {
let metadataUrl = entityUrl.appendingPathComponent(metadataFilename)
let data = try Data(contentsOf: metadataUrl)
let data = try Data(contentsOf: URL(filePath: metadataPath.string))
let entity = try decoder.decode(T.self, from: data)
index[entity.id] = entity
} catch {
log.warning(
"failed to load entity, ignoring",
metadata: [
"path": "\(entityUrl)"
"path": "\(metadataPath.string)"
])
}
}
@@ -121,7 +128,7 @@ public actor FilesystemEntityStore<T>: EntityStore where T: Codable & Identifiab
return index
}
private func metadataUrl(_ id: String) -> URL {
entityUrl(id).appendingPathComponent(metadataFilename)
private func metadataPath(_ id: String) throws -> FilePath {
try entityPath(id).appending(metadataFilename)
}
}
@@ -0,0 +1,29 @@
//===----------------------------------------------------------------------===//
// 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 SystemPackage
public struct TemporaryStorage {
public static func withTempDir<T: Sendable>(
_ body: @Sendable (FilePath) async throws -> T
) async throws -> T {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: url) }
return try await body(FilePath(url.path))
}
}
@@ -26,6 +26,7 @@ import ContainerizationExtras
import ContainerizationOS
import Foundation
import Logging
import SystemPackage
public actor NetworksService {
struct NetworkServiceState {
@@ -34,7 +35,7 @@ public actor NetworksService {
}
private let pluginLoader: PluginLoader
private let resourceRoot: URL
private let resourceRoot: FilePath
private let containersService: ContainersService
private let log: Logger
private let debugHelpers: Bool
@@ -48,7 +49,7 @@ public actor NetworksService {
public init(
pluginLoader: PluginLoader,
resourceRoot: URL,
resourceRoot: FilePath,
containersService: ContainersService,
log: Logger,
debugHelpers: Bool = false,
@@ -59,7 +60,7 @@ public actor NetworksService {
self.log = log
self.debugHelpers = debugHelpers
try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true)
self.store = try FilesystemEntityStore<NetworkConfiguration>(
path: resourceRoot,
type: "network",
@@ -484,9 +485,10 @@ public actor NetworksService {
args += ["--variant", variant]
}
try await pluginLoader.registerWithLaunchd(
let entityPath = try store.entityPath(configuration.id)
try pluginLoader.registerWithLaunchd(
plugin: networkPlugin,
pluginStateRoot: store.entityUrl(configuration.id),
pluginStateRoot: URL(filePath: entityPath.string),
args: args,
instanceId: configuration.id
)
@@ -27,7 +27,7 @@ import Synchronization
import SystemPackage
public actor VolumesService {
private let resourceRoot: URL
private let resourceRoot: FilePath
private let store: ContainerPersistence.FilesystemEntityStore<Volume>
private let log: Logger
private let lock = AsyncLock()
@@ -37,8 +37,8 @@ public actor VolumesService {
private static let entityFile = "entity.json"
private static let blockFile = "volume.img"
public init(resourceRoot: URL, containersService: ContainersService, log: Logger) throws {
try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true)
public init(resourceRoot: FilePath, containersService: ContainersService, log: Logger) throws {
try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true)
self.resourceRoot = resourceRoot
self.store = try FilesystemEntityStore<Volume>(path: resourceRoot, type: "volumes", log: log)
self.containersService = containersService
@@ -257,8 +257,9 @@ public actor VolumesService {
return sizeInBytes
}
// FIXME: These don't guarantee that name doesn't have component separators.
private nonisolated func volumePath(for name: String) -> String {
resourceRoot.appendingPathComponent(name).path
resourceRoot.appending(name).string
}
private nonisolated func entityPath(for name: String) -> String {
@@ -0,0 +1,177 @@
//===----------------------------------------------------------------------===//
// 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 ContainerTestSupport
import Foundation
import Logging
import SystemPackage
import Testing
@testable import ContainerPersistence
private struct Item: Codable, Identifiable, Sendable, Equatable {
var id: String
var value: String
}
struct FilesystemEntityStoreTests {
@Test func testListEmpty() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
let items = try await store.list()
#expect(items.isEmpty)
}
}
@Test func testCreateAndRetrieve() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "foo", value: "hello"))
let item = try await store.retrieve("foo")
#expect(item?.value == "hello")
}
}
@Test func testRetrieveNonexistentReturnsNil() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
let result = try await store.retrieve("nope")
#expect(result == nil)
}
}
@Test func testListAfterCreate() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "a", value: "1"))
try await store.create(Item(id: "b", value: "2"))
let items = try await store.list()
#expect(items.count == 2)
#expect(Set(items.map(\.id)) == ["a", "b"])
}
}
@Test func testCreateDuplicateThrows() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "dup", value: "x"))
await #expect(throws: Error.self) {
try await store.create(Item(id: "dup", value: "y"))
}
}
}
@Test func testUpdate() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "x", value: "v1"))
try await store.update(Item(id: "x", value: "v2"))
let item = try await store.retrieve("x")
#expect(item?.value == "v2")
}
}
@Test func testUpdateNonexistentThrows() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
await #expect(throws: Error.self) {
try await store.update(Item(id: "ghost", value: "x"))
}
}
}
@Test func testDelete() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "del", value: "v"))
try await store.delete("del")
let item = try await store.retrieve("del")
#expect(item == nil)
}
}
@Test func testDeleteRemovesDirectory() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "dir", value: "v"))
let entityDir = try store.entityPath("dir")
#expect(FileManager.default.fileExists(atPath: entityDir.string))
try await store.delete("dir")
#expect(!FileManager.default.fileExists(atPath: entityDir.string))
}
}
@Test func testDeleteNonexistentThrows() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
await #expect(throws: Error.self) {
try await store.delete("none")
}
}
}
@Test func testUpsertCreates() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.upsert(Item(id: "u", value: "new"))
let item = try await store.retrieve("u")
#expect(item?.value == "new")
}
}
@Test func testUpsertUpdates() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
try await store.create(Item(id: "u", value: "old"))
try await store.upsert(Item(id: "u", value: "new"))
let item = try await store.retrieve("u")
#expect(item?.value == "new")
}
}
@Test func testPersistenceAcrossReinit() async throws {
try await TemporaryStorage.withTempDir { path in
let store1 = try Self.makeStore(at: path)
try await store1.create(Item(id: "persist", value: "durable"))
let store2 = try Self.makeStore(at: path)
let item = try await store2.retrieve("persist")
#expect(item?.value == "durable")
}
}
@Test func testEntityPathIsIdUnderRoot() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
let entityPath = try store.entityPath("myentity")
#expect(entityPath == path.appending("myentity"))
}
}
@Test func testEntityIdWithSlashThrows() async throws {
try await TemporaryStorage.withTempDir { path in
let store = try Self.makeStore(at: path)
await #expect(throws: Error.self) {
try await store.create(Item(id: "foo/bar", value: "x"))
}
}
}
private static func makeStore(at path: FilePath) throws -> FilesystemEntityStore<Item> {
try FilesystemEntityStore<Item>(path: path, type: "item", log: Logger(label: "test"))
}
}