refactor(runtime): make deployment metadata references explicit

This commit is contained in:
Qiu Yuzhou
2026-10-19 19:59:23 +08:00
parent 8a53575d79
commit 766d1a3d86
7 changed files with 116 additions and 16 deletions
@@ -74,10 +74,11 @@ struct RuntimeFileStore {
let intent = PublishedRuntimeIntent(
sha256: contract.sha256, configurationSHA256: ProxyACLDocument.digest(configurationData),
summary: Redactor.documentSummary(contract.document))
_ = try create(JSONEncoder().encode(intent), kind: "config", suffix: "intent.json")
let intentMetadata = try create(
JSONEncoder().encode(intent), kind: "config", suffix: "intent.json")
let snapshot = RuntimeSupervision(
version: 1, generation: generation, deploymentID: UUID().uuidString,
configuration: configuration,
version: 2, generation: generation, deploymentID: UUID().uuidString,
configuration: configuration, intentMetadata: intentMetadata,
locals: encoded.locals.map {
.init(
inboundProtocol: $0.inboundProtocol, localAddress: $0.localAddress,
@@ -194,7 +195,8 @@ struct RuntimeFileStore {
}
private func publishedIntent(_ snapshot: RuntimeSupervision) -> PublishedRuntimeIntent? {
guard let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.intentFile))
guard
let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.intentMetadata))
else { return nil }
return try? JSONDecoder().decode(PublishedRuntimeIntent.self, from: data)
}
@@ -28,6 +28,8 @@ struct RuntimeSupervision: Codable, Equatable, Sendable {
let generation: UInt64
let deploymentID: String
let configuration: String
/// GUI 私有意图摘要的同代文件;Agent 只保留引用,不读取正文。
let intentMetadata: String
let locals: [Endpoint]
let ipv6Only: Bool?
let authenticationID: String?
@@ -35,13 +37,8 @@ struct RuntimeSupervision: Codable, Equatable, Sendable {
let acl: ACL?
let aclVariants: [String: ACL]
/// GUI 私有意图摘要的同代文件;Agent 只保留引用,不读取正文。
var intentFile: String {
configuration.replacingOccurrences(of: "-sslocal.json", with: "-intent.json")
}
var retainedFiles: Set<String> {
Set([configuration, intentFile] + authenticationFiles + aclVariants.values.map(\.file))
Set([configuration, intentMetadata] + authenticationFiles + aclVariants.values.map(\.file))
.union(acl.map { [$0.file] } ?? [])
}
@@ -51,9 +48,11 @@ struct RuntimeSupervision: Codable, Equatable, Sendable {
}
var hasValidStructure: Bool {
guard version == 1, generation > 0, UUID(uuidString: deploymentID) != nil,
configuration.hasPrefix("config-"), configuration.hasSuffix("-sslocal.json"),
guard version == 2, generation > 0, UUID(uuidString: deploymentID) != nil,
configuration.hasPrefix("config-"), intentMetadata.hasPrefix("config-"),
RuntimePublication.generation(of: configuration) == generation,
RuntimePublication.generation(of: intentMetadata) == generation,
configuration != intentMetadata,
authenticationFiles.allSatisfy({ $0.hasPrefix("auth-") && $0.hasSuffix(".json") }),
aclVariants.values.allSatisfy({ $0.file.hasPrefix("acl-") && $0.file.hasSuffix(".ini") }),
authenticationID.map({ UUID(uuidString: $0) != nil }) ?? true,
@@ -97,7 +97,7 @@ struct RuntimeConvergenceTests {
let name =
resource == "configuration"
? snapshot.configuration
: resource == "intent" ? snapshot.intentFile : try #require(snapshot.acl?.file)
: resource == "intent" ? snapshot.intentMetadata : try #require(snapshot.acl?.file)
try FileManager.default.removeItem(at: fixture.runtime.directory.appendingPathComponent(name))
let count = fixture.agent.registerCount
let restarted = fixture.reopeningController()
@@ -18,6 +18,99 @@ struct RuntimePublicationTests {
}
extension RuntimePublicationTests {
@Test func opaqueConfigurationUsesExplicitMetadataThroughSelectionAndRetirement() throws {
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
defer { try? FileManager.default.removeItem(at: runtime.directory) }
let store = RuntimeFileStore(fileURL: runtime.contract)
let document = ProxyRuntimeFixture.makeDocument()
try store.write(document)
let original = try #require(RuntimePublication.load(at: store.supervisionFileURL))
let bytes = try Data(contentsOf: store.supervisionFileURL)
var object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
let prefix = String(format: "config-%020llu-", original.generation)
let configuration = prefix + "payload.bin"
let metadata = prefix + "gui-metadata.dat"
try FileManager.default.moveItem(
at: runtime.directory.appendingPathComponent(original.intentMetadata),
to: runtime.directory.appendingPathComponent(metadata))
let payload = Data([0x00, 0xff, 0x80, 0x01])
try AtomicFileWriter.write(payload, to: runtime.directory.appendingPathComponent(configuration))
object["configuration"] = configuration
object["intentMetadata"] = metadata
try AtomicFileWriter.write(
JSONSerialization.data(withJSONObject: object), to: store.supervisionFileURL)
let selected = try #require(RuntimePublication.load(at: store.supervisionFileURL))
#expect(store.publishedSummary()?.contains("servers=1") == true)
try RuntimePublication.select(
selected, configurationLink: runtime.contract, aclLink: store.aclFileURL)
#expect(try Data(contentsOf: runtime.contract) == payload)
try store.write(document)
let next = try #require(RuntimePublication.load(at: store.supervisionFileURL))
try RuntimePublication.retire(
before: next, publishedAt: store.supervisionFileURL, protecting: selected.retainedFiles)
for name in [configuration, metadata] {
#expect(
FileManager.default.fileExists(atPath: runtime.directory.appendingPathComponent(name).path))
}
try RuntimePublication.select(
next, configurationLink: runtime.contract, aclLink: store.aclFileURL)
try RuntimePublication.retire(
before: next, publishedAt: store.supervisionFileURL, protecting: [])
for name in [configuration, metadata] {
#expect(
!FileManager.default.fileExists(atPath: runtime.directory.appendingPathComponent(name).path)
)
}
#expect(try store.contains(PreparedRuntimeContract(document)))
}
@Test func metadataReferenceIsRequiredAndMustBeDistinctAndFromTheSameGeneration() throws {
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
defer { try? FileManager.default.removeItem(at: runtime.directory) }
let store = RuntimeFileStore(fileURL: runtime.contract)
try store.write(ProxyRuntimeFixture.makeDocument())
let snapshot = try #require(RuntimePublication.load(at: store.supervisionFileURL))
let bytes = try Data(contentsOf: store.supervisionFileURL)
let invalidReferences: [String?] = [
nil, "../outside.json", snapshot.configuration,
String(format: "config-%020llu-intent.json", snapshot.generation - 1),
String(format: "config-%020llu-intent.json", snapshot.generation + 1),
String(format: "auth-%020llu-intent.json", snapshot.generation),
]
for reference in invalidReferences {
var object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
object["intentMetadata"] = reference
try AtomicFileWriter.write(
JSONSerialization.data(withJSONObject: object), to: store.supervisionFileURL)
#expect(RuntimePublication.metadata(at: store.supervisionFileURL) == nil)
}
}
@Test func missingOrSymlinkedMetadataPreventsDeploymentSelection() throws {
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
defer { try? FileManager.default.removeItem(at: runtime.directory) }
let store = RuntimeFileStore(fileURL: runtime.contract)
try store.write(ProxyRuntimeFixture.makeDocument())
let snapshot = try #require(RuntimePublication.load(at: store.supervisionFileURL))
let metadataURL = runtime.directory.appendingPathComponent(snapshot.intentMetadata)
try FileManager.default.removeItem(at: metadataURL)
#expect(RuntimePublication.load(at: store.supervisionFileURL) == nil)
#expect(throws: (any Error).self) {
try RuntimePublication.select(
snapshot, configurationLink: runtime.contract, aclLink: store.aclFileURL)
}
try FileManager.default.createSymbolicLink(
at: metadataURL,
withDestinationURL: runtime.directory.appendingPathComponent(snapshot.configuration))
#expect(RuntimePublication.load(at: store.supervisionFileURL) == nil)
#expect(throws: (any Error).self) {
try RuntimePublication.select(
snapshot, configurationLink: runtime.contract, aclLink: store.aclFileURL)
}
#expect(!FileManager.default.fileExists(atPath: runtime.contract.path))
}
@Test func routingIntentChangesEvenWhenUpstreamJSONDoesNot() throws {
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
defer { try? FileManager.default.removeItem(at: runtime.directory) }
@@ -123,7 +216,9 @@ extension RuntimePublicationTests {
let store = RuntimeFileStore(fileURL: runtime.contract)
try store.write(ProxyRuntimeFixture.makeDocument())
let bytes = try Data(contentsOf: store.supervisionFileURL)
for (key, value) in [("version", 99 as Any), ("configuration", "../outside.json" as Any)] {
for (key, value) in [
("version", 1 as Any), ("version", 99 as Any), ("configuration", "../outside.json" as Any),
] {
var object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
object[key] = value
try AtomicFileWriter.write(
@@ -44,7 +44,7 @@ extension RuntimeFileStore {
let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.configuration)),
let configuration = try? JSONDecoder().decode(SslocalConfigurationDocument.self, from: data),
let intentData = try? Data(
contentsOf: directoryURL.appendingPathComponent(snapshot.intentFile)),
contentsOf: directoryURL.appendingPathComponent(snapshot.intentMetadata)),
let intent = try? JSONDecoder().decode(PublishedRuntimeIntent.self, from: intentData)
else { return nil }
return PublishedRuntimeConfiguration(
@@ -30,6 +30,10 @@ GUI 不得重新引用退出保留集合的旧文件;准备文件必须先获
配置、认证和 ACL 文件名均包含 20 位十进制代次。认证身份使用随机 UUID;只在凭据变化时换身份和文件,不公开凭据摘要。GUI 为每份配置准备同代 `config-<generation>-intent.json`,保存 GUI 私有意图摘要、最终配置字节摘要与脱敏汇总,用于配置损坏后仍能识别失败意图并独立提供诊断事实;Agent 只保留该引用,不读取正文。该 GUI 私有摘要不进入监管资料、回执或诊断。
监管资料格式第 2 版以必填的 `configuration` 和 `intentMetadata` 分别记录配置与 GUI 私有元数据的具体文件引用,不通过配置文件名推导元数据位置。两个引用必须不同,均以 `config-` 开头并属于本次部署代次;监管校验不限定这两个文件的格式后缀。当前发布端继续生成 `sslocal.json`、`intent.json` 后缀,稳定链接仍为 `sslocal-active.json`。认证与 ACL 的命名和复用规则保持不变。元数据与配置同样必须是已存在的普通文件,缺失时整份部署不可用。发布与回收只依赖显式引用、受管文件名和代次,不解析配置正文。
本次格式变更不提供旧监管资料兼容、迁移或残留处理;开发环境切换前已停止后台进程并清理旧运行产物。未实现密文输入、密钥交接或通用资源框架。
`sslocal.pid` 保留尚未确认退出的子进程身份;Agent 崩溃后若该 PID 仍存在,则拒绝启动第二个实例并报告失败。无法确认退出的进程和保守 PID 复用阻断需要用户检查并显式重试。没有可靠活跃准备者证据时,崩溃遗留的未来代文件暂不回收。
## 失败与验证
@@ -10,4 +10,4 @@ GUI 的内存运行意图与 sslocal JSON 输出分离。上游配置只包含
LAN 认证仍写入现有受保护的明文认证文件。发布 implementation 比较本次内存生成的字节与当前监管资料引用的认证文件,相同则复用文件与随机认证身份,不同或不可读则重新准备。此比较不读取 sslocal 配置,也不增加凭据摘要、版本状态或完整秘密快照。未来认证文件是否加密仍开放;若编码形态变化,在发布 implementation 内调整比较方式,不提前引入通用加密框架。
本决定补充 ADR-0038,沿用其代次、锁、不可变发布、稳定链接、ACL 复用与清理规则,以及 ADR-0037 的失败处理。本次不实施加密、密钥交接、sslocal 应用确认协议或文件引用格式改造。当前回执仍不表示 sslocal 已确认应用重载。
本决定补充 ADR-0038,沿用其代次、锁、不可变发布、稳定链接、ACL 复用与清理规则,以及 ADR-0037 的失败处理。本次不实施加密、密钥交接或 sslocal 应用确认协议。后续文件引用格式改造见 ADR-0038 的第 2 版监管资料:配置与私有元数据采用显式引用,监管校验不依赖配置格式后缀。当前回执仍不表示 sslocal 已确认应用重载。