Skip to content

Commit 97167bb

Browse files
committed
Show plugin settings in system property list
1 parent c8b4fd7 commit 97167bb

3 files changed

Lines changed: 278 additions & 4 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerPersistence
18+
import Foundation
19+
import SystemPackage
20+
import TOML
21+
22+
struct SystemProperties: Encodable {
23+
let build: BuildConfig
24+
let container: ContainerConfig
25+
let dns: DNSConfig
26+
let kernel: KernelConfig
27+
let machine: MachineConfig
28+
let network: NetworkConfig
29+
let registry: RegistryConfig
30+
let vminit: VminitConfig
31+
let plugin: [String: PluginPropertyValue]?
32+
33+
init(config: ContainerSystemConfig, plugin: [String: PluginPropertyValue]) {
34+
self.build = config.build
35+
self.container = config.container
36+
self.dns = config.dns
37+
self.kernel = config.kernel
38+
self.machine = config.machine
39+
self.network = config.network
40+
self.registry = config.registry
41+
self.vminit = config.vminit
42+
self.plugin = plugin.isEmpty ? nil : plugin
43+
}
44+
}
45+
46+
enum PluginProperties {
47+
static func load(configurationFiles: [FilePath] = ConfigurationLoader.defaultConfigFiles()) throws -> [String: PluginPropertyValue] {
48+
var pluginProperties: [String: PluginPropertyValue] = [:]
49+
for path in configurationFiles.reversed() {
50+
guard FileManager.default.fileExists(atPath: path.string) else {
51+
continue
52+
}
53+
let data = try Data(contentsOf: URL(filePath: path.string))
54+
guard let content = String(data: data, encoding: .utf8) else {
55+
continue
56+
}
57+
let document = try TOMLDecoder().decode(TOMLDocument.self, from: content)
58+
guard case .table(let pluginTable)? = document.values["plugin"] else {
59+
continue
60+
}
61+
pluginProperties = merge(pluginProperties, overlay: pluginTable)
62+
}
63+
return pluginProperties
64+
}
65+
66+
private static func merge(
67+
_ base: [String: PluginPropertyValue],
68+
overlay: [String: PluginPropertyValue]
69+
) -> [String: PluginPropertyValue] {
70+
base.merging(overlay) { old, new in
71+
if case .table(let oldTable) = old, case .table(let newTable) = new {
72+
return .table(merge(oldTable, overlay: newTable))
73+
}
74+
return new
75+
}
76+
}
77+
}
78+
79+
enum PluginPropertyValue: Codable, Equatable {
80+
case string(String)
81+
case integer(Int64)
82+
case float(Double)
83+
case boolean(Bool)
84+
case array([PluginPropertyValue])
85+
case table([String: PluginPropertyValue])
86+
87+
init(from decoder: Decoder) throws {
88+
let container = try decoder.singleValueContainer()
89+
if let table = try? container.decode([String: PluginPropertyValue].self) {
90+
self = .table(table)
91+
} else if let array = try? container.decode([PluginPropertyValue].self) {
92+
self = .array(array)
93+
} else if let string = try? container.decode(String.self) {
94+
self = .string(string)
95+
} else if let integer = try? container.decode(Int64.self) {
96+
self = .integer(integer)
97+
} else if let float = try? container.decode(Double.self) {
98+
self = .float(float)
99+
} else if let boolean = try? container.decode(Bool.self) {
100+
self = .boolean(boolean)
101+
} else {
102+
throw DecodingError.typeMismatch(
103+
PluginPropertyValue.self,
104+
DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported plugin property value")
105+
)
106+
}
107+
}
108+
109+
func encode(to encoder: Encoder) throws {
110+
var container = encoder.singleValueContainer()
111+
switch self {
112+
case .string(let value):
113+
try container.encode(value)
114+
case .integer(let value):
115+
try container.encode(value)
116+
case .float(let value):
117+
try container.encode(value)
118+
case .boolean(let value):
119+
try container.encode(value)
120+
case .array(let value):
121+
try container.encode(value)
122+
case .table(let value):
123+
try container.encode(value)
124+
}
125+
}
126+
}
127+
128+
private struct TOMLDocument: Decodable {
129+
let values: [String: PluginPropertyValue]
130+
131+
init(from decoder: Decoder) throws {
132+
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
133+
var values: [String: PluginPropertyValue] = [:]
134+
for key in container.allKeys {
135+
values[key.stringValue] = try container.decode(PluginPropertyValue.self, forKey: key)
136+
}
137+
self.values = values
138+
}
139+
}
140+
141+
private struct DynamicCodingKey: CodingKey {
142+
let stringValue: String
143+
let intValue: Int?
144+
145+
init?(stringValue: String) {
146+
self.stringValue = stringValue
147+
self.intValue = nil
148+
}
149+
150+
init?(intValue: Int) {
151+
self.stringValue = "\(intValue)"
152+
self.intValue = intValue
153+
}
154+
}

Sources/ContainerCommands/System/Property/PropertyList.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
import ArgumentParser
1818
import ContainerAPIClient
1919
import ContainerPersistence
20-
import ContainerPlugin
2120
import Foundation
21+
import SystemPackage
2222

2323
enum ListOutputFormat: String, Decodable, ExpressibleByArgument {
2424
case json
@@ -42,11 +42,20 @@ extension Application {
4242
public init() {}
4343

4444
public func run() async throws {
45-
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
45+
let health = try await ClientHealthCheck.ping(timeout: .seconds(10))
46+
let configurationFiles = [
47+
ConfigurationLoader.configurationFile(in: FilePath(health.appRoot.path(percentEncoded: false)), of: .appRoot),
48+
ConfigurationLoader.configurationFile(in: FilePath(health.installRoot.path(percentEncoded: false)), of: .installRoot),
49+
]
50+
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: configurationFiles)
51+
let properties = try SystemProperties(
52+
config: containerSystemConfig,
53+
plugin: PluginProperties.load(configurationFiles: configurationFiles)
54+
)
4655
let output =
4756
switch format {
48-
case .json: try Output.renderJSON(containerSystemConfig)
49-
case .toml: try Output.renderTOML(containerSystemConfig)
57+
case .json: try Output.renderJSON(properties)
58+
case .toml: try Output.renderTOML(properties)
5059
}
5160
Output.emit(output)
5261
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import SystemPackage
19+
import Testing
20+
21+
@testable import ContainerCommands
22+
23+
struct PluginPropertiesTests {
24+
@Test func renderSystemPropertiesIncludesPluginProperties() throws {
25+
let properties = SystemProperties(
26+
config: .init(),
27+
plugin: [
28+
"example": .table([
29+
"enabled": .boolean(true),
30+
"name": .string("demo"),
31+
])
32+
]
33+
)
34+
35+
let toml = try Output.renderTOML(properties)
36+
#expect(toml.contains("[plugin.example]"))
37+
#expect(toml.contains("enabled = true"))
38+
#expect(toml.contains(#"name = "demo""#))
39+
40+
let json = try Output.renderJSON(properties)
41+
#expect(json.contains(#""plugin":{"example":{"enabled":true,"name":"demo"}}"#))
42+
}
43+
44+
@Test func loadPluginProperties() throws {
45+
let file = try temporaryConfig(
46+
"""
47+
[plugin.example]
48+
enabled = true
49+
retries = 3
50+
name = "demo"
51+
labels = ["a", "b"]
52+
53+
[plugin.example.nested]
54+
mode = "fast"
55+
"""
56+
)
57+
defer { try? FileManager.default.removeItem(atPath: file.string) }
58+
59+
let properties = try PluginProperties.load(configurationFiles: [file])
60+
61+
#expect(
62+
properties == [
63+
"example": .table([
64+
"enabled": .boolean(true),
65+
"retries": .integer(3),
66+
"name": .string("demo"),
67+
"labels": .array([.string("a"), .string("b")]),
68+
"nested": .table(["mode": .string("fast")]),
69+
])
70+
]
71+
)
72+
}
73+
74+
@Test func higherPrecedencePluginPropertiesOverrideLowerPrecedence() throws {
75+
let lower = try temporaryConfig(
76+
"""
77+
[plugin.example]
78+
enabled = false
79+
retries = 1
80+
"""
81+
)
82+
let higher = try temporaryConfig(
83+
"""
84+
[plugin.example]
85+
enabled = true
86+
"""
87+
)
88+
defer {
89+
try? FileManager.default.removeItem(atPath: lower.string)
90+
try? FileManager.default.removeItem(atPath: higher.string)
91+
}
92+
93+
let properties = try PluginProperties.load(configurationFiles: [higher, lower])
94+
95+
#expect(
96+
properties == [
97+
"example": .table([
98+
"enabled": .boolean(true),
99+
"retries": .integer(1),
100+
])
101+
]
102+
)
103+
}
104+
105+
private func temporaryConfig(_ content: String) throws -> FilePath {
106+
let url = FileManager.default.temporaryDirectory
107+
.appendingPathComponent("container-plugin-properties-\(UUID().uuidString).toml")
108+
try content.write(to: url, atomically: true, encoding: .utf8)
109+
return FilePath(url.path(percentEncoded: false))
110+
}
111+
}

0 commit comments

Comments
 (0)