Files

443 lines
20 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright SwiftAWSLambdaRuntime project authors
// Copyright (c) Amazon.com, Inc. or its affiliates.
// 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()) {
do {
arguments.append(resourcesDirectoryName)
try FileManager.default.copyItem(
atPath: artifactURL.path(),
toPath: relocatedResourcesDirectory.path()
)
} catch let error as CocoaError {
// On Linux, when the build has been done with Docker,
// the source file are owned by root
// this causes a permission error **after** the files have been copied
// see https://github.com/awslabs/swift-aws-lambda-runtime/issues/449
// see https://forums.swift.org/t/filemanager-copyitem-on-linux-fails-after-copying-the-files/77282
// because this error happens after the files have been copied, we can ignore it
// this code checks if the destination file exists
// if they do, just ignore error, otherwise throw it up to the caller.
if !(error.code == CocoaError.Code.fileWriteNoPermission
&& FileManager.default.fileExists(atPath: relocatedResourcesDirectory.path()))
{
throw error
} // else just ignore it
}
}
}
// 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 lambda-build
[--help] [--verbose]
[--output-path <path>]
[--products <list of products>]
[--configuration debug | release]
[--swift-version <version>]
[--base-docker-image <docker_image_name>]
[--disable-docker-image-update]
OPTIONS:
--verbose Produce verbose output for debugging.
--output-path <path> The path of the binary package.
(default is `.build/plugins/AWSLambdaPackager/outputs/...`)
--products <list> The list of executable targets to build.
(default is taken from Package.swift)
--configuration <name> 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 <name> The name of the base docker image to use for the build.
(default : swift-<version>: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
}