Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

๐Ÿš€ Added resolving env variables in plans #385

Merged
merged 7 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Docs/commands-help/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The command allows you to write a plan (combination of commands) in YAML format:
cache_plan_name:
- command: warmup
argument: s3.eu-west-2.amazonaws.com
headers: 'secret-key: ${RUGBY_S3_SECRET_KEY}'
except: SomePod
arch: x86_64
- command: build
Expand Down Expand Up @@ -174,3 +175,12 @@ usual:
strip: true
```
For example, the [cache](shortcuts/cache.md) command has a flag `strip`.

Plans can access environment variables in two different ways:
```yml
usual:
- command: warmup
argument: s3.eu-west-2.amazonaws.com
headers: 'secret-key: ${RUGBY_S3_SECRET_KEY}'
except: $BAD_POD_TARGET_NAME0
```
10 changes: 5 additions & 5 deletions Sources/Rugby/Commands/Mixed/Plan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ struct Plan: AsyncParsableCommand {
// It's a hidden subcommand
if let name, name == .plansList {
// Prints raw plans list for autocompletion
let plans = (try? dependencies.plansParser.plans(atPath: path)) ?? []
let plans = (try? await dependencies.plansParser.plans(atPath: path)) ?? []
plans.forEach { print($0.name) }
return
}
Expand All @@ -40,15 +40,15 @@ struct Plan: AsyncParsableCommand {

extension Plan: RunnableCommand {
func body() async throws {
let plan = try selectPlan()
let plan = try await selectPlan()
try await run(plan: plan)
}

private func selectPlan() throws -> RugbyFoundation.Plan {
private func selectPlan() async throws -> RugbyFoundation.Plan {
if let name {
return try dependencies.plansParser.planNamed(name, path: path)
return try await dependencies.plansParser.planNamed(name, path: path)
} else {
return try dependencies.plansParser.topPlan(atPath: path)
return try await dependencies.plansParser.topPlan(atPath: path)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ final class BuildPhaseHasher: Loggable {
let logger: ILogger
private let workingDirectoryPath: String
private let fileContentHasher: IFileContentHasher
private let xcodeEnvResolver: IXcodeEnvResolver
private let envVariablesResolver: IEnvVariablesResolver

private let dollarSymbol = "$"

init(logger: ILogger,
workingDirectoryPath: String,
fileContentHasher: IFileContentHasher,
xcodeEnvResolver: IXcodeEnvResolver) {
envVariablesResolver: IEnvVariablesResolver) {
self.workingDirectoryPath = workingDirectoryPath
self.logger = logger
self.fileContentHasher = fileContentHasher
self.xcodeEnvResolver = xcodeEnvResolver
self.envVariablesResolver = envVariablesResolver
}

// MARK: - Private
Expand Down Expand Up @@ -64,7 +64,7 @@ final class BuildPhaseHasher: Loggable {
additionalEnv: [String: String]
) async throws -> (resolved: [String], unresolved: [String]) {
let pathsToFileLists = try await paths.concurrentMap {
try await self.xcodeEnvResolver.resolve(path: $0, additionalEnv: additionalEnv)
try await self.envVariablesResolver.resolveXcodeVariables(in: $0, additionalEnv: additionalEnv)
}

let (unresolvedFileLists, resolvedFileLists) = Set(pathsToFileLists).partition { $0.contains(dollarSymbol) }
Expand All @@ -73,7 +73,7 @@ final class BuildPhaseHasher: Loggable {
let content = try File.read(at: path)
let pathsFromFile = content.components(separatedBy: "\n")
return try await pathsFromFile.concurrentMap {
try await self.xcodeEnvResolver.resolve(path: $0, additionalEnv: additionalEnv)
try await self.envVariablesResolver.resolveXcodeVariables(in: $0, additionalEnv: additionalEnv)
}
}

Expand Down
42 changes: 0 additions & 42 deletions Sources/RugbyFoundation/Core/Common/Hashers/XcodeEnvResolver.swift

This file was deleted.

62 changes: 62 additions & 0 deletions Sources/RugbyFoundation/Core/Env/EnvVariablesResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// MARK: - Interface

protocol IEnvVariablesResolver: AnyObject {
func resolve(in string: String) async throws -> String

func resolveXcodeVariables(
in string: String,
additionalEnv: [String: String]
) async throws -> String
}

// MARK: - Implementation

final class EnvVariablesResolver: Loggable {
let logger: ILogger
private let env: [String: String]

private let envVariablesRegex = #"\$[\{]?([a-zA-Z0-9_]+)[\}]?"#
private let xcodeVariablesRegex = #"\$[\{\(]?([a-zA-Z0-9_]+)[\}\)]?"#

init(logger: ILogger, env: [String: String]) {
self.logger = logger
self.env = env
}

private func resolve(
in string: String,
withRegex regex: String,
additionalEnv: [String: String]
) async throws -> String {
var resolvedPath = string
var replaced = true
while replaced {
replaced = false
let groups = try resolvedPath.groups(regex: regex)
guard groups.count == 2 else { continue }
let (match, variable) = (groups[0], groups[1])
guard let replace = env[variable] ?? additionalEnv[variable] else {
await log("Can't find \(match) environment variable.", level: .info)
continue
}
resolvedPath = resolvedPath.replacingOccurrences(of: match, with: replace)
replaced = true
}
return resolvedPath
}
}

// MARK: - IEnvVariablesResolver

extension EnvVariablesResolver: IEnvVariablesResolver {
func resolve(in string: String) async throws -> String {
try await resolve(in: string, withRegex: envVariablesRegex, additionalEnv: [:])
}

func resolveXcodeVariables(
in string: String,
additionalEnv: [String: String]
) async throws -> String {
try await resolve(in: string, withRegex: xcodeVariablesRegex, additionalEnv: additionalEnv)
}
}
83 changes: 56 additions & 27 deletions Sources/RugbyFoundation/Core/Plans/PlansParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import Yams
public protocol IPlansParser: AnyObject {
/// Returns plans list.
/// - Parameter path: A path to plans file.
func plans(atPath path: String) throws -> [Plan]
func plans(atPath path: String) async throws -> [Plan]

/// Returns the first plan from file at a path.
/// - Parameter path: A path to plans file.
func topPlan(atPath path: String) throws -> Plan
func topPlan(atPath path: String) async throws -> Plan

/// Returns a plan with the name.
/// - Parameters:
/// - name: A name of plan to find.
/// - path: A path to plans file.
func planNamed(_ name: String, path: String) throws -> Plan
func planNamed(_ name: String, path: String) async throws -> Plan
}

/// The Rugby plan structure.
Expand Down Expand Up @@ -76,18 +76,24 @@ enum PlansParserError: LocalizedError {
final class PlansParser {
private typealias Error = PlansParserError
private typealias RawCommand = [String: Any]

private let envVariablesResolver: IEnvVariablesResolver
private var cache: [String: [Plan]] = [:]
private let topPlanRegex = #"^([\w-]+)(?=:)"#
private let parsers: [FieldParser] = [
StringFieldParser(),
private lazy var parsers: [FieldParser] = [
StringFieldParser(envVariablesResolver: envVariablesResolver),
BoolFieldParser(),
IntFieldParser(),
StringsFieldParser()
StringsFieldParser(envVariablesResolver: envVariablesResolver)
]

init(envVariablesResolver: IEnvVariablesResolver) {
self.envVariablesResolver = envVariablesResolver
}

// MARK: - Methods

private func parse(path: String) throws -> [Plan] {
private func parse(path: String) async throws -> [Plan] {
if let cachedPlans = cache[path] { return cachedPlans }

let content = try File.read(at: path)
Expand All @@ -100,8 +106,13 @@ final class PlansParser {

// Bubbling up the 1st plan
let sortedPlans = rawPlans.sorted { lhs, _ in lhs.key == firstPlan }
let plans = try sortedPlans.compactMap { name, commands in
try Plan(name: name, commands: commands.compactMap(parseCommand))
var plans: [Plan] = []
for (name, commands) in sortedPlans {
var parsedCommands: [Plan.Command] = []
for command in commands {
try await parsedCommands.append(parseCommand(command))
}
plans.append(Plan(name: name, commands: parsedCommands))
}
cache[path] = plans
return plans
Expand All @@ -113,16 +124,20 @@ final class PlansParser {
return firstPlan
}

private func parseCommand(_ command: RawCommand) throws -> Plan.Command {
private func parseCommand(_ command: RawCommand) async throws -> Plan.Command {
guard let commandName = command[.commandKey] as? String else {
throw Error.missedCommandType
}

let args: [String] = try command.keys.sorted().reduce(into: []) { args, key in
guard let value = command[key], key != .commandKey else { return }
guard parsers.contains(where: { $0.parse(value, ofField: key, toArgs: &args) }) else {
throw Error.unknownArgumentType(value)
var args: [String] = []
for key in command.keys.sorted() {
guard let value = command[key], key != .commandKey else { continue }
var parsed = false
for parser in parsers {
parsed = try await parser.parse(value, ofField: key, toArgs: &args)
if parsed { break }
}
guard parsed else { throw Error.unknownArgumentType(value) }
}
return Plan.Command(name: commandName, args: args)
}
Expand All @@ -139,17 +154,24 @@ private extension String {
// MARK: - Field Parsers

private protocol FieldParser: AnyObject {
func parse(_ value: Any, ofField field: String, toArgs args: inout [String]) -> Bool
func parse(_ value: Any, ofField field: String, toArgs args: inout [String]) async throws -> Bool
}

private final class StringFieldParser: FieldParser {
func parse(_ value: Any, ofField field: String, toArgs args: inout [String]) -> Bool {
private let envVariablesResolver: IEnvVariablesResolver

init(envVariablesResolver: IEnvVariablesResolver) {
self.envVariablesResolver = envVariablesResolver
}

func parse(_ value: Any, ofField field: String, toArgs args: inout [String]) async throws -> Bool {
guard let string = value as? String else { return false }
let resolvedString = try await envVariablesResolver.resolve(in: string)
if field == .argumentKey {
args.insert(string, at: 0)
args.insert(resolvedString, at: 0)
} else {
args.append("\(String.optionPrefix)\(field)")
args.append(string)
args.append(resolvedString)
}
return true
}
Expand All @@ -175,14 +197,21 @@ private final class IntFieldParser: FieldParser {
}

private final class StringsFieldParser: FieldParser {
func parse(_ value: Any, ofField field: String, toArgs args: inout [String]) -> Bool {
private let envVariablesResolver: IEnvVariablesResolver

init(envVariablesResolver: IEnvVariablesResolver) {
self.envVariablesResolver = envVariablesResolver
}

func parse(_ value: Any, ofField field: String, toArgs args: inout [String]) async throws -> Bool {
guard let strings = value as? [String] else { return false }
guard strings.isNotEmpty else { return true }
let resolvedStrings = try await strings.concurrentMap(envVariablesResolver.resolve)
if field == .argumentKey {
args.insert(contentsOf: strings, at: 0)
args.insert(contentsOf: resolvedStrings, at: 0)
} else {
args.append("\(String.optionPrefix)\(field)")
args.append(contentsOf: strings)
args.append(contentsOf: resolvedStrings)
}
return true
}
Expand All @@ -191,20 +220,20 @@ private final class StringsFieldParser: FieldParser {
// MARK: - IPlansParser

extension PlansParser: IPlansParser {
public func plans(atPath path: String) throws -> [Plan] {
try parse(path: path)
public func plans(atPath path: String) async throws -> [Plan] {
try await parse(path: path)
}

public func topPlan(atPath path: String) throws -> Plan {
let plans = try plans(atPath: path)
public func topPlan(atPath path: String) async throws -> Plan {
let plans = try await plans(atPath: path)
guard let plan = plans.first else {
throw Error.noPlans
}
return plan
}

public func planNamed(_ name: String, path: String) throws -> Plan {
let plans = try plans(atPath: path)
public func planNamed(_ name: String, path: String) async throws -> Plan {
let plans = try await plans(atPath: path)
guard let plan = plans.first(where: { $0.name == name }) else {
throw Error.noPlanWithName(name)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/RugbyFoundation/Vault/Commands/Vault+Plan.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
public extension Vault {
/// The service to parse YAML files with Rugby plans.
var plansParser: IPlansParser { PlansParser() }
var plansParser: IPlansParser { PlansParser(envVariablesResolver: envVariablesResolver) }
}
Loading
Loading