mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Switch NetworksService, VolumesService, EntityStore to FilePath. (#1493)
- Closes #1485.
This commit is contained in:
+30
-14
@@ -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")
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user