Skip to content

Commit 615591d

Browse files
committed
Refactor: add RegistryResource
Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com>
1 parent c9f81ca commit 615591d

3 files changed

Lines changed: 273 additions & 24 deletions

File tree

Sources/ContainerCommands/Registry/RegistryList.swift

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import ArgumentParser
1818
import ContainerAPIClient
19+
import ContainerResource
1920
import ContainerizationOCI
2021
import ContainerizationOS
2122
import Foundation
@@ -39,28 +40,26 @@ extension Application {
3940

4041
public func run() async throws {
4142
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
42-
let registries = try keychain.list()
43+
let registryInfos = try keychain.list()
44+
let registries = registryInfos.map { RegistryResource(from: $0) }
45+
4346
try printRegistries(registries: registries, format: format)
4447
}
4548

4649
private func createHeader() -> [[String]] {
4750
[["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]]
4851
}
4952

50-
private func printRegistries(registries: [RegistryInfo], format: ListFormat) throws {
53+
private func printRegistries(registries: [RegistryResource], format: ListFormat) throws {
5154
if format == .json {
52-
let printables = registries.map {
53-
PrintableRegistry($0)
54-
}
55-
let data = try JSONEncoder().encode(printables)
55+
let data = try JSONEncoder().encode(registries)
5656
print(String(decoding: data, as: UTF8.self))
57-
5857
return
5958
}
6059

6160
if self.quiet {
6261
registries.forEach {
63-
print($0.hostname)
62+
print($0.name)
6463
}
6564
return
6665
}
@@ -75,26 +74,13 @@ extension Application {
7574
}
7675
}
7776
}
78-
extension RegistryInfo {
77+
extension RegistryResource {
7978
fileprivate var asRow: [String] {
8079
[
81-
self.hostname,
80+
self.name,
8281
self.username,
8382
self.modifiedDate.ISO8601Format(),
84-
self.createdDate.ISO8601Format(),
83+
self.creationDate.ISO8601Format(),
8584
]
8685
}
8786
}
88-
struct PrintableRegistry: Codable {
89-
let hostname: String
90-
let username: String
91-
let modifiedDate: Date
92-
let createdDate: Date
93-
94-
init(_ registry: RegistryInfo) {
95-
self.hostname = registry.hostname
96-
self.username = registry.username
97-
self.modifiedDate = registry.modifiedDate
98-
self.createdDate = registry.createdDate
99-
}
100-
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 ContainerizationOS
18+
import Foundation
19+
20+
public struct RegistryResource: ManagedResource {
21+
public var id: String
22+
23+
public var name: String
24+
25+
public let username: String
26+
27+
public var creationDate: Date
28+
29+
public var modifiedDate: Date
30+
31+
public var labels: [String: String]
32+
33+
public static func nameValid(_ name: String) -> Bool {
34+
let pattern = #"^([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(:[0-9]+)?$"#
35+
return name.range(of: pattern, options: .regularExpression) != nil
36+
}
37+
38+
public init(
39+
hostname: String,
40+
username: String,
41+
creationDate: Date,
42+
modifiedDate: Date,
43+
labels: [String: String] = [:]
44+
) {
45+
self.id = hostname
46+
self.name = hostname
47+
self.username = username
48+
self.creationDate = creationDate
49+
self.modifiedDate = modifiedDate
50+
self.labels = labels
51+
}
52+
}
53+
54+
extension RegistryResource {
55+
public init(from registryInfo: RegistryInfo) {
56+
self.init(
57+
hostname: registryInfo.hostname,
58+
username: registryInfo.username,
59+
creationDate: registryInfo.createdDate,
60+
modifiedDate: registryInfo.modifiedDate,
61+
)
62+
}
63+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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 Testing
19+
20+
@testable import ContainerResource
21+
@testable import ContainerizationOS
22+
23+
struct RegistryResourceTests {
24+
25+
// Mock RegistryInfo for testing
26+
func createMockRegistryInfo(
27+
hostname: String = "docker.io",
28+
username: String = "testuser"
29+
) -> RegistryInfo {
30+
RegistryInfo(
31+
hostname: hostname,
32+
username: username,
33+
modifiedDate: Date(timeIntervalSince1970: 1_700_000_000),
34+
createdDate: Date(timeIntervalSince1970: 1_690_000_000)
35+
)
36+
}
37+
38+
@Test("RegistryResource id and name are both hostname")
39+
func testRegistryResourceIdAndName() {
40+
let hostname = "ghcr.io"
41+
let registryInfo = createMockRegistryInfo(hostname: hostname, username: "myuser")
42+
let resource = RegistryResource(from: registryInfo)
43+
44+
#expect(resource.id == hostname, "id should be the hostname")
45+
#expect(resource.name == hostname, "name should be the hostname")
46+
#expect(resource.id == resource.name, "id and name should be identical")
47+
}
48+
49+
@Test("RegistryResource maps RegistryInfo correctly")
50+
func testRegistryResourceMapping() {
51+
let hostname = "registry.example.com:5000"
52+
let username = "developer"
53+
let registryInfo = createMockRegistryInfo(hostname: hostname, username: username)
54+
55+
let resource = RegistryResource(from: registryInfo)
56+
57+
#expect(resource.id == hostname)
58+
#expect(resource.name == hostname)
59+
#expect(resource.username == username)
60+
#expect(resource.creationDate == registryInfo.createdDate)
61+
#expect(resource.modifiedDate == registryInfo.modifiedDate)
62+
#expect(resource.labels.isEmpty, "default labels should be empty")
63+
}
64+
65+
@Test("RegistryResource implements ManagedResource")
66+
func testManagedResourceConformance() {
67+
let registryInfo = createMockRegistryInfo()
68+
let resource = RegistryResource(from: registryInfo)
69+
70+
// Test that it conforms to ManagedResource protocol
71+
let managedResource: any ManagedResource = resource
72+
#expect(managedResource.id == "docker.io")
73+
#expect(managedResource.name == "docker.io")
74+
#expect(managedResource.creationDate == registryInfo.createdDate)
75+
#expect(managedResource.labels.isEmpty)
76+
}
77+
78+
@Test("RegistryResource is Codable - JSON encoding")
79+
func testRegistryResourceJSONEncoding() throws {
80+
let hostname = "docker.io"
81+
let username = "testuser"
82+
let registryInfo = createMockRegistryInfo(hostname: hostname, username: username)
83+
let resource = RegistryResource(from: registryInfo)
84+
85+
// Encode to JSON
86+
let encoder = JSONEncoder()
87+
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
88+
let jsonData = try encoder.encode(resource)
89+
let jsonString = String(data: jsonData, encoding: .utf8)!
90+
91+
// Verify JSON contains expected fields
92+
#expect(jsonString.contains("\"id\""), "JSON should contain id field")
93+
#expect(jsonString.contains("\"name\""), "JSON should contain name field")
94+
#expect(jsonString.contains("\"username\""), "JSON should contain username field")
95+
#expect(jsonString.contains("\"creationDate\""), "JSON should contain creationDate field")
96+
#expect(jsonString.contains("\"modifiedDate\""), "JSON should contain modifiedDate field")
97+
#expect(jsonString.contains(hostname), "JSON should contain the hostname")
98+
#expect(jsonString.contains(username), "JSON should contain the username")
99+
}
100+
101+
@Test("RegistryResource is Codable - round trip")
102+
func testRegistryResourceRoundTrip() throws {
103+
let hostname = "ghcr.io"
104+
let username = "developer"
105+
let registryInfo = createMockRegistryInfo(hostname: hostname, username: username)
106+
let original = RegistryResource(from: registryInfo)
107+
108+
// Encode
109+
let encoder = JSONEncoder()
110+
let jsonData = try encoder.encode(original)
111+
112+
// Decode
113+
let decoder = JSONDecoder()
114+
let decoded = try decoder.decode(RegistryResource.self, from: jsonData)
115+
116+
// Verify
117+
#expect(decoded.id == original.id)
118+
#expect(decoded.name == original.name)
119+
#expect(decoded.username == original.username)
120+
#expect(decoded.creationDate.timeIntervalSince1970 == original.creationDate.timeIntervalSince1970)
121+
#expect(decoded.modifiedDate.timeIntervalSince1970 == original.modifiedDate.timeIntervalSince1970)
122+
#expect(decoded.labels == original.labels)
123+
}
124+
125+
@Test("RegistryResource nameValid validates hostnames")
126+
func testRegistryResourceNameValidation() {
127+
// Valid hostnames
128+
#expect(RegistryResource.nameValid("docker.io"), "docker.io should be valid")
129+
#expect(RegistryResource.nameValid("ghcr.io"), "ghcr.io should be valid")
130+
#expect(RegistryResource.nameValid("registry.example.com"), "registry.example.com should be valid")
131+
#expect(RegistryResource.nameValid("localhost:5000"), "localhost:5000 should be valid")
132+
#expect(RegistryResource.nameValid("registry.k8s.io"), "registry.k8s.io should be valid")
133+
134+
// Invalid hostnames
135+
#expect(!RegistryResource.nameValid(""), "empty string should be invalid")
136+
#expect(!RegistryResource.nameValid("-invalid.com"), "hostname starting with hyphen should be invalid")
137+
#expect(!RegistryResource.nameValid("invalid-.com"), "hostname ending with hyphen should be invalid")
138+
}
139+
140+
@Test("RegistryResource can have labels")
141+
func testRegistryResourceWithLabels() {
142+
let hostname = "docker.io"
143+
let username = "testuser"
144+
let labels = [
145+
"environment": "production",
146+
ResourceLabelKeys.role: "primary",
147+
]
148+
149+
let resource = RegistryResource(
150+
hostname: hostname,
151+
username: username,
152+
creationDate: Date(),
153+
modifiedDate: Date(),
154+
labels: labels
155+
)
156+
157+
#expect(resource.labels.count == 2)
158+
#expect(resource.labels["environment"] == "production")
159+
#expect(resource.labels[ResourceLabelKeys.role] == "primary")
160+
}
161+
162+
@Test("RegistryResource handles hostname with port")
163+
func testRegistryResourceWithPort() {
164+
let hostname = "localhost:5000"
165+
let registryInfo = createMockRegistryInfo(hostname: hostname, username: "admin")
166+
let resource = RegistryResource(from: registryInfo)
167+
168+
#expect(resource.id == hostname)
169+
#expect(resource.name == hostname)
170+
#expect(RegistryResource.nameValid(hostname))
171+
}
172+
173+
@Test("Multiple RegistryResources can be encoded as array")
174+
func testMultipleRegistryResourcesJSONEncoding() throws {
175+
let registries = [
176+
RegistryResource(from: createMockRegistryInfo(hostname: "docker.io", username: "user1")),
177+
RegistryResource(from: createMockRegistryInfo(hostname: "ghcr.io", username: "user2")),
178+
RegistryResource(from: createMockRegistryInfo(hostname: "quay.io", username: "user3")),
179+
]
180+
181+
let encoder = JSONEncoder()
182+
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
183+
let jsonData = try encoder.encode(registries)
184+
let jsonString = String(data: jsonData, encoding: .utf8)!
185+
186+
// Verify all hostnames are present
187+
#expect(jsonString.contains("docker.io"))
188+
#expect(jsonString.contains("ghcr.io"))
189+
#expect(jsonString.contains("quay.io"))
190+
191+
// Verify all usernames are present
192+
#expect(jsonString.contains("user1"))
193+
#expect(jsonString.contains("user2"))
194+
#expect(jsonString.contains("user3"))
195+
196+
// Print for manual verification
197+
print("Encoded JSON:")
198+
print(jsonString)
199+
}
200+
}

0 commit comments

Comments
 (0)