Skip to content

Commit 3472ad3

Browse files
committed
Runtime+Discovery: Implement Discovery as a ConfigProvider.
This commit implements Discovery, using the `ConfigProvider` protocol so that the `OPA.Runtime` can use it in a plug-and-play fashion. Signed-off-by: Philip Conrad <philip_conrad@apple.com>
1 parent 8066d9a commit 3472ad3

6 files changed

Lines changed: 160 additions & 5 deletions

File tree

Sources/Runtime/BundleLoader.swift

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,29 @@ import Rego
77
extension OPA {
88

99
/// BundleLoader abstracts over the details of retrieving a bundle.
10-
public protocol BundleLoader {
10+
public protocol BundleLoader: Sendable {
1111
/// Needs a public constructor that can build from the config directly.
1212
init(config: OPA.Config, bundleResourceName: String) throws
1313

14+
/// Constructor for loading a discovery bundle.
15+
///
16+
/// The loader should read the bundle location from `config.discovery`
17+
/// rather than `config.bundles`. Loaders that don't support discovery
18+
/// inherit a default implementation that throws.
19+
init(discoveryConfig: OPA.Config) throws
20+
1421
/// Load the bundle, based on the config and any existing state.
1522
mutating func load() async -> Result<OPA.Bundle, any Swift.Error>
1623

1724
/// Compatibility check, based on what's in the OPA config.
1825
static func compatibleWithConfig(config: OPA.Config, bundleResourceName: String) -> Bool
26+
27+
/// Compatibility check for discovery bundle loading.
28+
///
29+
/// Returns `true` if this loader type can handle fetching the
30+
/// discovery bundle described by `config.discovery`. The default
31+
/// implementation returns `false`.
32+
static func compatibleWithDiscoveryConfig(config: OPA.Config) -> Bool
1933
}
2034

2135
/// HTTPBundleLoader is a slightly more specialized protocol to allow greater
@@ -26,7 +40,41 @@ extension OPA {
2640
config: OPA.Config, bundleResourceName: String, etag: String?, headers: [String: String]?,
2741
httpClient: HTTPClient?) throws
2842

29-
/// Used by the loader-manging task to determine whether to sleep or not between polls.
43+
/// Constructor for loading a discovery bundle over HTTP.
44+
///
45+
/// The loader should read the bundle location from `config.discovery`
46+
/// rather than `config.bundles`. Loaders that don't support discovery
47+
/// inherit a default implementation that throws.
48+
init(
49+
discoveryConfig: OPA.Config, etag: String?, headers: [String: String]?,
50+
httpClient: HTTPClient?) throws
51+
52+
/// Used by the loader-managing task to determine whether to sleep or not between polls.
3053
func isLongPollingEnabled() -> Bool
3154
}
3255
}
56+
57+
// MARK: - Default Discovery Implementations
58+
59+
extension OPA.BundleLoader {
60+
/// Default: this loader does not support discovery.
61+
public static func compatibleWithDiscoveryConfig(config: OPA.Config) -> Bool {
62+
return false
63+
}
64+
65+
/// Default: loader throws at init time.
66+
public init(discoveryConfig: OPA.Config) throws {
67+
throw RuntimeError(code: .discoveryNotSupported, message: "Bundle loader does not support Discovery")
68+
}
69+
70+
}
71+
72+
extension OPA.HTTPBundleLoader {
73+
/// Default: loader throws at init time.
74+
public init(
75+
discoveryConfig: OPA.Config, etag: String?, headers: [String: String]?,
76+
httpClient: HTTPClient?
77+
) throws {
78+
throw RuntimeError(code: .discoveryNotSupported, message: "Bundle loader does not support Discovery")
79+
}
80+
}

Sources/Runtime/ConfigProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ extension OPA {
3636
/// 2. Yield subsequent configs whenever the configuration changes.
3737
/// 3. Call `continuation.finish()` when done (typically in a `defer`).
3838
/// 4. Exit on `Task.isCancelled`.
39-
func run(yielding continuation: AsyncStream<OPA.Config>.Continuation) async
39+
mutating func run(yielding continuation: AsyncStream<OPA.Config>.Continuation) async
4040
}
4141
}

Sources/Runtime/DiskBasedBundleLoader.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ extension OPA {
3737
self.fetchURL = url
3838
}
3939

40+
/// Constructor for loading from the `discovery` section of the config.
41+
public init(discoveryConfig config: OPA.Config) throws {
42+
guard let discovery = config.discovery else {
43+
throw RuntimeError(
44+
code: .internalError,
45+
message: "No discovery config found."
46+
)
47+
}
48+
49+
guard let url = URL(string: discovery.resource ?? "") else {
50+
throw RuntimeError(
51+
code: .internalError,
52+
message: "Invalid URL for discovery resource: \(discovery.resource ?? "")"
53+
)
54+
}
55+
56+
guard url.scheme == "file" else {
57+
throw RuntimeError(
58+
code: .internalError,
59+
message: "DiskBasedBundleLoader requires a file:// URL for discovery resource."
60+
)
61+
}
62+
63+
self.name = "discovery"
64+
self.fetchURL = url
65+
self.polling = discovery.downloaderConfig.polling
66+
}
67+
4068
// If the resource is a file URL, we can load it.
4169
public static func compatibleWithConfig(config: Config, bundleResourceName: String) -> Bool {
4270
guard let resource = config.bundles[bundleResourceName] else {
@@ -46,6 +74,13 @@ extension OPA {
4674
return (URL(string: resource.resource ?? "")?.scheme == "file")
4775
}
4876

77+
public static func compatibleWithDiscoveryConfig(config: Config) -> Bool {
78+
guard let discovery = config.discovery else {
79+
return false
80+
}
81+
return URL(string: discovery.resource ?? "")?.scheme == "file"
82+
}
83+
4984
public func load() async -> Result<Bundle, any Swift.Error> {
5085
var isDirectory: ObjCBool = false
5186
if FileManager.default.fileExists(atPath: self.fetchURL.path, isDirectory: &isDirectory) {

Sources/Runtime/Error.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public struct RuntimeError: Sendable, Swift.Error {
1616
case bundleRootConflictError
1717
case bundleUnpreparedError
1818
case invalidArgumentError
19+
20+
// Discovery errors
21+
case discoveryNotSupported
1922
}
2023

2124
private let internalCode: InternalCode
@@ -32,6 +35,9 @@ public struct RuntimeError: Sendable, Swift.Error {
3235
public static let bundleUnpreparedError = Code(.bundleUnpreparedError)
3336
public static let internalError = Code(.internalError)
3437
public static let invalidArgumentError = Code(.invalidArgumentError)
38+
39+
// Discovery-related codes
40+
public static let discoveryNotSupported = Code(.discoveryNotSupported)
3541
}
3642

3743
/// A code representing the high-level domain of the error.

Sources/Runtime/RESTClientBundleLoader.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,52 @@ extension OPA {
6666
self.longPollingEnabled = false
6767
}
6868

69+
/// Constructor for loading from the `discovery` section of the config.
70+
public init(
71+
discoveryConfig config: OPA.Config,
72+
etag: String? = nil,
73+
headers: [String: String]? = nil,
74+
httpClient: HTTPClient? = nil
75+
) throws {
76+
guard let discovery = config.discovery else {
77+
throw RuntimeError(
78+
code: .internalError,
79+
message: "No discovery config found."
80+
)
81+
}
82+
83+
guard !discovery.service.isEmpty else {
84+
throw RuntimeError(
85+
code: .internalError,
86+
message: "No service config was provided for discovery."
87+
)
88+
}
89+
90+
guard let service = config.services[discovery.service] else {
91+
throw RuntimeError(
92+
code: .internalError,
93+
message: "Service '\(discovery.service)' referenced by discovery config not found."
94+
)
95+
}
96+
97+
self.name = "discovery"
98+
self.fetchURL = service.url.appending(
99+
path: discovery.resource ?? "/bundles/discovery")
100+
self.etag = etag ?? ""
101+
self.serviceConfig = service
102+
self.bundleConfig = try BundleSourceConfig(
103+
downloaderConfig: discovery.downloaderConfig,
104+
service: discovery.service,
105+
resource: discovery.resource,
106+
signing: discovery.signing
107+
)
108+
self.customHeaders = headers ?? [:]
109+
self.httpClient = httpClient ?? HTTPClient.shared
110+
self.polling = discovery.downloaderConfig.polling
111+
self.lastBundle = nil
112+
self.longPollingEnabled = false
113+
}
114+
69115
// If the resource is for a compatible bundle source, we can load it.
70116
public static func compatibleWithConfig(config: Config, bundleResourceName: String) -> Bool {
71117
guard let resource = config.bundles[bundleResourceName] else {
@@ -88,6 +134,26 @@ extension OPA {
88134
}
89135
}
90136

137+
public static func compatibleWithDiscoveryConfig(config: Config) -> Bool {
138+
guard let discovery = config.discovery else {
139+
return false
140+
}
141+
142+
let isFileURL = (URL(string: discovery.resource ?? "")?.scheme == "file")
143+
guard !isFileURL && !discovery.service.isEmpty else {
144+
return false
145+
}
146+
147+
guard let service = config.services[discovery.service] else {
148+
return false
149+
}
150+
151+
switch service.credentials {
152+
case .defaultNoAuth, .bearer(_), .clientTLS(_): return true
153+
default: return false
154+
}
155+
}
156+
91157
// Adjust headers, then call the appropriate backend for fetching the bundle.
92158
// Mutation required for Etag header handling (also requires caching last good bundle).
93159
public mutating func load() async -> Result<OPA.Bundle, any Swift.Error> {

Sources/Runtime/Runtime.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ extension OPA {
4848
/// Optional config provider (e.g. discovery) that produces
4949
/// configuration updates over time. Built at init from the boot
5050
/// config or injected directly as an init parameter.
51-
private let configProvider: (any OPA.ConfigProvider)?
51+
private var configProvider: (any OPA.ConfigProvider)?
5252

5353
/// The ID this Runtime instance uses to identify itself in logs and traces.
5454
public nonisolated let instanceID: String
@@ -285,7 +285,7 @@ extension OPA.Runtime {
285285

286286
// Start the config provider (e.g., discovery) if present.
287287
// It runs entirely off the actor and yields configs to the stream.
288-
if let provider {
288+
if var provider {
289289
group.addTask {
290290
await provider.run(yielding: configContinuation)
291291
}

0 commit comments

Comments
 (0)