diff --git a/Package.swift b/Package.swift index 5f6f2d82..280977d4 100644 --- a/Package.swift +++ b/Package.swift @@ -12,32 +12,44 @@ let package = Package( name: "swift-aws-lambda-runtime", platforms: platforms, products: [ + + /* + The runtime library targets + */ + // this library exports `AWSLambdaRuntimeCore` and adds Foundation convenience methods .library(name: "AWSLambdaRuntime", targets: ["AWSLambdaRuntime"]), // this has all the main functionality for lambda and it does not link Foundation .library(name: "AWSLambdaRuntimeCore", targets: ["AWSLambdaRuntimeCore"]), + /* + The plugins + 'lambda-init' creates a new Lambda function + 'lambda-build' packages the Lambda function + 'lambda-deploy' deploys the Lambda function + + Plugins requires Linux or at least macOS v15 + + */ // plugin to create a new Lambda function, based on a template .plugin(name: "AWSLambdaInitializer", targets: ["AWSLambdaInitializer"]), // plugin to package the lambda, creating an archive that can be uploaded to AWS - // requires Linux or at least macOS v15 - .plugin(name: "AWSLambdaPackager", targets: ["AWSLambdaPackager"]), + .plugin(name: "AWSLambdaBuilder", targets: ["AWSLambdaBuilder"]), // plugin to deploy a Lambda function .plugin(name: "AWSLambdaDeployer", targets: ["AWSLambdaDeployer"]), - // an executable that implements the business logic for the plugins - .executable(name: "AWSLambdaPluginHelper", targets: ["AWSLambdaPluginHelper"]), - + /* + Testing targets + */ // for testing only .library(name: "AWSLambdaTesting", targets: ["AWSLambdaTesting"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.76.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), - // .package(url: "https://github.com/apple/swift-crypto.git", from: "3.9.1"), ], targets: [ .target( @@ -69,10 +81,32 @@ let package = Package( permissions: [ .writeToPackageDirectory(reason: "Create a file with an HelloWorld Lambda function.") ] - ) + ), + dependencies: [ + .target(name: "AWSLambdaPluginHelper") + ] ), + // keep this one (with "archive") to not break workflows + // This will be deprecated at some point in the future +// .plugin( +// name: "AWSLambdaPackager", +// capability: .command( +// intent: .custom( +// verb: "archive", +// description: +// "Archive the Lambda binary and prepare it for uploading to AWS. Requires docker on macOS or non Amazonlinux 2 distributions." +// ), +// permissions: [ +// .allowNetworkConnections( +// scope: .docker, +// reason: "This plugin uses Docker to create the AWS Lambda ZIP package." +// ) +// ] +// ), +// path: "Plugins/AWSLambdaBuilder" // same sources as the new "lambda-build" plugin +// ), .plugin( - name: "AWSLambdaPackager", + name: "AWSLambdaBuilder", capability: .command( intent: .custom( verb: "lambda-build", @@ -85,13 +119,16 @@ let package = Package( reason: "This plugin uses Docker to create the AWS Lambda ZIP package." ) ] - ) + ), + dependencies: [ + .target(name: "AWSLambdaPluginHelper") + ] ), .plugin( name: "AWSLambdaDeployer", capability: .command( intent: .custom( - verb: "deploy", + verb: "lambda-deploy", description: "Deploy the Lambda function. You must have an AWS account and know an access key and secret access key." ), diff --git a/Plugins/AWSLambdaBuilder/Plugin.swift b/Plugins/AWSLambdaBuilder/Plugin.swift new file mode 100644 index 00000000..8dd8a81c --- /dev/null +++ b/Plugins/AWSLambdaBuilder/Plugin.swift @@ -0,0 +1,179 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import PackagePlugin + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +@main +struct AWSLambdaPackager: CommandPlugin { + + func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { + + // values to pass to the AWSLambdaPluginHelper + let outputDirectory: URL + let products: [Product] + let buildConfiguration: PackageManager.BuildConfiguration + let packageID: String = context.package.id + let packageDisplayName = context.package.displayName + let packageDirectory = context.package.directoryURL + let dockerToolPath = try context.tool(named: "docker").url + let zipToolPath = try context.tool(named: "zip").url + + // extract arguments that require PluginContext to fully resolve + // resolve them here and pass them to the AWSLambdaPluginHelper as arguments + var argumentExtractor = ArgumentExtractor(arguments) + + let outputPathArgument = argumentExtractor.extractOption(named: "output-path") + let productsArgument = argumentExtractor.extractOption(named: "products") + let configurationArgument = argumentExtractor.extractOption(named: "configuration") + + if let outputPath = outputPathArgument.first { + #if os(Linux) + var isDirectory: Bool = false + #else + var isDirectory: ObjCBool = false + #endif + guard FileManager.default.fileExists(atPath: outputPath, isDirectory: &isDirectory) + else { + throw BuilderErrors.invalidArgument("invalid output directory '\(outputPath)'") + } + outputDirectory = URL(string: outputPath)! + } else { + outputDirectory = context.pluginWorkDirectoryURL.appending(path: "\(AWSLambdaPackager.self)") + } + + let explicitProducts = !productsArgument.isEmpty + if explicitProducts { + let _products = try context.package.products(named: productsArgument) + for product in _products { + guard product is ExecutableProduct else { + throw BuilderErrors.invalidArgument("product named '\(product.name)' is not an executable product") + } + } + products = _products + + } else { + products = context.package.products.filter { $0 is ExecutableProduct } + } + + if let _buildConfigurationName = configurationArgument.first { + guard let _buildConfiguration = PackageManager.BuildConfiguration(rawValue: _buildConfigurationName) else { + throw BuilderErrors.invalidArgument("invalid build configuration named '\(_buildConfigurationName)'") + } + buildConfiguration = _buildConfiguration + } else { + buildConfiguration = .release + } + + // TODO: When running on Amazon Linux 2, we have to build directly from the plugin + // launch the build, then call the helper just for the ZIP part + + let tool = try context.tool(named: "AWSLambdaPluginHelper") + let args = [ + "build", + "--output-path", outputDirectory.path(), + "--products", products.map { $0.name }.joined(separator: ","), + "--configuration", buildConfiguration.rawValue, + "--package-id", packageID, + "--package-display-name", packageDisplayName, + "--package-directory", packageDirectory.path(), + "--docker-tool-path", dockerToolPath.path, + "--zip-tool-path", zipToolPath.path + ] + arguments + + // Invoke the plugin helper on the target directory, passing a configuration + // file from the package directory. + let process = try Process.run(tool.url, arguments: args) + process.waitUntilExit() + + // Check whether the subprocess invocation was successful. + if !(process.terminationReason == .exit && process.terminationStatus == 0) { + let problem = "\(process.terminationReason):\(process.terminationStatus)" + Diagnostics.error("AWSLambdaPluginHelper invocation failed: \(problem)") + } + } + + // TODO: When running on Amazon Linux 2, we have to build directly from the plugin +// private func build( +// packageIdentity: Package.ID, +// products: [Product], +// buildConfiguration: PackageManager.BuildConfiguration, +// verboseLogging: Bool +// ) throws -> [LambdaProduct: URL] { +// print("-------------------------------------------------------------------------") +// print("building \"\(packageIdentity)\"") +// print("-------------------------------------------------------------------------") +// +// var results = [LambdaProduct: URL]() +// for product in products { +// print("building \"\(product.name)\"") +// var parameters = PackageManager.BuildParameters() +// parameters.configuration = buildConfiguration +// parameters.otherSwiftcFlags = ["-static-stdlib"] +// parameters.logging = verboseLogging ? .verbose : .concise +// +// let result = try packageManager.build( +// .product(product.name), +// parameters: parameters +// ) +// guard let artifact = result.executableArtifact(for: product) else { +// throw Errors.productExecutableNotFound(product.name) +// } +// results[.init(product)] = artifact.url +// } +// return results +// } + +// private func isAmazonLinux2() -> Bool { +// if let data = FileManager.default.contents(atPath: "/etc/system-release"), +// let release = String(data: data, encoding: .utf8) +// { +// return release.hasPrefix("Amazon Linux release 2") +// } else { +// return false +// } +// } +} + +private enum BuilderErrors: Error, CustomStringConvertible { + case invalidArgument(String) + + var description: String { + switch self { + case .invalidArgument(let description): + return description + } + } +} + +extension PackageManager.BuildResult { + // find the executable produced by the build + func executableArtifact(for product: Product) -> PackageManager.BuildResult.BuiltArtifact? { + let executables = self.builtArtifacts.filter { + $0.kind == .executable && $0.url.lastPathComponent == product.name + } + guard !executables.isEmpty else { + return nil + } + guard executables.count == 1, let executable = executables.first else { + return nil + } + return executable + } +} diff --git a/Plugins/AWSLambdaDeployer/Plugin.swift b/Plugins/AWSLambdaDeployer/Plugin.swift index 08cdc269..9ae59b0b 100644 --- a/Plugins/AWSLambdaDeployer/Plugin.swift +++ b/Plugins/AWSLambdaDeployer/Plugin.swift @@ -12,67 +12,33 @@ // //===----------------------------------------------------------------------===// -import Foundation import PackagePlugin +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + @main -@available(macOS 15.0, *) struct AWSLambdaDeployer: CommandPlugin { func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { - let configuration = try Configuration(context: context, arguments: arguments) - if configuration.help { - self.displayHelpMessage() - return + let tool = try context.tool(named: "AWSLambdaPluginHelper") + + let args = ["deploy"] + arguments + + // Invoke the plugin helper on the target directory, passing a configuration + // file from the package directory. + let process = try Process.run(tool.url, arguments: args) + process.waitUntilExit() + + // Check whether the subprocess invocation was successful. + if !(process.terminationReason == .exit && process.terminationStatus == 0) { + let problem = "\(process.terminationReason):\(process.terminationStatus)" + Diagnostics.error("AWSLambdaPluginHelper invocation failed: \(problem)") } - - let tool = try context.tool(named: "AWSLambdaDeployerHelper") - try Utils.execute(executable: tool.url, arguments: [], logLevel: .debug) } - private func displayHelpMessage() { - print( - """ - OVERVIEW: A SwiftPM plugin to deploy a Lambda function. - - USAGE: swift package lambda-deploy - [--with-url] - [--help] [--verbose] - - OPTIONS: - --with-url Add an URL to access the Lambda function - --verbose Produce verbose output for debugging. - --help Show help information. - """ - ) - } -} - -private struct Configuration: CustomStringConvertible { - public let help: Bool - public let verboseLogging: Bool - - public init( - context: PluginContext, - arguments: [String] - ) throws { - var argumentExtractor = ArgumentExtractor(arguments) - let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 - let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 - - // help required ? - self.help = helpArgument - - // verbose logging required ? - self.verboseLogging = verboseArgument - } - - var description: String { - """ - { - verboseLogging: \(self.verboseLogging) - } - """ - } } diff --git a/Plugins/AWSLambdaDeployer/PluginUtils.swift b/Plugins/AWSLambdaDeployer/PluginUtils.swift deleted file mode 100644 index 52d1b2be..00000000 --- a/Plugins/AWSLambdaDeployer/PluginUtils.swift +++ /dev/null @@ -1,156 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftAWSLambdaRuntime open source project -// -// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Dispatch -import Foundation -import PackagePlugin -import Synchronization - -@available(macOS 15.0, *) -struct Utils { - @discardableResult - static func execute( - executable: URL, - arguments: [String], - customWorkingDirectory: URL? = .none, - logLevel: ProcessLogLevel - ) throws -> String { - if logLevel >= .debug { - print("\(executable.path()) \(arguments.joined(separator: " "))") - } - - let fd = dup(1) - let stdout = fdopen(fd, "rw") - defer { if let so = stdout { fclose(so) } } - - // We need to use an unsafe transfer here to get the fd into our Sendable closure. - // This transfer is fine, because we write to the variable from a single SerialDispatchQueue here. - // We wait until the process is run below process.waitUntilExit(). - // This means no further writes to output will happen. - // This makes it save for us to read the output - struct UnsafeTransfer: @unchecked Sendable { - let value: Value - } - - let outputMutex = Mutex("") - let outputSync = DispatchGroup() - let outputQueue = DispatchQueue(label: "AWSLambdaPackager.output") - let unsafeTransfer = UnsafeTransfer(value: stdout) - let outputHandler = { @Sendable (data: Data?) in - dispatchPrecondition(condition: .onQueue(outputQueue)) - - outputSync.enter() - defer { outputSync.leave() } - - guard - let _output = data.flatMap({ - String(data: $0, encoding: .utf8)?.trimmingCharacters(in: CharacterSet(["\n"])) - }), !_output.isEmpty - else { - return - } - - outputMutex.withLock { output in - output += _output + "\n" - } - - switch logLevel { - case .silent: - break - case .debug(let outputIndent), .output(let outputIndent): - print(String(repeating: " ", count: outputIndent), terminator: "") - print(_output) - fflush(unsafeTransfer.value) - } - } - - let pipe = Pipe() - pipe.fileHandleForReading.readabilityHandler = { fileHandle in - outputQueue.async { outputHandler(fileHandle.availableData) } - } - - let process = Process() - process.standardOutput = pipe - process.standardError = pipe - process.executableURL = executable - process.arguments = arguments - if let workingDirectory = customWorkingDirectory { - process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory.path()) - } - process.terminationHandler = { _ in - outputQueue.async { - outputHandler(try? pipe.fileHandleForReading.readToEnd()) - } - } - - try process.run() - process.waitUntilExit() - - // wait for output to be full processed - outputSync.wait() - - let output = outputMutex.withLock { $0 } - - if process.terminationStatus != 0 { - // print output on failure and if not already printed - if logLevel < .output { - print(output) - fflush(stdout) - } - throw ProcessError.processFailed([executable.path()] + arguments, process.terminationStatus) - } - - return output - } - - enum ProcessError: Error, CustomStringConvertible { - case processFailed([String], Int32) - - var description: String { - switch self { - case .processFailed(let arguments, let code): - return "\(arguments.joined(separator: " ")) failed with code \(code)" - } - } - } - - enum ProcessLogLevel: Comparable { - case silent - case output(outputIndent: Int) - case debug(outputIndent: Int) - - var naturalOrder: Int { - switch self { - case .silent: - return 0 - case .output: - return 1 - case .debug: - return 2 - } - } - - static var output: Self { - .output(outputIndent: 2) - } - - static var debug: Self { - .debug(outputIndent: 2) - } - - static func < (lhs: ProcessLogLevel, rhs: ProcessLogLevel) -> Bool { - lhs.naturalOrder < rhs.naturalOrder - } - } -} diff --git a/Plugins/AWSLambdaInitializer/Plugin.swift b/Plugins/AWSLambdaInitializer/Plugin.swift index 21713c0f..bbfa90e2 100644 --- a/Plugins/AWSLambdaInitializer/Plugin.swift +++ b/Plugins/AWSLambdaInitializer/Plugin.swift @@ -12,80 +12,32 @@ // //===----------------------------------------------------------------------===// -import Foundation import PackagePlugin +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + @main -@available(macOS 15.0, *) struct AWSLambdaPackager: CommandPlugin { - let destFileName = "Sources/main.swift" - func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { - let configuration = try Configuration(context: context, arguments: arguments) + let tool = try context.tool(named: "AWSLambdaPluginHelper") + + let args = ["init", "--dest-dir", context.package.directoryURL.path()] + arguments - if configuration.help { - self.displayHelpMessage() - return - } + // Invoke the plugin helper on the target directory, passing a configuration + // file from the package directory. + let process = try Process.run(tool.url, arguments: args) + process.waitUntilExit() - let destFileURL = context.package.directoryURL.appendingPathComponent(destFileName) - do { - try functionWithUrlTemplate.write(to: destFileURL, atomically: true, encoding: .utf8) - - if configuration.verboseLogging { - Diagnostics.progress("✅ Lambda function written to \(destFileName)") - Diagnostics.progress("📦 You can now package with: 'swift package archive'") - } - - } catch { - Diagnostics.error("🛑Failed to create the Lambda function file: \(error)") + // Check whether the subprocess invocation was successful. + if !(process.terminationReason == .exit && process.terminationStatus == 0) { + let problem = "\(process.terminationReason):\(process.terminationStatus)" + Diagnostics.error("AWSLambdaPluginHelper invocation failed: \(problem)") } } - - private func displayHelpMessage() { - print( - """ - OVERVIEW: A SwiftPM plugin to scaffold a HelloWorld Lambda function. - - USAGE: swift package lambda-init - [--help] [--verbose] - [--allow-writing-to-package-directory] - - OPTIONS: - --allow-writing-to-package-directory Don't ask for permissions to write files. - --verbose Produce verbose output for debugging. - --help Show help information. - """ - ) - } - } -private struct Configuration: CustomStringConvertible { - public let help: Bool - public let verboseLogging: Bool - - public init( - context: PluginContext, - arguments: [String] - ) throws { - var argumentExtractor = ArgumentExtractor(arguments) - let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 - let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 - - // help required ? - self.help = helpArgument - - // verbose logging required ? - self.verboseLogging = verboseArgument - } - - var description: String { - """ - { - verboseLogging: \(self.verboseLogging) - } - """ - } -} diff --git a/Plugins/AWSLambdaPackager/Plugin.swift b/Plugins/AWSLambdaPackager/Plugin.swift deleted file mode 100644 index a8693945..00000000 --- a/Plugins/AWSLambdaPackager/Plugin.swift +++ /dev/null @@ -1,513 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftAWSLambdaRuntime open source project -// -// Copyright (c) 2022 Apple Inc. and the SwiftAWSLambdaRuntime project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import PackagePlugin - -@main -@available(macOS 15.0, *) -struct AWSLambdaPackager: CommandPlugin { - func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { - let configuration = try Configuration(context: context, arguments: arguments) - - if configuration.help { - self.displayHelpMessage() - return - } - - guard !configuration.products.isEmpty else { - throw Errors.unknownProduct("no appropriate products found to package") - } - - if configuration.products.count > 1 && !configuration.explicitProducts { - let productNames = configuration.products.map(\.name) - print( - "No explicit products named, building all executable products: '\(productNames.joined(separator: "', '"))'" - ) - } - - let builtProducts: [LambdaProduct: URL] - if self.isAmazonLinux2() { - // build directly on the machine - builtProducts = try self.build( - packageIdentity: context.package.id, - products: configuration.products, - buildConfiguration: configuration.buildConfiguration, - verboseLogging: configuration.verboseLogging - ) - } else { - // build with docker - builtProducts = try self.buildInDocker( - packageIdentity: context.package.id, - packageDirectory: context.package.directoryURL, - products: configuration.products, - toolsProvider: { name in try context.tool(named: name).url }, - outputDirectory: configuration.outputDirectory, - baseImage: configuration.baseDockerImage, - disableDockerImageUpdate: configuration.disableDockerImageUpdate, - buildConfiguration: configuration.buildConfiguration, - verboseLogging: configuration.verboseLogging - ) - } - - // create the archive - let archives = try self.package( - packageName: context.package.displayName, - products: builtProducts, - toolsProvider: { name in try context.tool(named: name).url }, - outputDirectory: configuration.outputDirectory, - verboseLogging: configuration.verboseLogging - ) - - print( - "\(archives.count > 0 ? archives.count.description : "no") archive\(archives.count != 1 ? "s" : "") created" - ) - for (product, archivePath) in archives { - print(" * \(product.name) at \(archivePath.path())") - } - } - - private func buildInDocker( - packageIdentity: Package.ID, - packageDirectory: URL, - products: [Product], - toolsProvider: (String) throws -> URL, - outputDirectory: URL, - baseImage: String, - disableDockerImageUpdate: Bool, - buildConfiguration: PackageManager.BuildConfiguration, - verboseLogging: Bool - ) throws -> [LambdaProduct: URL] { - let dockerToolPath = try toolsProvider("docker") - - print("-------------------------------------------------------------------------") - print("building \"\(packageIdentity)\" in docker") - print("-------------------------------------------------------------------------") - - if !disableDockerImageUpdate { - // update the underlying docker image, if necessary - print("updating \"\(baseImage)\" docker image") - try Utils.execute( - executable: dockerToolPath, - arguments: ["pull", baseImage], - logLevel: verboseLogging ? .debug : .output - ) - } - - // get the build output path - let buildOutputPathCommand = "swift build -c \(buildConfiguration.rawValue) --show-bin-path" - let dockerBuildOutputPath = try Utils.execute( - executable: dockerToolPath, - arguments: [ - "run", "--rm", "-v", "\(packageDirectory.path()):/workspace", "-w", "/workspace", baseImage, "bash", - "-cl", buildOutputPathCommand, - ], - logLevel: verboseLogging ? .debug : .silent - ) - guard let buildPathOutput = dockerBuildOutputPath.split(separator: "\n").last else { - throw Errors.failedParsingDockerOutput(dockerBuildOutputPath) - } - let buildOutputPath = URL( - string: buildPathOutput.replacingOccurrences(of: "/workspace/", with: packageDirectory.description) - )! - - // build the products - var builtProducts = [LambdaProduct: URL]() - for product in products { - print("building \"\(product.name)\"") - let buildCommand = - "swift build -c \(buildConfiguration.rawValue) --product \(product.name) --static-swift-stdlib" - if let localPath = ProcessInfo.processInfo.environment["LAMBDA_USE_LOCAL_DEPS"] { - // when developing locally, we must have the full swift-aws-lambda-runtime project in the container - // because Examples' Package.swift have a dependency on ../.. - // just like Package.swift's examples assume ../.., we assume we are two levels below the root project - let slice = packageDirectory.pathComponents.suffix(2) - try Utils.execute( - executable: dockerToolPath, - arguments: [ - "run", "--rm", "--env", "LAMBDA_USE_LOCAL_DEPS=\(localPath)", "-v", - "\(packageDirectory.path())../..:/workspace", "-w", - "/workspace/\(slice.joined(separator: "/"))", baseImage, "bash", "-cl", buildCommand, - ], - logLevel: verboseLogging ? .debug : .output - ) - } else { - try Utils.execute( - executable: dockerToolPath, - arguments: [ - "run", "--rm", "-v", "\(packageDirectory.path()):/workspace", "-w", "/workspace", baseImage, - "bash", "-cl", buildCommand, - ], - logLevel: verboseLogging ? .debug : .output - ) - } - let productPath = buildOutputPath.appending(path: product.name) - - guard FileManager.default.fileExists(atPath: productPath.path()) else { - Diagnostics.error("expected '\(product.name)' binary at \"\(productPath.path())\"") - throw Errors.productExecutableNotFound(product.name) - } - builtProducts[.init(product)] = productPath - } - return builtProducts - } - - private func build( - packageIdentity: Package.ID, - products: [Product], - buildConfiguration: PackageManager.BuildConfiguration, - verboseLogging: Bool - ) throws -> [LambdaProduct: URL] { - print("-------------------------------------------------------------------------") - print("building \"\(packageIdentity)\"") - print("-------------------------------------------------------------------------") - - var results = [LambdaProduct: URL]() - for product in products { - print("building \"\(product.name)\"") - var parameters = PackageManager.BuildParameters() - parameters.configuration = buildConfiguration - parameters.otherSwiftcFlags = ["-static-stdlib"] - parameters.logging = verboseLogging ? .verbose : .concise - - let result = try packageManager.build( - .product(product.name), - parameters: parameters - ) - guard let artifact = result.executableArtifact(for: product) else { - throw Errors.productExecutableNotFound(product.name) - } - results[.init(product)] = artifact.url - } - return results - } - - // TODO: explore using ziplib or similar instead of shelling out - private func package( - packageName: String, - products: [LambdaProduct: URL], - toolsProvider: (String) throws -> URL, - outputDirectory: URL, - verboseLogging: Bool - ) throws -> [LambdaProduct: URL] { - let zipToolPath = try toolsProvider("zip") - - var archives = [LambdaProduct: URL]() - for (product, artifactPath) in products { - print("-------------------------------------------------------------------------") - print("archiving \"\(product.name)\"") - print("-------------------------------------------------------------------------") - - // prep zipfile location - let workingDirectory = outputDirectory.appending(path: product.name) - let zipfilePath = workingDirectory.appending(path: "\(product.name).zip") - if FileManager.default.fileExists(atPath: workingDirectory.path()) { - try FileManager.default.removeItem(atPath: workingDirectory.path()) - } - try FileManager.default.createDirectory(atPath: workingDirectory.path(), withIntermediateDirectories: true) - - // rename artifact to "bootstrap" - let relocatedArtifactPath = workingDirectory.appending(path: "bootstrap") - try FileManager.default.copyItem(atPath: artifactPath.path(), toPath: relocatedArtifactPath.path()) - - var arguments: [String] = [] - #if os(macOS) || os(Linux) - arguments = [ - "--recurse-paths", - "--symlinks", - zipfilePath.lastPathComponent, - relocatedArtifactPath.lastPathComponent, - ] - #else - throw Errors.unsupportedPlatform("can't or don't know how to create a zip file on this platform") - #endif - - // add resources - var artifactPathComponents = artifactPath.pathComponents - _ = artifactPathComponents.removeFirst() // Get rid of beginning "/" - _ = artifactPathComponents.removeLast() // Get rid of the name of the package - let artifactDirectory = "/\(artifactPathComponents.joined(separator: "/"))" - for fileInArtifactDirectory in try FileManager.default.contentsOfDirectory(atPath: artifactDirectory) { - guard let artifactURL = URL(string: "\(artifactDirectory)/\(fileInArtifactDirectory)") else { - continue - } - - guard artifactURL.pathExtension == "resources" else { - continue // Not resources, so don't copy - } - let resourcesDirectoryName = artifactURL.lastPathComponent - let relocatedResourcesDirectory = workingDirectory.appending(path: resourcesDirectoryName) - if FileManager.default.fileExists(atPath: artifactURL.path()) { - try FileManager.default.copyItem( - atPath: artifactURL.path(), - toPath: relocatedResourcesDirectory.path() - ) - arguments.append(resourcesDirectoryName) - } - } - - // run the zip tool - try Utils.execute( - executable: zipToolPath, - arguments: arguments, - customWorkingDirectory: workingDirectory, - logLevel: verboseLogging ? .debug : .silent - ) - - archives[product] = zipfilePath - } - return archives - } - - private func isAmazonLinux2() -> Bool { - if let data = FileManager.default.contents(atPath: "/etc/system-release"), - let release = String(data: data, encoding: .utf8) - { - return release.hasPrefix("Amazon Linux release 2") - } else { - return false - } - } - - private func displayHelpMessage() { - print( - """ - OVERVIEW: A SwiftPM plugin to build and package your lambda function. - - REQUIREMENTS: To use this plugin, you must have docker installed and started. - - USAGE: swift package --allow-network-connections docker archive - [--help] [--verbose] - [--output-directory ] - [--products ] - [--configuration debug | release] - [--swift-version ] - [--base-docker-image ] - [--disable-docker-image-update] - - - OPTIONS: - --verbose Produce verbose output for debugging. - --output-directory The path of the binary package. - (default is `.build/plugins/AWSLambdaPackager/outputs/...`) - --products The list of executable targets to build. - (default is taken from Package.swift) - --configuration The build configuration (debug or release) - (default is release) - --swift-version The swift version to use for building. - (default is latest) - This parameter cannot be used when --base-docker-image is specified. - --base-docker-image The name of the base docker image to use for the build. - (default : swift-:amazonlinux2) - This parameter cannot be used when --swift-version is specified. - --disable-docker-image-update Do not attempt to update the docker image - --help Show help information. - """ - ) - } -} - -@available(macOS 15.0, *) -private struct Configuration: CustomStringConvertible { - public let help: Bool - public let outputDirectory: URL - public let products: [Product] - public let explicitProducts: Bool - public let buildConfiguration: PackageManager.BuildConfiguration - public let verboseLogging: Bool - public let baseDockerImage: String - public let disableDockerImageUpdate: Bool - - public init( - context: PluginContext, - arguments: [String] - ) throws { - var argumentExtractor = ArgumentExtractor(arguments) - let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 - let outputPathArgument = argumentExtractor.extractOption(named: "output-path") - let productsArgument = argumentExtractor.extractOption(named: "products") - let configurationArgument = argumentExtractor.extractOption(named: "configuration") - let swiftVersionArgument = argumentExtractor.extractOption(named: "swift-version") - let baseDockerImageArgument = argumentExtractor.extractOption(named: "base-docker-image") - let disableDockerImageUpdateArgument = argumentExtractor.extractFlag(named: "disable-docker-image-update") > 0 - let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 - - // help required ? - self.help = helpArgument - - // verbose logging required ? - self.verboseLogging = verboseArgument - - if let outputPath = outputPathArgument.first { - #if os(Linux) - var isDirectory: Bool = false - #else - var isDirectory: ObjCBool = false - #endif - guard FileManager.default.fileExists(atPath: outputPath, isDirectory: &isDirectory) - else { - throw Errors.invalidArgument("invalid output directory '\(outputPath)'") - } - self.outputDirectory = URL(string: outputPath)! - } else { - self.outputDirectory = context.pluginWorkDirectoryURL.appending(path: "\(AWSLambdaPackager.self)") - } - - self.explicitProducts = !productsArgument.isEmpty - if self.explicitProducts { - let products = try context.package.products(named: productsArgument) - for product in products { - guard product is ExecutableProduct else { - throw Errors.invalidArgument("product named '\(product.name)' is not an executable product") - } - } - self.products = products - - } else { - self.products = context.package.products.filter { $0 is ExecutableProduct } - } - - if let buildConfigurationName = configurationArgument.first { - guard let buildConfiguration = PackageManager.BuildConfiguration(rawValue: buildConfigurationName) else { - throw Errors.invalidArgument("invalid build configuration named '\(buildConfigurationName)'") - } - self.buildConfiguration = buildConfiguration - } else { - self.buildConfiguration = .release - } - - guard !(!swiftVersionArgument.isEmpty && !baseDockerImageArgument.isEmpty) else { - throw Errors.invalidArgument("--swift-version and --base-docker-image are mutually exclusive") - } - - let swiftVersion = swiftVersionArgument.first ?? .none // undefined version will yield the latest docker image - self.baseDockerImage = - baseDockerImageArgument.first ?? "swift:\(swiftVersion.map { $0 + "-" } ?? "")amazonlinux2" - - self.disableDockerImageUpdate = disableDockerImageUpdateArgument - - if self.verboseLogging { - print("-------------------------------------------------------------------------") - print("configuration") - print("-------------------------------------------------------------------------") - print(self) - } - } - - var description: String { - """ - { - outputDirectory: \(self.outputDirectory) - products: \(self.products.map(\.name)) - buildConfiguration: \(self.buildConfiguration) - baseDockerImage: \(self.baseDockerImage) - disableDockerImageUpdate: \(self.disableDockerImageUpdate) - } - """ - } -} - -private enum ProcessLogLevel: Comparable { - case silent - case output(outputIndent: Int) - case debug(outputIndent: Int) - - var naturalOrder: Int { - switch self { - case .silent: - return 0 - case .output: - return 1 - case .debug: - return 2 - } - } - - static var output: Self { - .output(outputIndent: 2) - } - - static var debug: Self { - .debug(outputIndent: 2) - } - - static func < (lhs: ProcessLogLevel, rhs: ProcessLogLevel) -> Bool { - lhs.naturalOrder < rhs.naturalOrder - } -} - -private enum Errors: Error, CustomStringConvertible { - case invalidArgument(String) - case unsupportedPlatform(String) - case unknownProduct(String) - case productExecutableNotFound(String) - case failedWritingDockerfile - case failedParsingDockerOutput(String) - case processFailed([String], Int32) - - var description: String { - switch self { - case .invalidArgument(let description): - return description - case .unsupportedPlatform(let description): - return description - case .unknownProduct(let description): - return description - case .productExecutableNotFound(let product): - return "product executable not found '\(product)'" - case .failedWritingDockerfile: - return "failed writing dockerfile" - case .failedParsingDockerOutput(let output): - return "failed parsing docker output: '\(output)'" - case .processFailed(let arguments, let code): - return "\(arguments.joined(separator: " ")) failed with code \(code)" - } - } -} - -private struct LambdaProduct: Hashable { - let underlying: Product - - init(_ underlying: Product) { - self.underlying = underlying - } - - var name: String { - self.underlying.name - } - - func hash(into hasher: inout Hasher) { - self.underlying.id.hash(into: &hasher) - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.underlying.id == rhs.underlying.id - } -} - -extension PackageManager.BuildResult { - // find the executable produced by the build - func executableArtifact(for product: Product) -> PackageManager.BuildResult.BuiltArtifact? { - let executables = self.builtArtifacts.filter { - $0.kind == .executable && $0.url.lastPathComponent == product.name - } - guard !executables.isEmpty else { - return nil - } - guard executables.count == 1, let executable = executables.first else { - return nil - } - return executable - } -} diff --git a/Sources/AWSLambdaPluginHelper/AWSLambdaPluginHelper.swift b/Sources/AWSLambdaPluginHelper/AWSLambdaPluginHelper.swift new file mode 100644 index 00000000..71b5af7e --- /dev/null +++ b/Sources/AWSLambdaPluginHelper/AWSLambdaPluginHelper.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@main +struct AWSLambdaPluginHelper { + + private enum Command: String { + case `init` + case build + case deploy + } + + public static func main() async throws { + let args = CommandLine.arguments + let helper = AWSLambdaPluginHelper() + let command = try helper.command(from: args) + switch command { + case .`init`: + try await Initializer().initialize(arguments: args) + case .build: + try await Builder().build(arguments: args) + case .deploy: + try await Deployer().deploy(arguments: args) + } + } + + private func command(from arguments: [String]) throws -> Command { + let args = CommandLine.arguments + + guard args.count > 2 else { + throw AWSLambdaPluginHelperError.noCommand + } + let commandName = args[1] + guard let command = Command(rawValue: commandName) else { + throw AWSLambdaPluginHelperError.invalidCommand(commandName) + } + + return command + } +} + +private enum AWSLambdaPluginHelperError: Error { + case noCommand + case invalidCommand(String) +} + diff --git a/Plugins/AWSLambdaPackager/PluginUtils.swift b/Sources/AWSLambdaPluginHelper/Process.swift similarity index 99% rename from Plugins/AWSLambdaPackager/PluginUtils.swift rename to Sources/AWSLambdaPluginHelper/Process.swift index f4e8cb02..235ae056 100644 --- a/Plugins/AWSLambdaPackager/PluginUtils.swift +++ b/Sources/AWSLambdaPluginHelper/Process.swift @@ -14,7 +14,6 @@ import Dispatch import Foundation -import PackagePlugin import Synchronization @available(macOS 15.0, *) diff --git a/Sources/AWSLambdaPluginHelper/Vendored/crypto/Padding.swift b/Sources/AWSLambdaPluginHelper/Vendored/crypto/Padding.swift index be90ba37..679e161f 100644 --- a/Sources/AWSLambdaPluginHelper/Vendored/crypto/Padding.swift +++ b/Sources/AWSLambdaPluginHelper/Vendored/crypto/Padding.swift @@ -33,26 +33,13 @@ public protocol PaddingProtocol { } public enum Padding: PaddingProtocol { - case noPadding, zeroPadding //, pkcs7, pkcs5, eme_pkcs1v15, emsa_pkcs1v15, iso78164, iso10126 - + case noPadding, zeroPadding public func add(to: [UInt8], blockSize: Int) -> [UInt8] { switch self { case .noPadding: return to // NoPadding().add(to: to, blockSize: blockSize) case .zeroPadding: return ZeroPadding().add(to: to, blockSize: blockSize) - // case .pkcs7: - // return PKCS7.Padding().add(to: to, blockSize: blockSize) - // case .pkcs5: - // return PKCS5.Padding().add(to: to, blockSize: blockSize) - // case .eme_pkcs1v15: - // return EMEPKCS1v15Padding().add(to: to, blockSize: blockSize) - // case .emsa_pkcs1v15: - // return EMSAPKCS1v15Padding().add(to: to, blockSize: blockSize) - // case .iso78164: - // return ISO78164Padding().add(to: to, blockSize: blockSize) - // case .iso10126: - // return ISO10126Padding().add(to: to, blockSize: blockSize) } } @@ -62,18 +49,6 @@ public enum Padding: PaddingProtocol { return from //NoPadding().remove(from: from, blockSize: blockSize) case .zeroPadding: return ZeroPadding().remove(from: from, blockSize: blockSize) - // case .pkcs7: - // return PKCS7.Padding().remove(from: from, blockSize: blockSize) - // case .pkcs5: - // return PKCS5.Padding().remove(from: from, blockSize: blockSize) - // case .eme_pkcs1v15: - // return EMEPKCS1v15Padding().remove(from: from, blockSize: blockSize) - // case .emsa_pkcs1v15: - // return EMSAPKCS1v15Padding().remove(from: from, blockSize: blockSize) - // case .iso78164: - // return ISO78164Padding().remove(from: from, blockSize: blockSize) - // case .iso10126: - // return ISO10126Padding().remove(from: from, blockSize: blockSize) } } } diff --git a/Sources/AWSLambdaPluginHelper/Vendored/signer/AWSSigner.swift b/Sources/AWSLambdaPluginHelper/Vendored/signer/AWSSigner.swift index e06d5b30..7196982e 100644 --- a/Sources/AWSLambdaPluginHelper/Vendored/signer/AWSSigner.swift +++ b/Sources/AWSLambdaPluginHelper/Vendored/signer/AWSSigner.swift @@ -22,7 +22,6 @@ // https://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html // -import Crypto import NIO import NIOHTTP1 @@ -34,6 +33,29 @@ import struct Foundation.Locale import struct Foundation.TimeZone import struct Foundation.URL +// adapter for the vendored SHA256 hashing function +private struct SHA256 { + static func hash(data: [UInt8]) -> [UInt8] { + Digest.sha256(data) + } + + static func hash(data: Data) -> [UInt8] { + SHA256.hash(data: data.bytes) + } + + static func hash(data: UnsafeBufferPointer) -> [UInt8] { + SHA256.hash(data: [UInt8](data)) + } +} + +// adapter for the vendored hexDigest function +extension Array where Element == UInt8 { + fileprivate func hexDigest() -> String { + // call the hexEncoded function from the Array+Extensions.swift file + self.toHexString() + } +} + /// Amazon Web Services V4 Signer public struct AWSSigner { /// security credentials for accessing AWS services diff --git a/Sources/AWSLambdaPluginHelper/Vendored/spm/ArgumentExtractor.swift b/Sources/AWSLambdaPluginHelper/Vendored/spm/ArgumentExtractor.swift new file mode 100644 index 00000000..8406692c --- /dev/null +++ b/Sources/AWSLambdaPluginHelper/Vendored/spm/ArgumentExtractor.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A rudimentary helper for extracting options and flags from a string list representing command line arguments. The idea is to extract all known options and flags, leaving just the positional arguments. This does not handle well the case in which positional arguments (or option argument values) happen to have the same name as an option or a flag. It only handles the long `--` form of options, but it does respect `--` as an indication that all remaining arguments are positional. +public struct ArgumentExtractor { + private var args: [String] + private let literals: [String] + + /// Initializes a ArgumentExtractor with a list of strings from which to extract flags and options. If the list contains `--`, any arguments that follow it are considered to be literals. + public init(_ arguments: [String]) { + // Split the array on the first `--`, if there is one. Everything after that is a literal. + let parts = arguments.split(separator: "--", maxSplits: 1, omittingEmptySubsequences: false) + self.args = Array(parts[0]) + self.literals = Array(parts.count == 2 ? parts[1] : []) + } + + /// Extracts options of the form `-- ` or `--=` from the remaining arguments, and returns the extracted values. + public mutating func extractOption(named name: String) -> [String] { + var values: [String] = [] + var idx = 0 + while idx < args.count { + var arg = args[idx] + if arg == "--\(name)" { + args.remove(at: idx) + if idx < args.count { + let val = args[idx] + values.append(val) + args.remove(at: idx) + } + } + else if arg.starts(with: "--\(name)=") { + arg.removeFirst(2 + name.count + 1) + values.append(arg) + args.remove(at: idx) + } + else { + idx += 1 + } + } + return values + } + + /// Extracts flags of the form `--` from the remaining arguments, and returns the count. + public mutating func extractFlag(named name: String) -> Int { + var count = 0 + var idx = 0 + while idx < args.count { + let arg = args[idx] + if arg == "--\(name)" { + args.remove(at: idx) + count += 1 + } + else { + idx += 1 + } + } + return count + } + + /// Returns any unextracted flags or options (based strictly on whether remaining arguments have a "--" prefix). + public var unextractedOptionsOrFlags: [String] { + return args.filter{ $0.hasPrefix("--") } + } + + /// Returns all remaining arguments, including any literals after the first `--` if there is one. + public var remainingArguments: [String] { + return args + literals + } +} \ No newline at end of file diff --git a/Sources/AWSLambdaPluginHelper/lambda-build/Builder.swift b/Sources/AWSLambdaPluginHelper/lambda-build/Builder.swift new file mode 100644 index 00000000..fb8ff73a --- /dev/null +++ b/Sources/AWSLambdaPluginHelper/lambda-build/Builder.swift @@ -0,0 +1,423 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +@available(macOS 15.0, *) +struct Builder { + func build(arguments: [String]) async throws { + let configuration = try BuilderConfiguration(arguments: arguments) + + if configuration.help { + self.displayHelpMessage() + return + } + + let builtProducts: [String: URL] + + // build with docker + // TODO: check if dockerToolPath is provided + // When not provided, it means we're building on Amazon Linux 2 + builtProducts = try self.buildInDocker( + packageIdentity: configuration.packageID, + packageDirectory: configuration.packageDirectory, + products: configuration.products, + dockerToolPath: configuration.dockerToolPath, + outputDirectory: configuration.outputDirectory, + baseImage: configuration.baseDockerImage, + disableDockerImageUpdate: configuration.disableDockerImageUpdate, + buildConfiguration: configuration.buildConfiguration, + verboseLogging: configuration.verboseLogging + ) + + // create the archive + let archives = try self.package( + packageName: configuration.packageDisplayName, + products: builtProducts, + zipToolPath: configuration.zipToolPath, + outputDirectory: configuration.outputDirectory, + verboseLogging: configuration.verboseLogging + ) + + print( + "\(archives.count > 0 ? archives.count.description : "no") archive\(archives.count != 1 ? "s" : "") created" + ) + for (product, archivePath) in archives { + print(" * \(product) at \(archivePath.path())") + } + } + + private func buildInDocker( + packageIdentity: String, + packageDirectory: URL, + products: [String], + dockerToolPath: URL, + outputDirectory: URL, + baseImage: String, + disableDockerImageUpdate: Bool, + buildConfiguration: BuildConfiguration, + verboseLogging: Bool + ) throws -> [String: URL] { + + print("-------------------------------------------------------------------------") + print("building \"\(packageIdentity)\" in docker") + print("-------------------------------------------------------------------------") + + if !disableDockerImageUpdate { + // update the underlying docker image, if necessary + print("updating \"\(baseImage)\" docker image") + try Utils.execute( + executable: dockerToolPath, + arguments: ["pull", baseImage], + logLevel: verboseLogging ? .debug : .silent + ) + } + + // get the build output path + let buildOutputPathCommand = "swift build -c \(buildConfiguration.rawValue) --show-bin-path" + let dockerBuildOutputPath = try Utils.execute( + executable: dockerToolPath, + arguments: [ + "run", "--rm", "-v", "\(packageDirectory.path()):/workspace", "-w", "/workspace", baseImage, "bash", + "-cl", buildOutputPathCommand, + ], + logLevel: verboseLogging ? .debug : .silent + ) + guard let buildPathOutput = dockerBuildOutputPath.split(separator: "\n").last else { + throw BuilderErrors.failedParsingDockerOutput(dockerBuildOutputPath) + } + let buildOutputPath = URL( + string: buildPathOutput.replacingOccurrences(of: "/workspace/", with: packageDirectory.description) + )! + + // build the products + var builtProducts = [String: URL]() + for product in products { + print("building \"\(product)\"") + let buildCommand = + "swift build -c \(buildConfiguration.rawValue) --product \(product) --static-swift-stdlib" + if let localPath = ProcessInfo.processInfo.environment["LAMBDA_USE_LOCAL_DEPS"] { + // when developing locally, we must have the full swift-aws-lambda-runtime project in the container + // because Examples' Package.swift have a dependency on ../.. + // just like Package.swift's examples assume ../.., we assume we are two levels below the root project + let slice = packageDirectory.pathComponents.suffix(2) + try Utils.execute( + executable: dockerToolPath, + arguments: [ + "run", "--rm", "--env", "LAMBDA_USE_LOCAL_DEPS=\(localPath)", "-v", + "\(packageDirectory.path())../..:/workspace", "-w", + "/workspace/\(slice.joined(separator: "/"))", baseImage, "bash", "-cl", buildCommand, + ], + logLevel: verboseLogging ? .debug : .output + ) + } else { + try Utils.execute( + executable: dockerToolPath, + arguments: [ + "run", "--rm", "-v", "\(packageDirectory.path()):/workspace", "-w", "/workspace", baseImage, + "bash", "-cl", buildCommand, + ], + logLevel: verboseLogging ? .debug : .output + ) + } + let productPath = buildOutputPath.appending(path: product) + + guard FileManager.default.fileExists(atPath: productPath.path()) else { + print("expected '\(product)' binary at \"\(productPath.path())\"") + throw BuilderErrors.productExecutableNotFound(product) + } + builtProducts[.init(product)] = productPath + } + return builtProducts + } + + // TODO: explore using ziplib or similar instead of shelling out + private func package( + packageName: String, + products: [String: URL], + zipToolPath: URL, + outputDirectory: URL, + verboseLogging: Bool + ) throws -> [String: URL] { + + var archives = [String: URL]() + for (product, artifactPath) in products { + print("-------------------------------------------------------------------------") + print("archiving \"\(product)\"") + print("-------------------------------------------------------------------------") + + // prep zipfile location + let workingDirectory = outputDirectory.appending(path: product) + let zipfilePath = workingDirectory.appending(path: "\(product).zip") + if FileManager.default.fileExists(atPath: workingDirectory.path()) { + try FileManager.default.removeItem(atPath: workingDirectory.path()) + } + try FileManager.default.createDirectory(atPath: workingDirectory.path(), withIntermediateDirectories: true) + + // rename artifact to "bootstrap" + let relocatedArtifactPath = workingDirectory.appending(path: "bootstrap") + try FileManager.default.copyItem(atPath: artifactPath.path(), toPath: relocatedArtifactPath.path()) + + var arguments: [String] = [] +#if os(macOS) || os(Linux) + arguments = [ + "--recurse-paths", + "--symlinks", + zipfilePath.lastPathComponent, + relocatedArtifactPath.lastPathComponent, + ] +#else + throw Errors.unsupportedPlatform("can't or don't know how to create a zip file on this platform") +#endif + + // add resources + var artifactPathComponents = artifactPath.pathComponents + _ = artifactPathComponents.removeFirst() // Get rid of beginning "/" + _ = artifactPathComponents.removeLast() // Get rid of the name of the package + let artifactDirectory = "/\(artifactPathComponents.joined(separator: "/"))" + for fileInArtifactDirectory in try FileManager.default.contentsOfDirectory(atPath: artifactDirectory) { + guard let artifactURL = URL(string: "\(artifactDirectory)/\(fileInArtifactDirectory)") else { + continue + } + + guard artifactURL.pathExtension == "resources" else { + continue // Not resources, so don't copy + } + let resourcesDirectoryName = artifactURL.lastPathComponent + let relocatedResourcesDirectory = workingDirectory.appending(path: resourcesDirectoryName) + if FileManager.default.fileExists(atPath: artifactURL.path()) { + try FileManager.default.copyItem( + atPath: artifactURL.path(), + toPath: relocatedResourcesDirectory.path() + ) + arguments.append(resourcesDirectoryName) + } + } + + // run the zip tool + try Utils.execute( + executable: zipToolPath, + arguments: arguments, + customWorkingDirectory: workingDirectory, + logLevel: verboseLogging ? .debug : .silent + ) + + archives[product] = zipfilePath + } + return archives + } + + private func displayHelpMessage() { + print( + """ + OVERVIEW: A SwiftPM plugin to build and package your lambda function. + + REQUIREMENTS: To use this plugin, you must have docker installed and started. + + USAGE: swift package --allow-network-connections docker archive + [--help] [--verbose] + [--output-directory ] + [--products ] + [--configuration debug | release] + [--swift-version ] + [--base-docker-image ] + [--disable-docker-image-update] + + + OPTIONS: + --verbose Produce verbose output for debugging. + --output-directory The path of the binary package. + (default is `.build/plugins/AWSLambdaPackager/outputs/...`) + --products The list of executable targets to build. + (default is taken from Package.swift) + --configuration The build configuration (debug or release) + (default is release) + --swift-version The swift version to use for building. + (default is latest) + This parameter cannot be used when --base-docker-image is specified. + --base-docker-image The name of the base docker image to use for the build. + (default : swift-:amazonlinux2) + This parameter cannot be used when --swift-version is specified. + --disable-docker-image-update Do not attempt to update the docker image + --help Show help information. + """ + ) + } +} + +private struct BuilderConfiguration: CustomStringConvertible { + + // passed by the user + public let help: Bool + public let outputDirectory: URL + public let products: [String] + public let buildConfiguration: BuildConfiguration + public let verboseLogging: Bool + public let baseDockerImage: String + public let disableDockerImageUpdate: Bool + + // passed by the plugin + public let packageID: String + public let packageDisplayName: String + public let packageDirectory: URL + public let dockerToolPath: URL + public let zipToolPath: URL + + public init(arguments: [String]) throws { + var argumentExtractor = ArgumentExtractor(arguments) + + let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 + let outputPathArgument = argumentExtractor.extractOption(named: "output-path") + let packageIDArgument = argumentExtractor.extractOption(named: "package-id") + let packageDisplayNameArgument = argumentExtractor.extractOption(named: "package-display-name") + let packageDirectoryArgument = argumentExtractor.extractOption(named: "package-directory") + let dockerToolPathArgument = argumentExtractor.extractOption(named: "docker-tool-path") + let zipToolPathArgument = argumentExtractor.extractOption(named: "zip-tool-path") + let productsArgument = argumentExtractor.extractOption(named: "products") + let configurationArgument = argumentExtractor.extractOption(named: "configuration") + let swiftVersionArgument = argumentExtractor.extractOption(named: "swift-version") + let baseDockerImageArgument = argumentExtractor.extractOption(named: "base-docker-image") + let disableDockerImageUpdateArgument = argumentExtractor.extractFlag(named: "disable-docker-image-update") > 0 + let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 + + // help required ? + self.help = helpArgument + + // verbose logging required ? + self.verboseLogging = verboseArgument + + // package id + guard !packageIDArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--package-id argument is required") + } + self.packageID = packageIDArgument.first! + + // package display name + guard !packageDisplayNameArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--package-display-name argument is required") + } + self.packageDisplayName = packageDisplayNameArgument.first! + + // package directory + guard !packageDirectoryArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--package-directory argument is required") + } + self.packageDirectory = URL(fileURLWithPath: packageDirectoryArgument.first!) + + // docker tool path + guard !dockerToolPathArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--docker-tool-path argument is required") + } + self.dockerToolPath = URL(fileURLWithPath: dockerToolPathArgument.first!) + + // zip tool path + guard !zipToolPathArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--zip-tool-path argument is required") + } + self.zipToolPath = URL(fileURLWithPath: zipToolPathArgument.first!) + + // output directory + guard !outputPathArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--output-path is required") + } + self.outputDirectory = URL(fileURLWithPath: outputPathArgument.first!) + + // products + guard !productsArgument.isEmpty else { + throw BuilderErrors.invalidArgument("--products argument is required") + } + self.products = productsArgument.flatMap { $0.split(separator: ",").map(String.init) } + + // build configuration + guard let buildConfigurationName = configurationArgument.first else { + throw BuilderErrors.invalidArgument("--configuration argument is equired") + } + guard let _buildConfiguration = BuildConfiguration(rawValue: buildConfigurationName) else { + throw BuilderErrors.invalidArgument("invalid build configuration named '\(buildConfigurationName)'") + } + self.buildConfiguration = _buildConfiguration + + guard !(!swiftVersionArgument.isEmpty && !baseDockerImageArgument.isEmpty) else { + throw BuilderErrors.invalidArgument("--swift-version and --base-docker-image are mutually exclusive") + } + + let swiftVersion = swiftVersionArgument.first ?? .none // undefined version will yield the latest docker image + self.baseDockerImage = + baseDockerImageArgument.first ?? "swift:\(swiftVersion.map { $0 + "-" } ?? "")amazonlinux2" + + self.disableDockerImageUpdate = disableDockerImageUpdateArgument + + if self.verboseLogging { + print("-------------------------------------------------------------------------") + print("configuration") + print("-------------------------------------------------------------------------") + print(self) + } + } + + var description: String { + """ + { + outputDirectory: \(self.outputDirectory) + products: \(self.products) + buildConfiguration: \(self.buildConfiguration) + dockerToolPath: \(self.dockerToolPath) + baseDockerImage: \(self.baseDockerImage) + disableDockerImageUpdate: \(self.disableDockerImageUpdate) + zipToolPath: \(self.zipToolPath) + packageID: \(self.packageID) + packageDisplayName: \(self.packageDisplayName) + packageDirectory: \(self.packageDirectory) + } + """ + } +} + +private enum BuilderErrors: Error, CustomStringConvertible { + case invalidArgument(String) + case unsupportedPlatform(String) + case unknownProduct(String) + case productExecutableNotFound(String) + case failedWritingDockerfile + case failedParsingDockerOutput(String) + case processFailed([String], Int32) + + var description: String { + switch self { + case .invalidArgument(let description): + return description + case .unsupportedPlatform(let description): + return description + case .unknownProduct(let description): + return description + case .productExecutableNotFound(let product): + return "product executable not found '\(product)'" + case .failedWritingDockerfile: + return "failed writing dockerfile" + case .failedParsingDockerOutput(let output): + return "failed parsing docker output: '\(output)'" + case .processFailed(let arguments, let code): + return "\(arguments.joined(separator: " ")) failed with code \(code)" + } + } +} + +private enum BuildConfiguration: String { + case debug + case release +} diff --git a/Sources/AWSLambdaPluginHelper/lambda-deploy/Deployer.swift b/Sources/AWSLambdaPluginHelper/lambda-deploy/Deployer.swift new file mode 100644 index 00000000..a9e2462f --- /dev/null +++ b/Sources/AWSLambdaPluginHelper/lambda-deploy/Deployer.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +struct Deployer { + + func deploy(arguments: [String]) async throws { + let configuration = try DeployerConfiguration(arguments: arguments) + + if configuration.help { + self.displayHelpMessage() + return + } + + //FIXME: use Logger + print("TODO: deploy") + } + + private func displayHelpMessage() { + print( + """ + OVERVIEW: A SwiftPM plugin to deploy a Lambda function. + + USAGE: swift package lambda-deploy + [--with-url] + [--help] [--verbose] + + OPTIONS: + --with-url Add an URL to access the Lambda function + --verbose Produce verbose output for debugging. + --help Show help information. + """ + ) + } +} + +private struct DeployerConfiguration: CustomStringConvertible { + public let help: Bool + public let verboseLogging: Bool + + public init(arguments: [String]) throws { + var argumentExtractor = ArgumentExtractor(arguments) + let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 + let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 + + // help required ? + self.help = helpArgument + + // verbose logging required ? + self.verboseLogging = verboseArgument + } + + var description: String { + """ + { + verboseLogging: \(self.verboseLogging) + } + """ + } +} diff --git a/Sources/AWSLambdaPluginHelper/lambda-init/Initializer.swift b/Sources/AWSLambdaPluginHelper/lambda-init/Initializer.swift new file mode 100644 index 00000000..8515d586 --- /dev/null +++ b/Sources/AWSLambdaPluginHelper/lambda-init/Initializer.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +struct Initializer { + + private let destFileName = "Sources/main.swift" + + func initialize(arguments: [String]) async throws { + + let configuration = try InitializerConfiguration(arguments: arguments) + + if configuration.help { + self.displayHelpMessage() + return + } + + let destFileURL = configuration.destinationDir.appendingPathComponent(destFileName) + do { + try functionWithUrlTemplate.write(to: destFileURL, atomically: true, encoding: .utf8) + + if configuration.verboseLogging { + print("File created at: \(destFileURL)") + } + + print("✅ Lambda function written to \(destFileName)") + print("📦 You can now package with: 'swift package archive'") + } catch { + print("🛑Failed to create the Lambda function file: \(error)") + } + } + + + private func displayHelpMessage() { + print( + """ + OVERVIEW: A SwiftPM plugin to scaffold a HelloWorld Lambda function. + + USAGE: swift package lambda-init + [--help] [--verbose] + [--allow-writing-to-package-directory] + + OPTIONS: + --allow-writing-to-package-directory Don't ask for permissions to write files. + --verbose Produce verbose output for debugging. + --help Show help information. + """ + ) + } +} + +private struct InitializerConfiguration: CustomStringConvertible { + public let help: Bool + public let verboseLogging: Bool + public let destinationDir: URL + + public init(arguments: [String]) throws { + var argumentExtractor = ArgumentExtractor(arguments) + let verboseArgument = argumentExtractor.extractFlag(named: "verbose") > 0 + let helpArgument = argumentExtractor.extractFlag(named: "help") > 0 + let destDirArgument = argumentExtractor.extractOption(named: "dest-dir") + + // help required ? + self.help = helpArgument + + // verbose logging required ? + self.verboseLogging = verboseArgument + + // dest dir + self.destinationDir = URL(fileURLWithPath: destDirArgument[0]) + } + + var description: String { + """ + { + verboseLogging: \(self.verboseLogging) + destinationDir: \(self.destinationDir) + } + """ + } +} diff --git a/Plugins/AWSLambdaInitializer/Template.swift b/Sources/AWSLambdaPluginHelper/lambda-init/Template.swift similarity index 98% rename from Plugins/AWSLambdaInitializer/Template.swift rename to Sources/AWSLambdaPluginHelper/lambda-init/Template.swift index 133e5033..0ca37ace 100644 --- a/Plugins/AWSLambdaInitializer/Template.swift +++ b/Sources/AWSLambdaPluginHelper/lambda-init/Template.swift @@ -12,8 +12,6 @@ // //===----------------------------------------------------------------------===// -import Foundation - let functionWithUrlTemplate = #""" import AWSLambdaRuntime import AWSLambdaEvents diff --git a/Sources/AWSLambdaPluginHelper/main.swift b/Sources/AWSLambdaPluginHelper/main.swift deleted file mode 100644 index 3ed1e5b5..00000000 --- a/Sources/AWSLambdaPluginHelper/main.swift +++ /dev/null @@ -1 +0,0 @@ -print("Deployer")