diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 21a50a14..a43defa3 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -87,6 +87,25 @@ jobs: working-directory: ComplianceSuite - run: make test-compliance || true # NB: This doesn't succeed yet! + build-minimal: + name: Build minimal (all traits disabled) + runs-on: ubuntu-latest + container: "swift:6.2" + permissions: + contents: read + steps: + - name: Show Swift version + run: swift --version + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install dependencies + run: | + apt-get update + apt-get install -y libjemalloc-dev make + - name: Build (minimal, all traits disabled) + run: make build-minimal + diff: name: diff compliance tests (vs main) runs-on: macos-latest diff --git a/Makefile b/Makefile index e075d198..c7670433 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,12 @@ perf: .PHONY: build build: swift build - + +# Minimum viable build: all package traits disabled. +.PHONY: build-minimal +build-minimal: + swift build --disable-default-traits + .PHONY: build-release build-release: swift build -c release diff --git a/Package.swift b/Package.swift index 280bfa8a..053bd1ce 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,15 @@ let package = Package( targets: ["CLI"] ), ], + traits: [ + // Enabled by default. Library-only consumers can opt out to drop the CLI and its + // dependencies from resolution. + .default(enabledTraits: ["CLI"]), + Trait( + name: "CLI", + description: "Builds the swift-opa-cli executable and its dependencies." + ), + ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0") ], @@ -77,7 +86,11 @@ let package = Package( name: "CLI", dependencies: [ "Rego", - .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product( + name: "ArgumentParser", + package: "swift-argument-parser", + condition: .when(traits: ["CLI"]) + ), ] ), ] diff --git a/README.md b/README.md index 9cb7449b..5a7bb243 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,25 @@ let package = Package( ) ``` +### Traits and Minimal Builds + +Swift-OPA ships a `swift-opa-cli` executable that depends on +[swift-argument-parser](https://github.com/apple/swift-argument-parser). This is enabled +by default via the `CLI` package trait. + +Library-only consumers who only want the VM can disable the trait to drop the CLI and +its dependencies entirely on newer Swift toolchain versions: + +```swift +dependencies: [ + .package( + url: "https://github.com/open-policy-agent/swift-opa", + branch: "main", + traits: [] // disables the default "CLI" trait; swift-argument-parser is not fetched + ), +], +``` + ## Usage The main entry point for policy evaluation is the `OPA.Engine`. An engine can evaluate policies packaged in one or more diff --git a/Sources/CLI/BenchCommand.swift b/Sources/CLI/BenchCommand.swift index d7c0d363..705a77c9 100644 --- a/Sources/CLI/BenchCommand.swift +++ b/Sources/CLI/BenchCommand.swift @@ -1,36 +1,38 @@ -import AST -import ArgumentParser -import Foundation -import Rego +#if CLI + import AST + import ArgumentParser + import Foundation + import Rego -struct BenchCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "bench", - abstract: "Benchmark a Rego query" - ) + struct BenchCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "bench", + abstract: "Benchmark a Rego query" + ) - @OptionGroup - var evalOptions: EvalOptions - @Option(name: [.short, .customLong("count")], help: "iteration count") - var count: UInt = 10_000 + @OptionGroup + var evalOptions: EvalOptions + @Option(name: [.short, .customLong("count")], help: "iteration count") + var count: UInt = 10_000 - mutating func run() async throws { - // Initialize a Rego.Engine initially configured with our bundles from the CLI options. - var regoEngine = Rego.OPA.Engine(bundlePaths: self.evalOptions.bundlePaths) + mutating func run() async throws { + // Initialize a Rego.Engine initially configured with our bundles from the CLI options. + var regoEngine = Rego.OPA.Engine(bundlePaths: self.evalOptions.bundlePaths) - // Prepare does as much pre-processing as possible to get ready to evaluate queries. - // This only needs to be done once when loading the engine and after updating it. - let preparedQuery = try await regoEngine.prepareForEvaluation(query: self.evalOptions.query) + // Prepare does as much pre-processing as possible to get ready to evaluate queries. + // This only needs to be done once when loading the engine and after updating it. + let preparedQuery = try await regoEngine.prepareForEvaluation(query: self.evalOptions.query) - let report = try await measureAsync(iterations: Int(count)) { - let _ = try await preparedQuery.evaluate( - input: self.evalOptions.inputValue, - strictBuiltins: self.evalOptions.strictBuiltinErrors - ) - } + let report = try await measureAsync(iterations: Int(count)) { + let _ = try await preparedQuery.evaluate( + input: self.evalOptions.inputValue, + strictBuiltins: self.evalOptions.strictBuiltinErrors + ) + } - // [ ...] - // https://go.googlesource.com/proposal/+/master/design/14313-benchmark-format.md - print(report.formatted(.gobench)) + // [ ...] + // https://go.googlesource.com/proposal/+/master/design/14313-benchmark-format.md + print(report.formatted(.gobench)) + } } -} +#endif diff --git a/Sources/CLI/CLI.swift b/Sources/CLI/CLI.swift index 0aaffc32..e01801f4 100644 --- a/Sources/CLI/CLI.swift +++ b/Sources/CLI/CLI.swift @@ -1,10 +1,21 @@ -import ArgumentParser +#if CLI + import ArgumentParser -@main -struct CLIRootCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "swift-opa-cli", - abstract: "An example command line showing swift-opa in action.", - subcommands: [EvalCommand.self, BenchCommand.self, CapabilitiesCommand.self] - ) -} + @main + struct CLIRootCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "swift-opa-cli", + abstract: "An example command line showing swift-opa in action.", + subcommands: [EvalCommand.self, BenchCommand.self, CapabilitiesCommand.self] + ) + } +#else + // The executable is built without its swift-argument-parser dependency when the + // "CLI" trait is disabled. This stub keeps the target linkable in that configuration. + @main + struct CLIRootCommand { + static func main() { + print("swift-opa-cli was built without the \"CLI\" trait enabled.") + } + } +#endif diff --git a/Sources/CLI/CapabilitiesCommand.swift b/Sources/CLI/CapabilitiesCommand.swift index d444efc4..1d5f6124 100644 --- a/Sources/CLI/CapabilitiesCommand.swift +++ b/Sources/CLI/CapabilitiesCommand.swift @@ -1,51 +1,54 @@ -import ArgumentParser -import Foundation -import Rego - -struct CapabilitiesCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "capabilities", - abstract: "Output capabilities JSON for supported builtins.", - shouldDisplay: false - ) - - @Argument(help: "Path to the capabilities.json file") - var capabilitiesPath: String - - func run() async throws { - let supportedBuiltins = Set(BuiltinRegistry.getSupportedBuiltinNames()) - - guard let capabilitiesData = FileManager.default.contents(atPath: capabilitiesPath) else { - throw CapabilitiesError.capabilitiesFileNotFound(path: capabilitiesPath) - } - - guard let capabilitiesDict = try JSONSerialization.jsonObject(with: capabilitiesData) as? [String: Any] else { - throw CapabilitiesError.invalidCapabilitiesFormat - } - - guard let builtinsArray = capabilitiesDict["builtins"] as? [[String: Any]] else { - throw CapabilitiesError.invalidCapabilitiesFormat - } - - let filteredBuiltins = builtinsArray.filter { builtin in - guard let name = builtin["name"] as? String else { return false } - return supportedBuiltins.contains(name) - } - - var filteredCapabilities = capabilitiesDict - filteredCapabilities["builtins"] = filteredBuiltins - filteredCapabilities.removeValue(forKey: "wasm_abi_versions") - - let jsonData = try JSONSerialization.data( - withJSONObject: filteredCapabilities, options: [.prettyPrinted, .sortedKeys]) - - if let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) +#if CLI + import ArgumentParser + import Foundation + import Rego + + struct CapabilitiesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "capabilities", + abstract: "Output capabilities JSON for supported builtins.", + shouldDisplay: false + ) + + @Argument(help: "Path to the capabilities.json file") + var capabilitiesPath: String + + func run() async throws { + let supportedBuiltins = Set(BuiltinRegistry.getSupportedBuiltinNames()) + + guard let capabilitiesData = FileManager.default.contents(atPath: capabilitiesPath) else { + throw CapabilitiesError.capabilitiesFileNotFound(path: capabilitiesPath) + } + + guard let capabilitiesDict = try JSONSerialization.jsonObject(with: capabilitiesData) as? [String: Any] + else { + throw CapabilitiesError.invalidCapabilitiesFormat + } + + guard let builtinsArray = capabilitiesDict["builtins"] as? [[String: Any]] else { + throw CapabilitiesError.invalidCapabilitiesFormat + } + + let filteredBuiltins = builtinsArray.filter { builtin in + guard let name = builtin["name"] as? String else { return false } + return supportedBuiltins.contains(name) + } + + var filteredCapabilities = capabilitiesDict + filteredCapabilities["builtins"] = filteredBuiltins + filteredCapabilities.removeValue(forKey: "wasm_abi_versions") + + let jsonData = try JSONSerialization.data( + withJSONObject: filteredCapabilities, options: [.prettyPrinted, .sortedKeys]) + + if let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } } } -} -enum CapabilitiesError: Error { - case capabilitiesFileNotFound(path: String) - case invalidCapabilitiesFormat -} + enum CapabilitiesError: Error { + case capabilitiesFileNotFound(path: String) + case invalidCapabilitiesFormat + } +#endif diff --git a/Sources/CLI/EvalCommand.swift b/Sources/CLI/EvalCommand.swift index b02f6c10..0375502d 100644 --- a/Sources/CLI/EvalCommand.swift +++ b/Sources/CLI/EvalCommand.swift @@ -1,59 +1,61 @@ -import AST -import ArgumentParser -import Foundation -import Rego - -struct EvalCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "eval", - abstract: "Evaluate a Rego query" - ) - - @OptionGroup - var evalOptions: EvalOptions - - mutating func run() async throws { - // Initialize a Rego.Engine initially configured with our bundles from the CLI options. - var regoEngine = Rego.OPA.Engine(bundlePaths: self.evalOptions.bundlePaths) - - let tracer = tracerForLevel(self.evalOptions.explain) - - // Prepare does as much pre-processing as possible to get ready to evaluate queries. - // This only needs to be done once when loading the engine and after updating it. These - // PreparedQuery's can be re-used. - let preparedQuery = try await regoEngine.prepareForEvaluation(query: self.evalOptions.query) - - let resultSet = try await preparedQuery.evaluate( - input: self.evalOptions.inputValue, - tracer: tracer, - strictBuiltins: self.evalOptions.strictBuiltinErrors +#if CLI + import AST + import ArgumentParser + import Foundation + import Rego + + struct EvalCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "eval", + abstract: "Evaluate a Rego query" ) - // Serialize and output the response - let output = try resultSet.jsonString - print(output) + @OptionGroup + var evalOptions: EvalOptions - guard let tracer = tracer else { - return + mutating func run() async throws { + // Initialize a Rego.Engine initially configured with our bundles from the CLI options. + var regoEngine = Rego.OPA.Engine(bundlePaths: self.evalOptions.bundlePaths) + + let tracer = tracerForLevel(self.evalOptions.explain) + + // Prepare does as much pre-processing as possible to get ready to evaluate queries. + // This only needs to be done once when loading the engine and after updating it. These + // PreparedQuery's can be re-used. + let preparedQuery = try await regoEngine.prepareForEvaluation(query: self.evalOptions.query) + + let resultSet = try await preparedQuery.evaluate( + input: self.evalOptions.inputValue, + tracer: tracer, + strictBuiltins: self.evalOptions.strictBuiltinErrors + ) + + // Serialize and output the response + let output = try resultSet.jsonString + print(output) + + guard let tracer = tracer else { + return + } + print("Trace:") + tracer.prettyPrint(to: FileHandle.standardOutput) } - print("Trace:") - tracer.prettyPrint(to: FileHandle.standardOutput) } -} - -func tracerForLevel(_ level: ExplainLevel) -> OPA.Trace.BufferedQueryTracer? { - return switch level { - case .full: - OPA.Trace.BufferedQueryTracer(level: .full) - case .notes: - OPA.Trace.BufferedQueryTracer(level: .note) - default: - nil + + func tracerForLevel(_ level: ExplainLevel) -> OPA.Trace.BufferedQueryTracer? { + return switch level { + case .full: + OPA.Trace.BufferedQueryTracer(level: .full) + case .notes: + OPA.Trace.BufferedQueryTracer(level: .note) + default: + nil + } } -} -enum ExplainLevel: String, CaseIterable, ExpressibleByArgument { - case off - case full - case notes -} + enum ExplainLevel: String, CaseIterable, ExpressibleByArgument { + case off + case full + case notes + } +#endif diff --git a/Sources/CLI/EvalOptions.swift b/Sources/CLI/EvalOptions.swift index 793b1061..81266a07 100644 --- a/Sources/CLI/EvalOptions.swift +++ b/Sources/CLI/EvalOptions.swift @@ -1,79 +1,81 @@ -import AST -import ArgumentParser -import Foundation -import Rego +#if CLI + import AST + import ArgumentParser + import Foundation + import Rego -// EvalOptions are common options for evaluating a bundle. -// This is shared by related commands. -struct EvalOptions: ParsableArguments { - @Argument(help: "The Rego query to evaluate") - var query: String - @Option(name: [.short, .customLong("bundle")], help: "Load paths as bundle files or root directories") - var bundles: [String] - @Option(name: [.long], help: "Enable query explanations") - var explain: ExplainLevel = .off - @Option(name: [.short, .long], help: "set input file path") - var inputFile: String? - @Option( - name: [.customShort("j"), .customLong("just-use-this-json-string-as-input-plz")], help: "Input JSON string") - var rawInput: String? - @Flag(name: [.long], help: "treat the first built-in function error encountered as fatal") - var strictBuiltinErrors: Bool = false + // EvalOptions are common options for evaluating a bundle. + // This is shared by related commands. + struct EvalOptions: ParsableArguments { + @Argument(help: "The Rego query to evaluate") + var query: String + @Option(name: [.short, .customLong("bundle")], help: "Load paths as bundle files or root directories") + var bundles: [String] + @Option(name: [.long], help: "Enable query explanations") + var explain: ExplainLevel = .off + @Option(name: [.short, .long], help: "set input file path") + var inputFile: String? + @Option( + name: [.customShort("j"), .customLong("just-use-this-json-string-as-input-plz")], help: "Input JSON string") + var rawInput: String? + @Flag(name: [.long], help: "treat the first built-in function error encountered as fatal") + var strictBuiltinErrors: Bool = false - // Parsed inputs during validation - var inputValue: AST.RegoValue = .object([:]) - var bundlePaths: [Rego.OPA.Engine.BundlePath] = [] + // Parsed inputs during validation + var inputValue: AST.RegoValue = .object([:]) + var bundlePaths: [Rego.OPA.Engine.BundlePath] = [] - enum CodingKeys: String, CodingKey { - case inputValue - case bundles - case explain - case inputFile - case query - case rawInput - case strictBuiltinErrors - } + enum CodingKeys: String, CodingKey { + case inputValue + case bundles + case explain + case inputFile + case query + case rawInput + case strictBuiltinErrors + } - mutating func validate() throws { - var inputData: Data? + mutating func validate() throws { + var inputData: Data? - if inputFile != nil { - guard rawInput == nil else { - throw ValidationError("Cannot specify both input file and raw input JSON string") - } - do { - let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: inputFile!)) - inputData = fileHandle.readDataToEndOfFile() - } catch { - throw ValidationError("Could not open input file \(inputFile!): \(error)") + if inputFile != nil { + guard rawInput == nil else { + throw ValidationError("Cannot specify both input file and raw input JSON string") + } + do { + let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: inputFile!)) + inputData = fileHandle.readDataToEndOfFile() + } catch { + throw ValidationError("Could not open input file \(inputFile!): \(error)") + } } - } - if let rawInput { - guard inputFile == nil else { - throw ValidationError("Cannot specify both input file and raw input JSON string") + if let rawInput { + guard inputFile == nil else { + throw ValidationError("Cannot specify both input file and raw input JSON string") + } + inputData = rawInput.data(using: .utf8)! } - inputData = rawInput.data(using: .utf8)! - } - if let inputData = inputData { - do { - self.inputValue = try AST.RegoValue(jsonData: inputData) - } catch { - throw ValidationError("Failed to parse input JSON: \(error)") + if let inputData = inputData { + do { + self.inputValue = try AST.RegoValue(jsonData: inputData) + } catch { + throw ValidationError("Failed to parse input JSON: \(error)") + } } - } - let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - self.bundlePaths = try bundles.compactMap { - guard let url = URL(string: $0, relativeTo: cwd) else { - throw ValidationError("Invalid bundle path: \($0): must be a valid file URL") + self.bundlePaths = try bundles.compactMap { + guard let url = URL(string: $0, relativeTo: cwd) else { + throw ValidationError("Invalid bundle path: \($0): must be a valid file URL") + } + return Rego.OPA.Engine.BundlePath( + name: $0, + url: url + ) } - return Rego.OPA.Engine.BundlePath( - name: $0, - url: url - ) } } -} +#endif