├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .mailmap ├── .spi.yml ├── .swift-format ├── CONTRIBUTING.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Formic │ ├── .swift │ ├── Backoff.swift │ ├── Command.swift │ ├── CommandError.swift │ ├── CommandOutput.swift │ ├── Commands │ ├── AnyCommand.swift │ ├── CopyFrom.swift │ ├── CopyInto.swift │ ├── SSHCommand.swift │ ├── ShellCommand.swift │ └── VerifyAccess.swift │ ├── DependencyProxies │ ├── CommandInvoker.swift │ ├── LocalSystemAccess.swift │ └── ProcessCommandInvoker.swift │ ├── Documentation.docc │ ├── Backoff.md │ ├── Backoff_Strategy.md │ ├── Command.md │ ├── CommandError.md │ ├── CommandOutput.md │ ├── Documentation.md │ ├── Engine │ │ ├── CommandExecutionResult.md │ │ ├── Engine.md │ │ └── Verbosity.md │ ├── FormicGoals.md │ ├── Host.md │ ├── Host_IPv4Address.md │ ├── Host_NetworkAddress.md │ └── SSHAccessCredentials.md │ ├── Engine │ ├── .swift │ ├── CommandExecutionResult.swift │ ├── CommandOutputDetail.swift │ └── Engine.swift │ ├── IPv4Address.swift │ ├── NetworkAddress.swift │ ├── RemoteHost.swift │ ├── ResourceTypes │ ├── Dpkg+Parsers.swift │ ├── Dpkg.swift │ ├── OperatingSystem.swift │ └── Swarm+Parsers.swift │ ├── Resources │ ├── CollectionResource.swift │ ├── Resource.swift │ ├── ResourceError.swift │ ├── SingularResource.swift │ └── StatefulResource.swift │ └── SSHAccessCredentials.swift ├── Tests └── formicTests │ ├── BackoffTests.swift │ ├── CommandExecutionOutputTests.swift │ ├── CommandInvokerTests.swift │ ├── CommandOutputTests.swift │ ├── Commands │ ├── AnyCommandTests.swift │ ├── CopyFromTests.swift │ ├── CopyIntoTests.swift │ ├── ShellCommandTests.swift │ └── VerifyAccessCommandTests.swift │ ├── EngineTests.swift │ ├── Fixtures │ ├── id_ed25519 │ └── id_ed25519.pub │ ├── HostTests.swift │ ├── IPv4AddressTests.swift │ ├── IntegrationTests │ └── CIIntegrationTests.swift │ ├── NetworkAddressTests.swift │ ├── Resources │ ├── OperatingSystemTests.swift │ ├── PackagesTests.swift │ └── SwarmInitTests.swift │ ├── SSHAccessCredentialsTests.swift │ ├── TestDependencies.swift │ ├── TestError.swift │ └── TestTags.swift ├── examples └── updateExample │ ├── .gitignore │ ├── .vscode │ └── launch.json │ ├── Package.resolved │ ├── Package.swift │ └── Sources │ └── updateExample.swift └── scripts └── preflight.bash /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*.swift] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the issue** 11 | A clear and concise description of what the behavior is that's unexpected or wrong. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Version** 24 | What version (or branch, or tag) of the library are you using? 25 | 26 | **Current OS** 27 | What operating system is the being run from? (`uname -a` and the output of `cat /etc/-lsb-release` if the target is Linux) 28 | 29 | **Target OS** 30 | What operating system is the code interacting with? (`uname -a` and the output of `cat /etc/lsb-release` if the target is Linux) 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the "why" behind your request** 14 | A clear and concise description of what problem you're trying to solve. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How has this been tested? 11 | 12 | 13 | 14 | 15 | ## Types of changes 16 | 17 | - [ ] Bug fix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected. Examples include renaming parameters in public API, removing public API, or adding a new parameter to public API without a default value.) 20 | 21 | ## Checklist 22 | 23 | 24 | - [ ] I have updated the documentation accordingly. 25 | - [ ] I have added tests to cover my changes. 26 | - [ ] All new and existing tests passed. 27 | - [ ] I have run `./scripts/preflight.bash` and it passed without errors. 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**.swift' 9 | - '**.yml' 10 | pull_request: 11 | branches: [ main ] 12 | workflow_dispatch: 13 | 14 | jobs: 15 | linux: 16 | runs-on: ubuntu-latest 17 | services: 18 | ssh-server: 19 | image: lscr.io/linuxserver/openssh-server 20 | # docs: https://hub.docker.com/r/linuxserver/openssh-server 21 | ports: 22 | - 2222:2222 23 | env: 24 | USER_NAME: fred 25 | PUBLIC_KEY: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvu92Ykn9Yr7jxemV9MVXPK8nchioFkPUs7rC+5Yus9 heckj@Sparrow.local 26 | timeout-minutes: 15 27 | strategy: 28 | matrix: 29 | image: ["swift:6.0"] 30 | 31 | container: 32 | image: ${{ matrix.image }} 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | - name: Lint 37 | run: swift package lint-source-code 38 | - name: Enforce permissions for private key fixture integration tests 39 | run: chmod 0600 Tests/formicTests/Fixtures/id_ed25519 40 | - name: Display permissions for private key usage in functional tests 41 | run: ls -al Tests/formicTests/Fixtures 42 | - name: Test 43 | run: | 44 | swift test --enable-code-coverage 45 | env: 46 | SSH_HOST: ssh-server 47 | SSH_PORT: 2222 48 | SSH_USERNAME: fred 49 | - name: Convert coverage files 50 | run: | 51 | llvm-cov export --format="lcov" \ 52 | --instr-profile .build/debug/codecov/default.profdata \ 53 | -ignore-filename-regex="\/Tests\/" \ 54 | -ignore-filename-regex="\/Benchmarks\/" \ 55 | .build/debug/FormicPackageTests.xctest > info.lcov 56 | - name: Upload to codecov.io 57 | uses: codecov/codecov-action@v4 58 | with: 59 | token: ${{ secrets.CODECOV_TOKEN }} 60 | file: info.lcov 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | .swiftpm 31 | 32 | .build/ 33 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Joseph Heck 2 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Formic] 5 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "indentation" : { 3 | "spaces" : 4 4 | }, 5 | "lineLength" : 120, 6 | "rules" : { 7 | "AllPublicDeclarationsHaveDocumentation" : true, 8 | "AlwaysUseLiteralForEmptyCollectionInit" : false, 9 | "AlwaysUseLowerCamelCase" : true, 10 | "AmbiguousTrailingClosureOverload" : true, 11 | "BeginDocumentationCommentWithOneLineSummary" : true, 12 | "DoNotUseSemicolons" : true, 13 | "DontRepeatTypeInStaticProperties" : true, 14 | "FileScopedDeclarationPrivacy" : true, 15 | "FullyIndirectEnum" : true, 16 | "GroupNumericLiterals" : true, 17 | "IdentifiersMustBeASCII" : true, 18 | "NeverForceUnwrap" : true, 19 | "NeverUseForceTry" : false, 20 | "NeverUseImplicitlyUnwrappedOptionals" : false, 21 | "NoAccessLevelOnExtensionDeclaration" : true, 22 | "NoAssignmentInExpressions" : true, 23 | "NoBlockComments" : true, 24 | "NoCasesWithOnlyFallthrough" : true, 25 | "NoEmptyTrailingClosureParentheses" : true, 26 | "NoLabelsInCasePatterns" : true, 27 | "NoLeadingUnderscores" : false, 28 | "NoParensAroundConditions" : true, 29 | "NoPlaygroundLiterals" : true, 30 | "NoVoidReturnOnFunctionSignature" : true, 31 | "OmitExplicitReturns" : false, 32 | "OneCasePerLine" : true, 33 | "OneVariableDeclarationPerLine" : true, 34 | "OnlyOneTrailingClosureArgument" : true, 35 | "OrderedImports" : true, 36 | "ReplaceForEachWithForLoop" : true, 37 | "ReturnVoidInsteadOfEmptyTuple" : true, 38 | "TypeNamesShouldBeCapitalized" : true, 39 | "UseEarlyExits" : false, 40 | "UseExplicitNilCheckInConditions" : true, 41 | "UseLetInEveryBoundCaseVariable" : true, 42 | "UseShorthandTypeNames" : true, 43 | "UseSingleLinePropertyGetter" : true, 44 | "UseSynthesizedInitializer" : true, 45 | "UseTripleSlashForDocumentationComments" : true, 46 | "UseWhereClausesInForLoops" : false, 47 | "ValidateDocumentationComments" : false 48 | }, 49 | "tabWidth" : 4 50 | } 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Formic 2 | 3 | ## Overview 4 | 5 | - [issues](https://github.com/heckj/formic/issues) are welcome. 6 | - this is a personal project and there's no guarantee of support or maintenance. 7 | 8 | - [pull requests](https://github.com/heckj/formic/pulls) are welcome. 9 | - discuss larger features or efforts in an [issue](https://github.com/heckj/formic/issues) first. 10 | - linting should pass before review. 11 | - all checks must pass before merging. 12 | 13 | The library is developed with the following expectations: 14 | - Swift 6 language mode 15 | - macOS and Linux support 16 | - [swift-testing](https://developer.apple.com/documentation/testing/) 17 | 18 | ## Contributing and Support 19 | 20 | If this sounds interesting to you - you're welcome to fork/borrow any of this code for your own use (it's intentionally MIT licensed). 21 | I'm also open to contributions, but I'm not looking to build a large community around this. 22 | So pull requests and issues are open and welcome, but I am no beholden to anyone else to fix, change, or resolve issues, and there should be no expectation of support or maintenance. 23 | 24 | If you _really_ want to use this, but need some deeper level of support, reach out to me directly, and we can discuss a possible arrangement. 25 | 26 | The world of software deployment has changed significantly in the last 20 years, 10 even. 27 | Docker and images, Cloud Functions/Lambdas, Virtual Machines are easily accessible, and a variety of Provider-specific hosted resources from Kubernetes Clusters to CDNs. 28 | There's a lot of places this _could_ go. 29 | If you want to extend and use this as well, please do. 30 | 31 | ## Tests 32 | 33 | As a Swift 6 project, the testing is using [swift-testing](https://developer.apple.com/documentation/testing/) instead of XCTest. 34 | 35 | As an IT automation tool, not everything can be easily tested with unit tests. 36 | That said, there's enough abstraction in the API (and I want to keep it so), that this project can leverage the [swift-dependencies](https://github.com/pointfreeco/swift-dependencies) package to enable a bit of "dependency injection" to make this easier. 37 | There are a couple of internal code structures that are explicitly to support dependency injection. 38 | Extend the existing ones or add your own as needed to support testing. 39 | 40 | The CI system with this package sends coverage data to CodeCov: 41 | 42 | [![codecov](https://codecov.io/gh/heckj/formic/graph/badge.svg?token=BGzZDLrdjQ)](https://codecov.io/gh/heckj/formic) 43 | 44 | [![code coverag sunburst diagram](https://codecov.io/gh/heckj/formic/graphs/sunburst.svg?token=BGzZDLrdjQ)](https://app.codecov.io/gh/heckj/formic) 45 | 46 | ## Documentation 47 | 48 | As mentioned above, the `.swift-format` configuration is picky about ensuring documentation exists for public types and APIs, and that's verified on continuous integration. 49 | The documentation is hosting courtesy of [Swift Package Index](https://swiftpackageindex.com) - https://swiftpackageindex.com/heckj/formic/documentation/formic 50 | 51 | If you're adding, or changing API, in a pull request - make sure to also include any relevant API documentation, and I recommend doing a local build of the documentation to ensure it looks right. 52 | 53 | 54 | ## Style and Formatting 55 | 56 | I'm doing this from scratch to do everything in Swift 6 and concurrency safe from the start. 57 | For this project, I'm trying to embrace `swift-format` (the one built-in to the Swift6 toolchain). 58 | There's a script to use pre-push to GitHub (yeah, it could be a git hook): 59 | 60 | ```bash 61 | ./scripts/preflight.bassh 62 | ``` 63 | 64 | It runs the formatter, then the linter - to verify things are "good" locally before pushing to GitHub. 65 | The rules enabled in `.swift-format` in the repository include being pushy and picky about documentation. 66 | For details on the configuration options for `swift-format`, see the [Configuration documentation](https://github.com/swiftlang/swift-format/blob/main/Documentation/Configuration.md). 67 | 68 | If you want to just run the commands on directly: 69 | 70 | ```bash 71 | swift package lint-source-code 72 | ``` 73 | 74 | ```bash 75 | swift package format-source-code --allow-writing-to-package-directory 76 | ``` 77 | 78 | ## Checking the code on Linux 79 | 80 | The CI system checks this on Linux with Swift 6. 81 | I do development on an Apple Silicon mac, and use Docker (well, Orbstack really) to check on Linux locally as well. 82 | 83 | Preload the images: 84 | 85 | ```bash 86 | docker pull swift:5.9 # 2.55GB 87 | docker pull swift:5.10 # 2.57GB 88 | docker pull swift:6.0 # 3.2GB 89 | ``` 90 | 91 | Get a command-line operational with the version of swift you want. For example: 92 | 93 | ```bash 94 | docker run --rm --privileged --interactive --tty --volume "$(pwd):/src" --workdir "/src" swift:6.0 95 | ``` 96 | 97 | Append on specific scripts or commands for run-and-done: 98 | 99 | ```bash 100 | docker run --rm --privileged --interactive --tty --volume "$(pwd):/src" --workdir "/src" swift:6.0 scripts/precheck.bash 101 | ``` 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Joseph Heck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Formic", 7 | platforms: [.macOS(.v13), .iOS(.v16)], 8 | products: [ 9 | .library( 10 | name: "Formic", 11 | targets: ["Formic"] 12 | ) 13 | ], 14 | dependencies: [ 15 | .package( 16 | url: "https://github.com/apple/swift-argument-parser.git", 17 | .upToNextMajor(from: "1.5.0") 18 | ), 19 | .package( 20 | url: "https://github.com/apple/swift-async-dns-resolver", 21 | .upToNextMajor(from: "0.1.0") 22 | ), 23 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), 24 | .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.13.0"), 25 | .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), 26 | .package( 27 | url: "https://github.com/swiftlang/swift-format.git", 28 | .upToNextMajor(from: "600.0.0")), 29 | .package(url: "https://github.com/neallester/swift-log-testing.git", from: "0.0.1"), 30 | ], 31 | targets: [ 32 | .target( 33 | name: "Formic", 34 | dependencies: [ 35 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 36 | .product(name: "AsyncDNSResolver", package: "swift-async-dns-resolver"), 37 | .product(name: "Parsing", package: "swift-parsing"), 38 | .product(name: "Dependencies", package: "swift-dependencies"), 39 | .product(name: "Logging", package: "swift-log"), 40 | ] 41 | ), 42 | .testTarget( 43 | name: "FormicTests", 44 | dependencies: [ 45 | "Formic", 46 | .product(name: "SwiftLogTesting", package: "swift-log-testing"), 47 | ], 48 | resources: [ 49 | .copy("formicTests/Fixtures/id_ed25519"), 50 | .copy("formicTests/Fixtures/id_ed25519.pub"), 51 | ] 52 | ), 53 | ], 54 | swiftLanguageModes: [.v6] 55 | ) 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Formic 🐜 2 | 3 | Swift library to support IT Automation tasks. 4 | 5 | ## Overview 6 | 7 | This is a library to support building IT automation tools in Swift, taking a lot of inspiration from existing and past IT automation tools. 8 | It's meant to operate similarly to Ansible, focusing on configuring the software on remote hosts using a channel of "ssh" to those hosts, presumably with a key you already have. 9 | 10 | I expect that most SRE/DevOps staff are not going to be interested in creating something using the Swift language. 11 | Instead, I'm assembling these pieces to support building my own custom playbooks and tools for managing remote hosts and services. 12 | 13 | - [API Documentation](https://swiftpackageindex.com/heckj/formic/main/documentation/formic) 14 | 15 | I've included a hard-coded example of using this library from an argument-parser based CLI tool. 16 | Look through the content in [updateExample](examples/updateExample) to get a sense of using it in practice. 17 | -------------------------------------------------------------------------------- /Sources/Formic/.swift: -------------------------------------------------------------------------------- 1 | //import Foundation 2 | // 3 | //extension Pipe { 4 | // /// Returns the data within the pipe as a UTF-8 encoded string, if available. 5 | // public func string() throws -> String? { 6 | // guard let data = try self.fileHandleForReading.readToEnd() else { 7 | // return nil 8 | // } 9 | // guard let stringValue = String(data: data, encoding: String.Encoding.utf8) else { 10 | // return nil 11 | // } 12 | // return stringValue 13 | // } 14 | //} 15 | -------------------------------------------------------------------------------- /Sources/Formic/Backoff.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The retry and backoff delay settings for a command. 4 | public struct Backoff: Sendable, Hashable, Codable { 5 | 6 | /// The maximum number of retries to attempt on failure. 7 | public let maxRetries: Int 8 | 9 | /// The delay strategy for waiting between retries. 10 | public let strategy: Strategy 11 | 12 | public var retryOnFailure: Bool { 13 | maxRetries > 0 14 | } 15 | 16 | /// The backoff strategy and values for delaying. 17 | public enum Strategy: Sendable, Hashable, Codable { 18 | // precomputed fibonacci backoffs for up to 16 attempts 19 | // max delay of ~5 minutes seemed completely reasonable 20 | static let fibBackoffs: [Int] = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610] 21 | static let exponentialBackoffs: [Int] = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512] 22 | /// No delay, retry immediately. 23 | case none 24 | /// Always delay by the same amount. 25 | case constant(delay: Duration) 26 | /// Increment delay by a constant amount, up to a max interval. 27 | case linear(increment: Duration, maxDelay: Duration) 28 | /// Increment delay by a backoff increasing using a fibonacci sequence, up to a max interval. 29 | case fibonacci(maxDelay: Duration) 30 | /// Increment delay by a backoff increasing using a exponential sequence, up to a max interval. 31 | case exponential(maxDelay: Duration) 32 | 33 | func delay(for attempt: Int, withJitter: Bool) -> Duration { 34 | switch self { 35 | case .none: 36 | return .zero 37 | case .constant(let delay): 38 | return delay 39 | case .linear(let increment, let maxDelay): 40 | if withJitter { 41 | return Self.jitterValue(base: increment * attempt, max: maxDelay) 42 | } else { 43 | return min(increment * attempt, maxDelay) 44 | } 45 | case .fibonacci(let maxDelay): 46 | if attempt >= Self.fibBackoffs.count { 47 | if withJitter { 48 | return Self.jitterValue(base: min(.seconds(610), maxDelay), max: maxDelay) 49 | } else { 50 | return min(.seconds(610), maxDelay) 51 | } 52 | } 53 | if withJitter { 54 | return Self.jitterValue(base: .seconds(Self.fibBackoffs[attempt]), max: maxDelay) 55 | } else { 56 | return min(.seconds(Self.fibBackoffs[attempt]), maxDelay) 57 | } 58 | case .exponential(let maxDelay): 59 | if attempt >= Self.exponentialBackoffs.count { 60 | if withJitter { 61 | return Self.jitterValue(base: min(.seconds(512), maxDelay), max: maxDelay) 62 | } else { 63 | return min(.seconds(512), maxDelay) 64 | } 65 | } 66 | if withJitter { 67 | return Self.jitterValue(base: .seconds(Self.exponentialBackoffs[attempt]), max: maxDelay) 68 | } else { 69 | return min(.seconds(Self.exponentialBackoffs[attempt]), maxDelay) 70 | } 71 | } 72 | } 73 | 74 | static func jitterValue(base: Duration, max: Duration) -> Duration { 75 | // plus or minus 5% of the base duration 76 | let jitter: Duration = base * Double.random(in: -1...1) / 20 77 | let adjustedDuration = base + jitter 78 | if adjustedDuration > max { 79 | return max 80 | } else if adjustedDuration < .zero { 81 | return .zero 82 | } else { 83 | return adjustedDuration 84 | } 85 | } 86 | } 87 | 88 | /// Creates a new backup setting with the values you provide. 89 | /// - Parameters: 90 | /// - maxRetries: The maximum number of retry attempts allowed. Negative integers are treated as 0 retries. 91 | /// - strategy: The delay strategy for waiting between retries. 92 | public init(maxRetries: Int, strategy: Strategy) { 93 | self.maxRetries = max(maxRetries, 0) 94 | self.strategy = strategy 95 | } 96 | 97 | /// Never attempt retry 98 | /// 99 | /// Do not attempt to retry on failure. 100 | public static var never: Backoff { 101 | Backoff(maxRetries: 0, strategy: .none) 102 | } 103 | 104 | /// Default backoff settings 105 | /// 106 | /// Attempt up to 3 retries, with a growing backoff with a maximum of 60 seconds. 107 | public static var `default`: Backoff { 108 | Backoff(maxRetries: 3, strategy: .fibonacci(maxDelay: .seconds(10))) 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /Sources/Formic/Command.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | /// A type that represents a command, run locally or remotely. 5 | public protocol Command: Sendable, Identifiable, Hashable { 6 | /// The identifier of a command. 7 | var id: UUID { get } 8 | /// A Boolean value that indicates whether a failing command within a sequence should fail the overall sequence of commands. 9 | var ignoreFailure: Bool { get } 10 | /// The retry settings to apply when a command fails. 11 | var retry: Backoff { get } 12 | 13 | /// The maximum time allowed for the command to execute. 14 | var executionTimeout: Duration { get } 15 | 16 | /// The function that is invoked by an engine to run the command. 17 | /// - Parameters: 18 | /// - host: The host on which the command is run. 19 | /// - logger: An optional logger to record the command output or errors. 20 | /// - Returns: The combined output from the command execution. 21 | func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Formic/CommandError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An error that occurs when running a command. 4 | public enum CommandError: LocalizedError { 5 | /// Failure due to missing SSH access credentials. 6 | case missingSSHAccessCredentials(msg: String) 7 | /// Failure due to inability to resolve a host. 8 | case failedToResolveHost(name: String) 9 | /// Failure due to no output to parse. 10 | case noOutputToParse(msg: String) 11 | /// Failure due to a command failing. 12 | case commandFailed(rc: Int32, errmsg: String) 13 | /// Failure due to an invalid command. 14 | case invalidCommand(msg: String) 15 | /// Failure due to a command timeout exceeding. 16 | case timeoutExceeded(cmd: (any Command)) 17 | /// Failure due to no output from a command 18 | case noOutputFromCommand(cmd: (any Command)) 19 | 20 | /// Failure due to using a remote command with a local host. 21 | case localUnsupported(msg: String) 22 | 23 | /// The localized description. 24 | public var errorDescription: String? { 25 | switch self { 26 | case .missingSSHAccessCredentials(let msg): 27 | "Missing SSH access credentials: \(msg)" 28 | case .failedToResolveHost(let name): 29 | "Failed to resolve \(name) as a valid internet host." 30 | case .noOutputToParse(let msg): 31 | "No output to parse: \(msg)" 32 | case .commandFailed(let rc, let errmsg): 33 | "Command failed with return code \(rc): \(errmsg)" 34 | case .invalidCommand(let msg): 35 | "Invalid command: \(msg)" 36 | case .timeoutExceeded(let command): 37 | "Timeout exceeded for command: \(command)" 38 | case .noOutputFromCommand(let cmd): 39 | "No output received from command: \(cmd)" 40 | case .localUnsupported(let msg): 41 | "Local host does not support remote commands: \(msg)" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Formic/CommandOutput.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The structured output of a shell command. 4 | /// 5 | /// The API for invoking a process typically exposes a return code as well as potentially separate 6 | /// streams of data for standard output and standard error. CommandOutput captures those separately 7 | /// as raw data in order to pass it across concurrency isolation boundaries, store, or use the content 8 | /// of the output. 9 | /// 10 | /// The standard output and standard error are stored as `Data`, with convenience properties to access 11 | /// them as `String` values, encoded using `UTF-8`. 12 | /// 13 | /// To mimicking commands that aren't shell commands, use the convenience initializers: 14 | /// ``empty``, ``generalSuccess(msg:)``, ``generalFailure(msg:)``, and ``exceptionFailure()``. 15 | public struct CommandOutput: Sendable { 16 | /// The return code. 17 | public let returnCode: Int32 18 | /// The raw data from STDOUT, if any. 19 | public let stdOut: Data? 20 | /// The raw data from STDERR, if any. 21 | public let stdErr: Data? 22 | 23 | /// The data from STDOUT reported as a UTF-8 string. 24 | public var stdoutString: String? { 25 | guard let stdOut else { 26 | return nil 27 | } 28 | return String(data: stdOut, encoding: String.Encoding.utf8) 29 | } 30 | 31 | /// The data from STDERR reported as a UTF-8 string. 32 | public var stderrString: String? { 33 | guard let stdErr else { 34 | return nil 35 | } 36 | return String(data: stdErr, encoding: String.Encoding.utf8) 37 | } 38 | 39 | /// Create a new command output. 40 | /// - Parameters: 41 | /// - returnCode: The return code 42 | /// - stdOut: The raw data for STDOUT, if any. 43 | /// - stdErr: The raw data for STDERR, if any. 44 | init(returnCode: Int32, stdOut: Data?, stdErr: Data?) { 45 | self.returnCode = returnCode 46 | self.stdOut = stdOut 47 | self.stdErr = stdErr 48 | } 49 | 50 | /// A null output with no useful information. 51 | public static var empty: CommandOutput { 52 | CommandOutput(returnCode: 0, stdOut: nil, stdErr: nil) 53 | } 54 | 55 | /// Creates a command out that represents a success. 56 | /// - Parameter msg: A message to include as the standard output. 57 | public static func generalSuccess(msg: String) -> CommandOutput { 58 | CommandOutput(returnCode: 0, stdOut: msg.data(using: .utf8), stdErr: nil) 59 | } 60 | 61 | /// Creates a command out that represents a failure. 62 | /// - Parameter msg: A message to include as the standard error. 63 | public static func generalFailure(msg: String) -> CommandOutput { 64 | CommandOutput(returnCode: -1, stdOut: nil, stdErr: msg.data(using: .utf8)) 65 | } 66 | 67 | /// Creates a command out that represents an exception thrown failure, and has no output. 68 | public static func exceptionFailure() -> CommandOutput { 69 | CommandOutput(returnCode: -1, stdOut: nil, stdErr: nil) 70 | } 71 | 72 | } 73 | 74 | extension CommandOutput: Hashable {} 75 | 76 | // IMPL NOTES: I'm not sure I require the Codable representation, but it doesn't hurt to have it. 77 | extension CommandOutput: Codable {} 78 | -------------------------------------------------------------------------------- /Sources/Formic/Commands/AnyCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | /// A general command that is run against a host. 5 | /// 6 | /// This allows you to provide an throwing closure that will be run as the execution logic for a command. 7 | /// The closure is provided a tuple of the `Host` and an optional `Logger` for recording output. 8 | /// The closure must return a `CommandOutput` object to indicate success or failure. 9 | public struct AnyCommand: Command { 10 | /// A Boolean value that indicates whether a failing command should fail a playbook. 11 | public let ignoreFailure: Bool 12 | /// The retry settings for the command. 13 | public let retry: Backoff 14 | /// The maximum duration to allow for the command. 15 | public let executionTimeout: Duration 16 | /// The ID of the command. 17 | public let id: UUID 18 | let name: String 19 | let commandClosure: @Sendable (RemoteHost, Logger?) async throws -> CommandOutput 20 | 21 | /// Invokes a command on the host to verify access. 22 | /// - Parameters: 23 | /// - name: A name for this command. 24 | /// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook. 25 | /// - retry: The retry settings for the command. 26 | /// - executionTimeout: The maximum duration to allow for the command. 27 | /// - commandClosure: An asynchronous closure that the engine invokes when it runs the command. 28 | public init( 29 | name: String, 30 | ignoreFailure: Bool, 31 | retry: Backoff, 32 | executionTimeout: Duration, 33 | commandClosure: @escaping @Sendable (RemoteHost, Logger?) async throws -> CommandOutput 34 | ) { 35 | self.retry = retry 36 | self.ignoreFailure = ignoreFailure 37 | self.executionTimeout = executionTimeout 38 | self.commandClosure = commandClosure 39 | self.name = name 40 | id = UUID() 41 | } 42 | 43 | /// The function that is invoked by an engine to run the command. 44 | /// - Parameters: 45 | /// - host: The host on which the command is run. 46 | /// - logger: An optional logger to record the command output or errors. 47 | /// - Returns: The combined output from the command execution. 48 | @discardableResult 49 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 50 | try await commandClosure(host, logger) 51 | } 52 | } 53 | 54 | extension AnyCommand: Equatable { 55 | /// Returns a Boolean value that indicates whether the two commands are equal. 56 | /// - Parameters: 57 | /// - lhs: The first command. 58 | /// - rhs: The second command. 59 | /// - Returns: `true` if the settings, name, and ID of the commands are equal; `false` otherwise. 60 | public static func == (lhs: AnyCommand, rhs: AnyCommand) -> Bool { 61 | lhs.id == rhs.id && lhs.ignoreFailure == rhs.ignoreFailure && lhs.retry == rhs.retry 62 | && lhs.executionTimeout == rhs.executionTimeout && lhs.name == rhs.name 63 | } 64 | } 65 | 66 | extension AnyCommand: Hashable { 67 | /// Combines elements of the command to generate a hash value. 68 | /// - Parameter hasher: The hasher to use when combining the components of the command. 69 | public func hash(into hasher: inout Hasher) { 70 | hasher.combine(id) 71 | hasher.combine(ignoreFailure) 72 | hasher.combine(retry) 73 | hasher.combine(executionTimeout) 74 | hasher.combine(name) 75 | } 76 | } 77 | 78 | extension AnyCommand: CustomStringConvertible { 79 | /// A textual representation of the command. 80 | public var description: String { 81 | return name 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Formic/Commands/CopyFrom.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A command to transfer a file from a remote URL into the host. 6 | /// 7 | /// This command requests the contents of the URL, storing it temporarily on the local file system, before sending it to the host. 8 | /// Once the file is received locally, it is sent to the remote host using scp through a local shell. 9 | public struct CopyFrom: Command { 10 | /// The URL from which to copy the file. 11 | public let from: URL 12 | /// The destination path on the remote host. 13 | public let destinationPath: String 14 | /// An optional dictionary of environment variables the system sets when it runs the command. 15 | public let env: [String: String]? 16 | /// A Boolean value that indicates whether a failing command should fail a playbook. 17 | public let ignoreFailure: Bool 18 | /// The retry settings for the command. 19 | public let retry: Backoff 20 | /// The maximum duration to allow for the command. 21 | public let executionTimeout: Duration 22 | /// The ID of the command. 23 | public let id: UUID 24 | 25 | /// Transfers a file from the host where this is run to the destination host. 26 | /// - Parameters: 27 | /// - into: The destination path on the remote host. 28 | /// - from: The URL from which to copy the file. 29 | /// - env: An optional dictionary of environment variables the system sets when the engine runs the the command. 30 | /// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook. 31 | /// - retry: The retry settings for the command. 32 | /// - executionTimeout: The maximum duration to allow for the command. 33 | public init( 34 | into: String, from: URL, env: [String: String]? = nil, ignoreFailure: Bool = false, 35 | retry: Backoff = .never, executionTimeout: Duration = .seconds(30) 36 | ) { 37 | self.from = from 38 | self.env = env 39 | self.destinationPath = into 40 | self.retry = retry 41 | self.ignoreFailure = ignoreFailure 42 | self.executionTimeout = executionTimeout 43 | id = UUID() 44 | } 45 | 46 | /// The function that is invoked by an engine to run the command. 47 | /// - Parameters: 48 | /// - host: The host on which the command is run. 49 | /// - logger: An optional logger to record the command output or errors. 50 | /// - Returns: The combined output from the command execution. 51 | @discardableResult 52 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 53 | @Dependency(\.commandInvoker) var invoker: any CommandInvoker 54 | let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(from.lastPathComponent) 55 | do { 56 | let data = try await invoker.getDataAtURL(url: from, logger: logger) 57 | try data.write(to: tempFile) 58 | } catch { 59 | return CommandOutput( 60 | returnCode: -1, stdOut: nil, stdErr: "Unable to retrieve file: \(error)".data(using: .utf8)) 61 | } 62 | let sshCreds = host.sshAccessCredentials 63 | let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description 64 | return try await invoker.remoteCopy( 65 | host: targetHostName, 66 | user: sshCreds.username, 67 | identityFile: sshCreds.identityFile, 68 | port: host.sshPort, 69 | strictHostKeyChecking: false, 70 | localPath: tempFile.path, 71 | remotePath: destinationPath, 72 | logger: logger) 73 | } 74 | } 75 | 76 | extension CopyFrom: CustomStringConvertible { 77 | /// A textual representation of the command. 78 | public var description: String { 79 | return "scp \(from.absoluteURL) to remote host:\(destinationPath)" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Formic/Commands/CopyInto.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A command to transfer a file into the host. 6 | /// 7 | /// This command uses scp through a shell on the local host to transfer the file. 8 | public struct CopyInto: Command { 9 | /// The local path of the file to copy. 10 | public let from: String 11 | /// The destination path on the remote host. 12 | public let destinationPath: String 13 | /// An optional dictionary of environment variables the system sets when it runs the command. 14 | public let env: [String: String]? 15 | /// A Boolean value that indicates whether a failing command should fail a playbook. 16 | public let ignoreFailure: Bool 17 | /// The retry settings for the command. 18 | public let retry: Backoff 19 | /// The maximum duration to allow for the command. 20 | public let executionTimeout: Duration 21 | /// The ID of the command. 22 | public let id: UUID 23 | 24 | /// Transfers a file from the host where this is run to the destination host. 25 | /// - Parameters: 26 | /// - location: The location where the file is copied into. 27 | /// - from: The location of the file to copy. 28 | /// - env: An optional dictionary of environment variables the system sets when the engine runs the the command. 29 | /// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook. 30 | /// - retry: The retry settings for the command. 31 | /// - executionTimeout: The maximum duration to allow for the command. 32 | public init( 33 | location: String, from: String, env: [String: String]? = nil, ignoreFailure: Bool = false, 34 | retry: Backoff = .never, executionTimeout: Duration = .seconds(30) 35 | ) { 36 | self.from = from 37 | self.env = env 38 | self.destinationPath = location 39 | self.retry = retry 40 | self.ignoreFailure = ignoreFailure 41 | self.executionTimeout = executionTimeout 42 | id = UUID() 43 | } 44 | 45 | /// The function that is invoked by an engine to run the command. 46 | /// - Parameters: 47 | /// - host: The host on which the command is run. 48 | /// - logger: An optional logger to record the command output or errors. 49 | /// - Returns: The combined output from the command execution. 50 | @discardableResult 51 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 52 | @Dependency(\.commandInvoker) var invoker: any CommandInvoker 53 | let sshCreds = host.sshAccessCredentials 54 | let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description 55 | return try await invoker.remoteCopy( 56 | host: targetHostName, 57 | user: sshCreds.username, 58 | identityFile: sshCreds.identityFile, 59 | port: host.sshPort, 60 | strictHostKeyChecking: false, 61 | localPath: from, 62 | remotePath: destinationPath, 63 | logger: logger) 64 | } 65 | } 66 | 67 | extension CopyInto: CustomStringConvertible { 68 | /// A textual representation of the command. 69 | public var description: String { 70 | return "scp \(from) to remote host:\(destinationPath)" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Formic/Commands/SSHCommand.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Citadel) 2 | 3 | import Citadel 4 | import Crypto // for loading a private key to use with Citadel for authentication 5 | import Dependencies 6 | import Foundation 7 | import Logging 8 | import NIOCore // to interact with ByteBuffer - otherwise it's opaquely buried in Citadel's API response 9 | 10 | #if canImport(FoundationNetworking) // Required for Linux 11 | import FoundationNetworking 12 | #endif 13 | 14 | /// A command to run on a remote host. 15 | /// 16 | /// This (experimental) command uses the Citadel SSH library to connect to a remote host and invoke a command on it. 17 | /// Do not use shell control or redirect operators in the command string. 18 | public struct SSHCommand: Command { 19 | /// The command and arguments to run. 20 | public let commandString: String 21 | /// An optional dictionary of environment variables the system sets when it runs the command. 22 | public let env: [String: String]? 23 | /// A Boolean value that indicates whether a failing command should fail a playbook. 24 | public let ignoreFailure: Bool 25 | /// The retry settings for the command. 26 | public let retry: Backoff 27 | /// The maximum duration to allow for the command. 28 | public let executionTimeout: Duration 29 | /// The ID of the command. 30 | public let id: UUID 31 | 32 | /// Creates a new command declaration that the engine runs as a shell command. 33 | /// - Parameters: 34 | /// - argString: the command and arguments to run as a single string separated by spaces. 35 | /// - env: An optional dictionary of environment variables the system sets when it runs the command. 36 | /// - chdir: An optional directory to change to before running the command. 37 | /// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook. 38 | /// - retry: The retry settings for the command. 39 | /// - executionTimeout: The maximum duration to allow for the command. 40 | public init( 41 | _ argString: String, env: [String: String]? = nil, chdir: String? = nil, 42 | ignoreFailure: Bool = false, 43 | retry: Backoff = .never, executionTimeout: Duration = .seconds(120) 44 | ) { 45 | self.commandString = argString 46 | self.env = env 47 | self.retry = retry 48 | self.ignoreFailure = ignoreFailure 49 | self.executionTimeout = executionTimeout 50 | id = UUID() 51 | } 52 | 53 | /// The function that is invoked by an engine to run the command. 54 | /// - Parameters: 55 | /// - host: The host on which the command is run. 56 | /// - logger: An optional logger to record the command output or errors. 57 | /// - Returns: The combined output from the command execution. 58 | @discardableResult 59 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 60 | @Dependency(\.commandInvoker) var invoker: any CommandInvoker 61 | 62 | let sshCreds = host.sshAccessCredentials 63 | let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description 64 | return try await self.remoteCommand( 65 | host: targetHostName, 66 | user: sshCreds.username, 67 | identityFile: sshCreds.identityFile, 68 | port: host.sshPort, 69 | strictHostKeyChecking: host.strictHostKeyChecking, 70 | cmd: commandString, 71 | env: env, 72 | logger: logger 73 | ) 74 | } 75 | 76 | // IMPLEMENTATION NOTE(heckj): 77 | // This is a more direct usage of Citadel SSHClient, not abstracted through a protocol (such as 78 | // CommandInvoker) in order to just "try it out". This means it's not really amenable to use 79 | // and test in api's which use this functionality to make requests and get data. 80 | 81 | // Citadel *also* supports setting up a connecting _once_, and then executing multiple commands, 82 | // which wasn't something you could do with forking commands through Process. I'm not trying to 83 | // take advantage of that capability here. 84 | 85 | // Finally, Citadel is particular about the KIND of key you're using - and this iteration is only 86 | // written to handle Ed25519 keys. To make this "real", we'd want to work in how to support RSA 87 | // and DSA keys for SSH authentication as well. Maybe even password authentication. 88 | 89 | /// Invoke a command using SSH on a remote host. 90 | /// 91 | /// - Parameters: 92 | /// - host: The remote host to connect to and call the shell command. 93 | /// - user: The user on the remote host to connect as 94 | /// - identityFile: The string path to an SSH identity file. 95 | /// - port: The port to use for SSH to the remote host. 96 | /// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking, defaults to `false`. 97 | /// - cmd: A list of strings that make up the command and any arguments. 98 | /// - env: A dictionary of shell environment variables to apply. 99 | /// - debugPrint: A Boolean value that indicates if the invoker prints the raw command before running it. 100 | /// - Returns: the command output. 101 | /// - Throws: any errors from invoking the shell process, or errors attempting to connect. 102 | func remoteCommand( 103 | host: String, 104 | user: String, 105 | identityFile: String? = nil, 106 | port: Int? = nil, 107 | strictHostKeyChecking: Bool = false, 108 | cmd: String, 109 | env: [String: String]? = nil, 110 | logger: Logger? 111 | ) async throws -> CommandOutput { 112 | 113 | guard let identityFile = identityFile else { 114 | throw CommandError.noOutputToParse(msg: "No identity file provided for SSH connection") 115 | } 116 | 117 | let urlForData = URL(fileURLWithPath: identityFile) 118 | let dataFromURL = try Data(contentsOf: urlForData) // 411 bytes 119 | 120 | let client = try await SSHClient.connect( 121 | host: host, 122 | authenticationMethod: .ed25519(username: "docker-user", privateKey: .init(sshEd25519: dataFromURL)), 123 | hostKeyValidator: .acceptAnything(), 124 | // ^ Please use another validator if at all possible, this is insecure 125 | reconnect: .never 126 | ) 127 | 128 | var stdoutData: Data = Data() 129 | var stderrData: Data = Data() 130 | 131 | do { 132 | let streams = try await client.executeCommandStream(cmd, inShell: true) 133 | 134 | for try await event in streams { 135 | switch event { 136 | case .stdout(let stdout): 137 | stdoutData.append(Data(buffer: stdout)) 138 | case .stderr(let stderr): 139 | stderrData.append(Data(buffer: stderr)) 140 | } 141 | } 142 | 143 | // Citadel API appears to provide a return code on failure, but not on success. 144 | 145 | let results: CommandOutput = CommandOutput(returnCode: 0, stdOut: stdoutData, stdErr: stderrData) 146 | return results 147 | } catch let error as SSHClient.CommandFailed { 148 | // Have to catch the exceptions thrown by executeCommandStream to get the return code, 149 | // in the event of a command failure. 150 | let results: CommandOutput = CommandOutput( 151 | returnCode: Int32(error.exitCode), stdOut: stdoutData, stdErr: stderrData) 152 | return results 153 | } catch { 154 | throw error 155 | } 156 | 157 | } 158 | } 159 | 160 | extension SSHCommand: CustomStringConvertible { 161 | /// A textual representation of the command. 162 | public var description: String { 163 | return commandString 164 | } 165 | } 166 | 167 | #endif 168 | -------------------------------------------------------------------------------- /Sources/Formic/Commands/ShellCommand.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A command to run on a local or remote host. 6 | /// 7 | /// This command uses SSH through a local process to invoke commands on a remote host. 8 | /// 9 | /// Do not use shell control or redirect operators in the command string. 10 | /// Execution timeouts are not respected for these commands. 11 | public struct ShellCommand: Command { 12 | /// The command and arguments to run. 13 | public let commandString: String 14 | /// An optional dictionary of environment variables the system sets when it runs the command. 15 | public let env: [String: String]? 16 | /// A Boolean value that indicates whether a failing command should fail a playbook. 17 | public let ignoreFailure: Bool 18 | /// The retry settings for the command. 19 | public let retry: Backoff 20 | /// The maximum duration to allow for the command. 21 | public let executionTimeout: Duration 22 | /// The ID of the command. 23 | public let id: UUID 24 | 25 | /// Creates a new command declaration that the engine runs as a shell command. 26 | /// - Parameters: 27 | /// - argString: the command and arguments to run as a single string separated by spaces. 28 | /// - env: An optional dictionary of environment variables the system sets when it runs the command. 29 | /// - chdir: An optional directory to change to before running the command. 30 | /// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook. 31 | /// - retry: The retry settings for the command. 32 | /// - executionTimeout: The maximum duration to allow for the command. 33 | public init( 34 | _ argString: String, env: [String: String]? = nil, chdir: String? = nil, 35 | ignoreFailure: Bool = false, 36 | retry: Backoff = .never, executionTimeout: Duration = .seconds(120) 37 | ) { 38 | self.commandString = argString 39 | self.env = env 40 | self.retry = retry 41 | self.ignoreFailure = ignoreFailure 42 | self.executionTimeout = executionTimeout 43 | id = UUID() 44 | } 45 | 46 | /// The function that is invoked by an engine to run the command. 47 | /// - Parameters: 48 | /// - host: The host on which the command is run. 49 | /// - logger: An optional logger to record the command output or errors. 50 | /// - Returns: The combined output from the command execution. 51 | @discardableResult 52 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 53 | @Dependency(\.commandInvoker) var invoker: any CommandInvoker 54 | 55 | let sshCreds = host.sshAccessCredentials 56 | let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description 57 | return try await invoker.remoteShell( 58 | host: targetHostName, 59 | user: sshCreds.username, 60 | identityFile: sshCreds.identityFile, 61 | port: host.sshPort, 62 | strictHostKeyChecking: host.strictHostKeyChecking, 63 | cmd: commandString, 64 | env: env, 65 | logger: logger 66 | ) 67 | } 68 | } 69 | 70 | extension ShellCommand: CustomStringConvertible { 71 | /// A textual representation of the command. 72 | public var description: String { 73 | return commandString 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Formic/Commands/VerifyAccess.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A command to verify access to a host. 6 | /// 7 | /// This command attempts to SSH to the remote host and invoke a simple command to verify access. 8 | /// By default, this command will repeated with a backoff strategy if it fails, to provide time 9 | /// for a remote host to reboot or otherwise become accessible. 10 | /// 11 | /// To verify a remote host is immediately access, set the `retry` parameter to `.never` when defining the command. 12 | public struct VerifyAccess: Command { 13 | /// A Boolean value that indicates whether a failing command should fail a playbook. 14 | public let ignoreFailure: Bool 15 | /// The retry settings for the command. 16 | public let retry: Backoff 17 | /// The maximum duration to allow for the command. 18 | public let executionTimeout: Duration 19 | /// The ID of the command. 20 | public let id: UUID 21 | 22 | /// Invokes a command on the host to verify access. 23 | /// - Parameters: 24 | /// - ignoreFailure: A Boolean value that indicates whether a failing command should fail a playbook. 25 | /// - retry: The retry settings for the command. 26 | /// - executionTimeout: The maximum duration to allow for the command. 27 | public init( 28 | ignoreFailure: Bool = false, 29 | retry: Backoff = Backoff( 30 | maxRetries: 10, 31 | strategy: .fibonacci(maxDelay: .seconds(600))), 32 | executionTimeout: Duration = .seconds(30) 33 | ) { 34 | self.retry = retry 35 | self.ignoreFailure = ignoreFailure 36 | self.executionTimeout = executionTimeout 37 | id = UUID() 38 | } 39 | 40 | /// The function that is invoked by an engine to run the command. 41 | /// - Parameters: 42 | /// - host: The host on which the command is run. 43 | /// - logger: An optional logger to record the command output or errors. 44 | /// - Returns: The combined output from the command execution. 45 | @discardableResult 46 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 47 | @Dependency(\.commandInvoker) var invoker: any CommandInvoker 48 | let command = "echo 'hello'" 49 | 50 | let sshCreds = host.sshAccessCredentials 51 | let targetHostName = host.networkAddress.dnsName ?? host.networkAddress.address.description 52 | let answer = try await invoker.remoteShell( 53 | host: targetHostName, 54 | user: sshCreds.username, 55 | identityFile: sshCreds.identityFile, 56 | port: host.sshPort, 57 | strictHostKeyChecking: false, 58 | cmd: command, 59 | env: nil, 60 | logger: logger) 61 | 62 | if let answerString = answer.stdoutString, answerString.contains("hello") { 63 | return CommandOutput(returnCode: 0, stdOut: "hello".data(using: .utf8), stdErr: nil) 64 | } else { 65 | return CommandOutput(returnCode: -1, stdOut: nil, stdErr: "Unable to verify access.".data(using: .utf8)) 66 | } 67 | } 68 | } 69 | 70 | extension VerifyAccess: CustomStringConvertible { 71 | /// A textual representation of the command. 72 | public var description: String { 73 | return "echo 'hello'" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Formic/DependencyProxies/CommandInvoker.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | #if canImport(FoundationNetworking) // Required for Linux 6 | import FoundationNetworking 7 | #endif 8 | 9 | // IMPLEMENTATION NOTES: 10 | // When using Foundation's Process to run shell commands, it's important to note that 11 | // the method calls a synchronous and blocking, making them a bit tricky to align with 12 | // swift concurrency. 13 | // 14 | // It also means that you don't see any output _while_ it's happening. 15 | // 16 | // It's possible we might be able to stream if we switch to one the Async oriented shell 17 | // libraries, that stream/flow data from the Pipes as it appears, but in terms of the 18 | // functional logic of this - it's more relevant to see what the output is when it's complete 19 | // than the see the internals as it flows. It *looks* a lot nicer - gives a feeling of 20 | // progress that's really great - but isn't strictly needed for the core functionality. 21 | // 22 | // There are two other (existing) async shell command execution libraries that I found: 23 | // 24 | // - https://github.com/GeorgeLyon/Shwift 25 | // Shwift has clearly been around the block, but has heavier dependencies (all of SwiftNIO) that 26 | // make it a heavier take. 27 | // 28 | // - https://github.com/Zollerboy1/SwiftCommand 29 | // I like the structure of SwiftCommand, but it has a few swift6 concurrency warnings about fiddling 30 | // with mutable buffers that are slightly concerning to me. There also doesn't appear to 31 | // be a convenient way to capture STDERR separately (it's mixed together). 32 | 33 | // This protocol is internal, for supporting unit testing with dependency injection of commands 34 | // and using their output without actually invoking them on a remote host. 35 | // 36 | // Documentation for the Dependency injection details: 37 | // https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies 38 | 39 | protocol CommandInvoker: Sendable { 40 | func remoteShell( 41 | host: String, 42 | user: String, 43 | identityFile: String?, 44 | port: Int?, 45 | strictHostKeyChecking: Bool, 46 | cmd: String, 47 | env: [String: String]?, 48 | logger: Logger? 49 | ) async throws -> CommandOutput 50 | 51 | func remoteCopy( 52 | host: String, 53 | user: String, 54 | identityFile: String?, 55 | port: Int?, 56 | strictHostKeyChecking: Bool, 57 | localPath: String, 58 | remotePath: String, 59 | logger: Logger? 60 | ) async throws -> CommandOutput 61 | 62 | func getDataAtURL(url: URL, logger: Logger?) async throws -> Data 63 | 64 | func localShell( 65 | cmd: [String], 66 | stdIn: Pipe?, 67 | env: [String: String]?, 68 | logger: Logger? 69 | ) async throws -> CommandOutput 70 | } 71 | 72 | // registers the dependency 73 | 74 | private enum CommandInvokerKey: DependencyKey { 75 | static let liveValue: any CommandInvoker = ProcessCommandInvoker() 76 | } 77 | 78 | // adds a dependencyValue for convenient access 79 | 80 | extension DependencyValues { 81 | var commandInvoker: CommandInvoker { 82 | get { self[CommandInvokerKey.self] } 83 | set { self[CommandInvokerKey.self] = newValue } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Formic/DependencyProxies/LocalSystemAccess.swift: -------------------------------------------------------------------------------- 1 | import AsyncDNSResolver 2 | import Dependencies 3 | import Foundation 4 | 5 | // The "live" version for Dependency Injection (using Dependencies) accessing closures 6 | // that interact with a local system. The stuff **not** included in the CommandInvoker protocol. 7 | // 8 | // Dependency injection docs: 9 | // https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies 10 | 11 | /// Protocol for shimming in dependencies for accessing the local system. 12 | protocol LocalSystemAccess: Sendable { 13 | var username: String? { get } 14 | var homeDirectory: URL { get } 15 | func fileExists(atPath: String) -> Bool 16 | // async DNS resolver 17 | func queryA(name: String) async throws -> [ARecord] 18 | } 19 | 20 | /// The default "live" local system access. 21 | struct LiveLocalSystemAccess: LocalSystemAccess { 22 | let username = ProcessInfo.processInfo.environment["USER"] 23 | let homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser 24 | func fileExists(atPath: String) -> Bool { 25 | FileManager.default.fileExists(atPath: atPath) 26 | } 27 | func queryA(name: String) async throws -> [ARecord] { 28 | let resolver = try AsyncDNSResolver() 29 | return try await resolver.queryA(name: name) 30 | } 31 | } 32 | 33 | // registers the dependency 34 | 35 | private enum LocalSystemAccessKey: DependencyKey { 36 | static let liveValue: any LocalSystemAccess = LiveLocalSystemAccess() 37 | } 38 | 39 | // adds a dependencyValue for convenient access 40 | 41 | extension DependencyValues { 42 | var localSystemAccess: LocalSystemAccess { 43 | get { self[LocalSystemAccessKey.self] } 44 | set { self[LocalSystemAccessKey.self] = newValue } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Backoff.md: -------------------------------------------------------------------------------- 1 | # ``Backoff`` 2 | 3 | ## Topics 4 | 5 | ### Creating retry values 6 | 7 | - ``init(maxRetries:strategy:)`` 8 | - ``init(from:)`` 9 | - ``default`` 10 | - ``Strategy`` 11 | 12 | ### Inspecting retry values 13 | 14 | - ``maxRetries`` 15 | - ``strategy`` 16 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Backoff_Strategy.md: -------------------------------------------------------------------------------- 1 | # ``Backoff/Strategy`` 2 | 3 | ## Topics 4 | 5 | ### Strategy options 6 | 7 | - ``none`` 8 | - ``constant(delay:)`` 9 | - ``linear(increment:maxDelay:)`` 10 | - ``fibonacci(maxDelay:)`` 11 | - ``exponential(maxDelay:)`` 12 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Command.md: -------------------------------------------------------------------------------- 1 | # ``Command`` 2 | 3 | ## Overview 4 | 5 | Conform to the Command protocol to provide the logic for the Formic engine to invoke a command effecting a remote host or service. 6 | 7 | The engine calls ``run(host:logger:)`` to invoke the command, providing a `Host` instance that presents the host to the logic for executing the command. 8 | The engine may provide a [`Logger`](https://swiftpackageindex.com/apple/swift-log/documentation/logging/logger) instance, to allow the command to log messages, typically to the console when invoked from a command-line interface. 9 | Log output at `debug` or `trace` log levels within your command. 10 | Throw errors to indicate unavoidable error conditions, and report failure conditions by including the relevant information into ``CommandOutput`` that you return from this method. 11 | 12 | 13 | ## Topics 14 | 15 | ### Inspecting Commands 16 | 17 | - ``Command/id`` 18 | - ``Command/ignoreFailure`` 19 | - ``Command/retry`` 20 | - ``Command/executionTimeout`` 21 | - ``Backoff`` 22 | 23 | ### Invoking Commands 24 | 25 | - ``Command/run(host:logger:)`` 26 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/CommandError.md: -------------------------------------------------------------------------------- 1 | # ``CommandError`` 2 | 3 | ## Topics 4 | 5 | ### Inspecting the setting 6 | 7 | - ``commandFailed(rc:errmsg:)`` 8 | - ``missingSSHAccessCredentials(msg:)`` 9 | - ``failedToResolveHost(name:)`` 10 | - ``noOutputToParse(msg:)`` 11 | - ``invalidCommand(msg:)`` 12 | 13 | - ``timeoutExceeded(cmd:)`` 14 | - ``noOutputFromCommand(cmd:)`` 15 | 16 | ### Getting the localized error message 17 | 18 | - ``errorDescription`` 19 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/CommandOutput.md: -------------------------------------------------------------------------------- 1 | # ``CommandOutput`` 2 | 3 | ## Topics 4 | 5 | ### Creating output 6 | 7 | - ``empty`` 8 | - ``generalSuccess(msg:)`` 9 | - ``generalFailure(msg:)`` 10 | 11 | ### Inspecting Command Output 12 | 13 | - ``returnCode`` 14 | - ``stdOut`` 15 | - ``stdoutString`` 16 | - ``stdErr`` 17 | - ``stderrString`` 18 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``Formic`` 2 | 3 | 🐜 Swift library to support IT Automation tasks. 🐜 🐜 4 | 5 | ## Overview 6 | 7 | This is a library to support building IT automation tools in Swift, taking a lot of inspiration from existing and past IT automation tools. 8 | This library is intended to support building CLI executables with swift-argument-parser, or other dedicated tooling for managing remote system. 9 | If you've just stumbled into this project, it isn't intended to be a full-bore replacement for any existing tools commonly used for DevOps/SRE/IT Automation. 10 | Quite a bit is inspired by [Ansible](https://github.com/ansible/ansible), with a goal of building pre-set sequences of tasks that do useful operational work. 11 | For more information on the project, see . 12 | 13 | To use formic, create an instance of `Engine`, handing it a configured logger if you want to see informational or detailed about while commands are executed, and use ``Engine/run(host:displayProgress:verbosity:commands:)`` or ``Engine/run(hosts:displayProgress:verbosity:commands:)`` to run commands on those hosts. 14 | Formic works with the idea of running a set of commands against a single host, or all of those same commands against a set of hosts. 15 | A ``Host`` in formic holds the collection of network address as well as credentials needed to access the host. 16 | 17 | ```swift 18 | var logger = Logger(label: "updateExample") 19 | logger.logLevel = .info 20 | // use `.trace` for detailed output of raw commands invoked as well 21 | // as the standard output and standard error returned from commands. 22 | 23 | let engine = Engine(logger: logger) 24 | 25 | guard let hostAddress = Host.NetworkAddress(hostname) else { 26 | fatalError("Unable to parse the provided host address: \(hostname)") 27 | } 28 | let targetHost: Host = try Host(hostAddress, 29 | sshPort: port, 30 | sshUser: user, 31 | sshIdentityFile: privateKeyLocation) 32 | 33 | // environment variables to use while invoking commands on the remote host 34 | let debUnattended = ["DEBIAN_FRONTEND": "noninteractive", 35 | "DEBCONF_NONINTERACTIVE_SEEN": "true"] 36 | 37 | try await engine.run( 38 | host: targetHost, displayProgress: true, verbosity: verbosity, 39 | commands: [ 40 | // Apply all current upgrades available 41 | ShellCommand("sudo apt-get update -q", env: debUnattended), 42 | ShellCommand("sudo apt-get upgrade -y -qq", env: debUnattended), 43 | ]) 44 | ``` 45 | 46 | For a more fleshed out example, review the example source for the [updateExample CLI executable](https://github.com/heckj/formic/blob/main/examples/updateExample/Sources/updateExample.swift). 47 | 48 | ## Topics 49 | 50 | ### Running Playbooks 51 | 52 | - ``Engine`` 53 | - ``CommandExecutionResult`` 54 | - ``Verbosity`` 55 | 56 | ### Commands 57 | 58 | - ``Host`` 59 | - ``Command`` 60 | - ``CommandOutput`` 61 | - ``CommandError`` 62 | 63 | ### Built-in Commands 64 | 65 | - ``ShellCommand`` 66 | - ``SSHCommand`` 67 | - ``CopyFrom`` 68 | - ``CopyInto`` 69 | - ``AnyCommand`` 70 | - ``VerifyAccess`` 71 | 72 | ### Resources 73 | 74 | - ``OperatingSystem`` 75 | - ``Dpkg`` 76 | 77 | - ``Resource`` 78 | - ``ParsedResource`` 79 | - ``StatefulResource`` 80 | - ``ResourceError`` 81 | 82 | ### Resource Parsers 83 | 84 | - ``SwarmJoinCommand`` 85 | 86 | ### Singular Resources 87 | 88 | - ``SingularResource`` 89 | 90 | ### Collections of Resources 91 | 92 | - ``CollectionResource`` 93 | 94 | ### About Formic 95 | 96 | - 97 | 98 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Engine/CommandExecutionResult.md: -------------------------------------------------------------------------------- 1 | # ``CommandExecutionResult`` 2 | 3 | ## Topics 4 | 5 | ### Inspecting Command Execution Output 6 | 7 | - ``command`` 8 | - ``host`` 9 | - ``output`` 10 | - ``duration`` 11 | - ``retries`` 12 | - ``exception`` 13 | 14 | ### Generating console output 15 | 16 | - ``consoleOutput(verbosity:)`` 17 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Engine/Engine.md: -------------------------------------------------------------------------------- 1 | # ``Engine`` 2 | 3 | ## Topics 4 | 5 | ### Creating an Engine 6 | 7 | - ``init(logger:)`` 8 | 9 | ### Running Playbooks 10 | 11 | - ``run(host:displayProgress:verbosity:commands:)`` 12 | - ``run(hosts:displayProgress:verbosity:commands:)`` 13 | 14 | ### Running an individual command 15 | 16 | - ``run(host:command:)`` 17 | 18 | ### Receiving Updates from the Engine 19 | 20 | - ``commandUpdates`` 21 | 22 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Engine/Verbosity.md: -------------------------------------------------------------------------------- 1 | # ``Verbosity`` 2 | 3 | ## Topics 4 | 5 | ### Levels 6 | 7 | - ``silent(emoji:)`` 8 | - ``normal(emoji:)`` 9 | - ``verbose(emoji:)`` 10 | - ``debug(emoji:)`` 11 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/FormicGoals.md: -------------------------------------------------------------------------------- 1 | # Package Goals and Architecture 2 | 3 | An overview of how and why this package exists, and what it's intended to do. 4 | 5 | ## Overview 6 | 7 | There's a huge amount of inspiration from my own use of IT automation tools, from Ansible, Puppet, Chef, Terraform, and the lessons learned managing systems over the years. 8 | 9 | The primary goal of this project is to provide support for building command-line executable tools that have the same feeling as assembling playlists in Ansible. 10 | The starting point are imperative actions that are easily assembled, all invoked remotely, primarily using SSH as a transport. 11 | Extending from that, API for a declarative that uses the basic "operator" pattern for inspecting current state, computing the delta, and invoking actions to change it to the desired state. 12 | 13 | - Swift 6 language mode (full concurrency safety) 14 | - macOS and Linux support 15 | 16 | ### Engine Architecture 17 | 18 | ``Engine`` is a Swift actor - a reference type that maintains some internal state. Its intentionally _not_ a public actor, for the purpose of allowing more than one to exist on a system. 19 | The idea is that an instance `Engine` is embedded into your CLI app, and it gives you the primary interface to run commands. 20 | 21 | Output from commands are exposed via a logger, with the return values being structured data from the command invocations reporting success, failure, or exceptions. 22 | The general idea being that in your code, you'll assemble a list of commands, and hand that to the engine to do the work. 23 | If any command throws an exception, the playbook (list of commands) you've submitted will terminate. 24 | Commands conform to the ``Command`` protocol, and have a number of options to control how they react - including ignoring a failure or retrying on failure with a ``Backoff`` and ``Backoff/Strategy-swift.enum``. 25 | 26 | The `Command` protocol is set up to allow anyone to create their own commands, and use them with this general framework. 27 | 28 | Beyond the imperative command setup, I'd like to reach for declarative resources and systems that can manage themselves as much as possible. 29 | Those are defined through the ``Resource`` protocol, and variations that include stateful, singular, and collections of resources. 30 | 31 | ### High Level Architecture - Declarative Software Infrastructure 32 | 33 | I want to do this with a declarative structure that has an idea of state, using a single-pass following the operator pattern: 34 | 35 | - start with a declared state 36 | - inspect current state 37 | - compute the delta 38 | - invoke actions to resolve the delta 39 | 40 | To use a declarative structure, there needs to be an API structure that holds the state - what I'm currently calling a Resource. 41 | A Resource doesn't need to model everything on a remote system, only the pieces that are relevant to the tasks at hand. 42 | 43 | In addition to inspecting state, I want to extend Resource to include some actions to allow deeper inspection, with multiple levels of verification. 44 | I'm calling these "diagnostic levels" (borrowing from SciFi themes). 45 | 46 | 1. verifying they're there at all. 47 | 2. inspecting their outputs or ancillary systems to verify they seem to be working. 48 | 3. interaction with the service in a sort of "smoke test" - verifying simple functionality. 49 | 4. ... way more fuzzy here - but a realm of possible extensions that work to characterize the service - performance, SLA measurements, or end to end tests and validation akin to "acceptance tests". 50 | 51 | I'd like the idea of a resource to be flexible enough to represent a singular process or service, or even just a configuration file, through to a set of services working in coordination across a number of hosts. 52 | 53 | ### What this isn't 54 | 55 | This isn't meant to be a general parser for text structures or support an extension external DSL (what Ansible, Puppet, Chef, or Terraform have done). It is meant for swift developers to be able to assemble and build their own devops tools, particularly dedicated CLI tools leveraging [swift-argument-parser](https://swiftpackageindex.com/apple/swift-argument-parser/documentation/argumentparser). 56 | 57 | ### Future Directions 58 | 59 | The public API is focused on building "run a CLI tool once", performing a single pass to run any imperative efforts, or resolve declarative state (when that's added) - very akin to what Ansible does. 60 | 61 | Down the road, I'd like to consider moving more towards the ["Operator" pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/), and extending API to support that use case. 62 | That entails enabling the API to be used to build custom operators (borrowing from CoreOS and Kubernetes) that can remain active, watches the system, and resolves drift from the desired state. 63 | This isn't the immediate goal, but I want to be aware of that as a possible future goal, not cutting off API or structures that could be used in that context. 64 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Host.md: -------------------------------------------------------------------------------- 1 | # ``Host`` 2 | 3 | ## Topics 4 | 5 | ### Creating a Host 6 | 7 | - ``init(_:sshPort:sshUser:sshIdentityFile:strictHostKeyChecking:)-1p3rq`` 8 | - ``init(_:sshPort:sshUser:sshIdentityFile:strictHostKeyChecking:)-2kgoh`` 9 | - ``resolve(_:sshPort:sshUser:sshIdentityFile:strictHostKeyChecking:)`` 10 | - ``init(argument:)`` 11 | - ``localhost`` 12 | 13 | ### Inspecting a Host's Network Address 14 | 15 | - ``networkAddress`` 16 | 17 | - ``NetworkAddress`` 18 | - ``IPv4Address`` 19 | 20 | ### Inspecting a Host's SSH settings 21 | 22 | - ``sshAccessCredentials`` 23 | - ``sshPort`` 24 | - ``strictHostKeyChecking`` 25 | 26 | - ``SSHAccessCredentials`` 27 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Host_IPv4Address.md: -------------------------------------------------------------------------------- 1 | # ``Host/IPv4Address`` 2 | 3 | ## Topics 4 | 5 | ### Creating an IPv4 Address 6 | 7 | - ``init(_:)-1vyzc`` 8 | - ``init(_:)-7bgx3`` 9 | - ``localhost`` 10 | 11 | ### Inspecting Commands 12 | 13 | - ``description`` 14 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/Host_NetworkAddress.md: -------------------------------------------------------------------------------- 1 | # ``Host/NetworkAddress`` 2 | 3 | ## Topics 4 | 5 | ### Creating a Network Address 6 | 7 | - ``init(_:)-2svz2`` 8 | - ``init(_:)-39l39`` 9 | - ``init(_:)-35vdc`` 10 | - ``localhost`` 11 | - ``resolve(_:)`` 12 | 13 | ### Inspecting Network Addresses 14 | 15 | - ``address`` 16 | - ``dnsName`` 17 | -------------------------------------------------------------------------------- /Sources/Formic/Documentation.docc/SSHAccessCredentials.md: -------------------------------------------------------------------------------- 1 | # ``Host/SSHAccessCredentials`` 2 | 3 | ## Topics 4 | 5 | ### Creating a Command 6 | 7 | - ``init(username:identityFile:)-1akk2`` 8 | - ``init(username:identityFile:)-4s1pc`` 9 | 10 | ### Inspecting Commands 11 | 12 | - ``username`` 13 | - ``identityFile`` 14 | 15 | -------------------------------------------------------------------------------- /Sources/Formic/Engine/.swift: -------------------------------------------------------------------------------- 1 | ///// The state of execution for a playbook. 2 | //public enum PlaybookState: Sendable, Hashable, Codable { 3 | // /// The playbook is scheduled to run, but hasn't yet started. 4 | // /// 5 | // /// This is the initial state. 6 | // /// 7 | // /// A playbook will generally transition to ``running`` once any command from it has been accepted back by the engine. 8 | // /// A playbook may transition directly to ``failed`` if an exception is thrown while 9 | // /// running a command. 10 | // /// It may also transition to ``cancelled`` if it is cancelled using ``Engine/cancel(_:)`` before any commands were run. 11 | // case scheduled 12 | // /// The playbook is in progress. 13 | // /// 14 | // /// The playbook stays in this state until all commands have been run. 15 | // /// When a command is run that returns a failed, and the command wasn't set to ignore the failure or when an exception, the playbook transitions to ``failed``. 16 | // /// If all commands are run without any failures to report, the playbook transitions to ``complete``. 17 | // case running 18 | // /// The playbook is finished without any failed commands. 19 | // /// 20 | // /// This is a terminal state. 21 | // case complete 22 | // /// The playbook was terminated due to a failed command 23 | // /// or an exception being thrown while attempting to run a command. 24 | // /// 25 | // /// This is a terminal state. 26 | // case failed 27 | // /// The playbook was terminated before completion due to cancellation. 28 | // /// 29 | // /// This is a terminal state. 30 | // case cancelled 31 | //} 32 | -------------------------------------------------------------------------------- /Sources/Formic/Engine/CommandExecutionResult.swift: -------------------------------------------------------------------------------- 1 | /// The result of executing a command. 2 | public struct CommandExecutionResult: Sendable { 3 | /// The command. 4 | public let command: any Command 5 | /// The host for the command. 6 | public let host: RemoteHost 7 | /// The output from the command. 8 | public let output: CommandOutput 9 | /// The duration of execution of the command. 10 | public let duration: Duration 11 | /// The number of retries needed for the command. 12 | public let retries: Int 13 | /// The description of the exception thrown while invoking the command, if any. 14 | public let exception: (any Error)? 15 | 16 | /// Creates an annotated command execution result. 17 | /// - Parameters: 18 | /// - command: The command. 19 | /// - host: The host for the command. 20 | /// - output: The output from the command 21 | /// - duration: The duration of execution of the command. 22 | /// - retries: The number of retries needed for the command. 23 | /// - exception: The description of the exception thrown while invoking the command, if any. 24 | public init( 25 | command: any Command, host: RemoteHost, output: CommandOutput, duration: Duration, 26 | retries: Int, exception: (any Error)? 27 | ) { 28 | self.command = command 29 | self.host = host 30 | self.output = output 31 | self.duration = duration 32 | self.retries = retries 33 | self.exception = exception 34 | } 35 | 36 | /// Returns a Boolean value that indicates if the execution result represents a failure. 37 | public func representsFailure() -> Bool { 38 | output.returnCode != 0 && !command.ignoreFailure 39 | } 40 | } 41 | 42 | extension CommandExecutionResult { 43 | /// Returns a possibly multi-line string representation of the command execution result. 44 | /// - Parameter verbosity: The verbosity level of the output. 45 | public func consoleOutput(detailLevel: CommandOutputDetail) -> String { 46 | let style = Duration.TimeFormatStyle(pattern: .hourMinuteSecond(padHourToLength: 2)) 47 | let formattedDuration = duration.formatted(style) // "00:00:02". 48 | 49 | var stringOutput = "" 50 | switch detailLevel { 51 | case .silent(emoji: let includeEmoji): 52 | if includeEmoji { 53 | stringOutput.append(emojiString()) 54 | } 55 | // Reports only failure. 56 | if let exception = exception { 57 | if includeEmoji { 58 | stringOutput.append(" ") 59 | } 60 | stringOutput.append("exception: \(exception)") 61 | } else if representsFailure() { 62 | if includeEmoji { 63 | stringOutput.append(" ") 64 | } 65 | stringOutput.append("return code: \(output.returnCode)") 66 | if let errorOutput = output.stderrString { 67 | stringOutput.append("\nSTDERR: \(errorOutput)") 68 | } else { 69 | stringOutput.append("\nNo STDERR output.") 70 | } 71 | } 72 | case .normal(emoji: let includeEmoji): 73 | // Reports host and command with an indication of command success or failure. 74 | if includeEmoji { 75 | stringOutput.append("\(emojiString()) ") 76 | } 77 | if let exception = exception { 78 | if includeEmoji { 79 | stringOutput.append(" ") 80 | } 81 | stringOutput.append("[\(formattedDuration)] ") 82 | stringOutput.append("exception: \(exception)") 83 | } else if output.returnCode != 0 { 84 | stringOutput.append("command: \(command), rc=\(output.returnCode), retries=\(retries)") 85 | stringOutput.append(" δt=[\(formattedDuration)] ") 86 | if let errorOutput = output.stderrString { 87 | stringOutput.append("\nSTDERR: \(errorOutput)") 88 | } else { 89 | stringOutput.append(" No STDERR output.") 90 | } 91 | } else { 92 | stringOutput.append("command: \(command), rc=\(output.returnCode), retries=\(retries)") 93 | stringOutput.append(" δt=[\(formattedDuration)] ") 94 | } 95 | case .verbose(emoji: let includeEmoji): 96 | // Reports host, command, duration, the result code, and stdout on success, or stderr on failure. 97 | if includeEmoji { 98 | stringOutput.append("\(emojiString()) ") 99 | } 100 | if let exception = exception { 101 | if includeEmoji { 102 | stringOutput.append(" ") 103 | } 104 | stringOutput.append(" δt=[\(formattedDuration)] ") 105 | stringOutput.append("exception: \(exception)") 106 | } else if output.returnCode != 0 { 107 | stringOutput.append(" δt=[\(formattedDuration)] ") 108 | stringOutput.append("command: \(command), rc=\(output.returnCode), retries=\(retries)") 109 | if let errorOutput = output.stderrString { 110 | stringOutput.append("\nSTDERR: \(errorOutput)") 111 | } else { 112 | stringOutput.append(" No STDERR output.") 113 | } 114 | } else { 115 | stringOutput.append(" δt=[\(formattedDuration)] ") 116 | stringOutput.append("command: \(command), rc=\(output.returnCode), retries=\(retries)") 117 | if let stdoutOutput = output.stdoutString { 118 | stringOutput.append("\nSTDOUT: \(stdoutOutput)") 119 | } else { 120 | stringOutput.append(" No STDOUT output.") 121 | } 122 | } 123 | case .debug(emoji: let includeEmoji): 124 | // Reports host, command, duration, the result code, stdout, and stderr returned from the command. 125 | if includeEmoji { 126 | stringOutput.append("\(emojiString()) ") 127 | } 128 | if let exception = exception { 129 | if includeEmoji { 130 | stringOutput.append(" ") 131 | } 132 | stringOutput.append(" δt=[\(formattedDuration)] ") 133 | stringOutput.append("exception: \(exception)") 134 | } else { 135 | stringOutput.append(" δt=[\(formattedDuration)] ") 136 | stringOutput.append("command: \(command), rc=\(output.returnCode), retries=\(retries)") 137 | if let errorOutput = output.stderrString { 138 | stringOutput.append("\nSTDERR: \(errorOutput)") 139 | } else { 140 | stringOutput.append(" No STDERR output.") 141 | } 142 | if let stdoutOutput = output.stdoutString { 143 | stringOutput.append("\nSTDOUT: \(stdoutOutput)") 144 | } else { 145 | stringOutput.append(" No STDOUT output.") 146 | } 147 | } 148 | } 149 | return stringOutput 150 | } 151 | 152 | func emojiString() -> String { 153 | if exception != nil { 154 | return "🚫" 155 | } else if output.returnCode != 0 { 156 | return command.ignoreFailure ? "⚠️" : "❌" 157 | } else { 158 | return "✅" 159 | } 160 | } 161 | } 162 | 163 | extension CommandExecutionResult: Equatable { 164 | /// Returns `true` if the two execution results are equal. 165 | /// - Parameters: 166 | /// - lhs: The first execution result 167 | /// - rhs: The second execution result 168 | public static func == (lhs: CommandExecutionResult, rhs: CommandExecutionResult) -> Bool { 169 | lhs.command.id == rhs.command.id && lhs.host == rhs.host 170 | && lhs.output == rhs.output && lhs.duration == rhs.duration && lhs.retries == rhs.retries 171 | && lhs.exception?.localizedDescription == rhs.exception?.localizedDescription 172 | } 173 | } 174 | 175 | extension CommandExecutionResult: Hashable { 176 | /// Hashes the essential components of the execution result. 177 | /// - Parameter hasher: The hasher to use when combining the components 178 | public func hash(into hasher: inout Hasher) { 179 | let hashOfCommand = command.hashValue 180 | hasher.combine(hashOfCommand) 181 | hasher.combine(host) 182 | hasher.combine(output) 183 | hasher.combine(duration) 184 | hasher.combine(retries) 185 | hasher.combine(exception?.localizedDescription) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Sources/Formic/Engine/CommandOutputDetail.swift: -------------------------------------------------------------------------------- 1 | /// The level and contents of output exposed for a command execution result. 2 | public enum CommandOutputDetail: Sendable, Hashable { 3 | /// Reports only failures 4 | case silent(emoji: Bool = false) 5 | /// Reports host and command with an indication of command success or failure. 6 | case normal(emoji: Bool = true) 7 | /// Reports host, command, duration, the result code, and stdout on success, or stderr on failure. 8 | case verbose(emoji: Bool = true) 9 | /// Reports host, command, duration, the result code, stdout, and stderr returned from the command. 10 | case debug(emoji: Bool = true) 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Formic/Engine/Engine.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | /// An engine that runs playbooks and exposes the results. 5 | public actor Engine { 6 | let clock: ContinuousClock 7 | let logger: Logger? 8 | 9 | /// An asynchronous stream of command execution results. 10 | public nonisolated let commandUpdates: AsyncStream<(CommandExecutionResult)> 11 | let commandContinuation: AsyncStream<(CommandExecutionResult)>.Continuation 12 | 13 | /// Creates a new engine. 14 | public init(logger: Logger? = nil) { 15 | clock = ContinuousClock() 16 | self.logger = logger 17 | 18 | // assemble the streams and continuations 19 | (commandUpdates, commandContinuation) = AsyncStream.makeStream(of: CommandExecutionResult.self) 20 | } 21 | 22 | /// Directly runs a series of commands against a single host. 23 | /// - Parameters: 24 | /// - host: The host on which to run the command. 25 | /// - displayProgress: A Boolean value that indicates whether to display progress while the commands are executed. 26 | /// - verbosity: The level of verbosity for reporting progress. 27 | /// - commands: The commands to run. 28 | /// - Returns: A list of the results of the command executions. 29 | @discardableResult 30 | public func run( 31 | host: RemoteHost, 32 | displayProgress: Bool, 33 | detailLevel: CommandOutputDetail = .silent(emoji: true), 34 | commands: [(any Command)] 35 | ) async throws -> [CommandExecutionResult] { 36 | var results: [CommandExecutionResult] = [] 37 | for command in commands { 38 | logger?.debug("\(host) running command: \(command)") 39 | 40 | let result = try await run(host: host, command: command) 41 | results.append(result) 42 | if displayProgress { 43 | logger?.info("\(result.consoleOutput(detailLevel: detailLevel))") 44 | } 45 | if result.representsFailure() { 46 | logger?.debug("result: \(result) represents failure - breaking") 47 | break 48 | } 49 | } 50 | logger?.trace("returning \(results.count) CEResults") 51 | return results 52 | } 53 | 54 | /// Runs a series of commands on all of the hosts you provide. 55 | /// - Parameters: 56 | /// - hosts: The hosts on which to run the commands. 57 | /// - displayProgress: A Boolean value that indicates whether to display progress while the playbook is executed. 58 | /// - verbosity: The verbosity level to use if you display progress. 59 | /// - commands: The commands to run. 60 | /// - Returns: A dictionary of the command results by host. 61 | /// - Throws: Any exceptions that occur while running the commands. 62 | @discardableResult 63 | public func run( 64 | hosts: [RemoteHost], 65 | displayProgress: Bool, 66 | detailLevel: CommandOutputDetail = .silent(emoji: true), 67 | commands: [(any Command)] 68 | ) async throws 69 | -> [RemoteHost: [CommandExecutionResult]] 70 | { 71 | var hostResults: [RemoteHost: [CommandExecutionResult]] = [:] 72 | 73 | for host in hosts { 74 | async let resultsOfSingleHost = self.run( 75 | host: host, displayProgress: displayProgress, detailLevel: detailLevel, commands: commands) 76 | hostResults[host] = try await resultsOfSingleHost 77 | } 78 | 79 | return hostResults 80 | } 81 | 82 | /// Directly runs a single command against a single host, applying the retry and timeout policies of the command. 83 | /// - Parameters: 84 | /// - host: The host on which to run the command. 85 | /// - command: The command to run. 86 | /// - Returns: The result of the command execution. 87 | /// - Throws: exceptions from Task.sleep delay while retrying. 88 | public nonisolated func run(host: RemoteHost, command: (any Command)) async throws 89 | -> CommandExecutionResult 90 | { 91 | // `nonisolated` + `async` means run on a cooperative thread pool and return the result 92 | // remove the `nonisolated` keyword to run in the actor's context. 93 | var numberOfRetries: Int = -1 94 | var durationOfLastAttempt: Duration = .zero 95 | var outputOfLastAttempt: CommandOutput = .empty 96 | var capturedException: (any Error)? = nil 97 | 98 | repeat { 99 | capturedException = nil 100 | numberOfRetries += 1 101 | let start = clock.now 102 | do { 103 | outputOfLastAttempt = try await command.run(host: host, logger: logger) 104 | // - DISABLED execution timeout checking because it's hanging when 105 | // the process that invokes the commands is Foundation.process, which is all 106 | // synchronous/blocking code, and fairly incompatible with async/await. 107 | // 108 | // outputOfLastAttempt = try await withThrowingTaskGroup( 109 | // of: CommandOutput.self, returning: CommandOutput.self 110 | // ) { 111 | // group in 112 | // group.addTask { 113 | // return try await command.run(host: host) 114 | // } 115 | // group.addTask { 116 | // try await Task.sleep(for: command.executionTimeout) 117 | // try Task.checkCancellation() 118 | // throw CommandError.timeoutExceeded(cmd: command) 119 | // } 120 | // guard let output = try await group.next() else { 121 | // throw CommandError.noOutputFromCommand(cmd: command) 122 | // } 123 | // group.cancelAll() 124 | // return output 125 | // } 126 | } catch { 127 | // catch inner exception conditions and treat as a failure to allow for 128 | // retries and timeouts to be handled. 129 | capturedException = error 130 | // mark as a failure due to the exception capture - if not marked 131 | // as a failure explicitly, .empty is assumed to be a success. 132 | outputOfLastAttempt = .exceptionFailure() 133 | } 134 | durationOfLastAttempt = clock.now - start 135 | 136 | // if successful, return the output immediately 137 | if outputOfLastAttempt.returnCode == 0 { 138 | return CommandExecutionResult( 139 | command: command, host: host, output: outputOfLastAttempt, 140 | duration: durationOfLastAttempt, retries: numberOfRetries, 141 | exception: nil) 142 | } 143 | 144 | // otherwise, prep for possible retry 145 | if command.retry.retryOnFailure && numberOfRetries < command.retry.maxRetries { 146 | let delay = command.retry.strategy.delay(for: numberOfRetries, withJitter: true) 147 | logger?.trace("\(host) delaying for \(delay) (due to failure) before retrying: \(command)") 148 | try await Task.sleep(for: delay) 149 | } 150 | } while command.retry.retryOnFailure && numberOfRetries < command.retry.maxRetries 151 | 152 | return CommandExecutionResult( 153 | command: command, host: host, output: outputOfLastAttempt, 154 | duration: durationOfLastAttempt, retries: numberOfRetries, 155 | exception: capturedException) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/Formic/IPv4Address.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import RegexBuilder 3 | 4 | extension RemoteHost { 5 | /// An IPv4 address. 6 | public struct IPv4Address: LosslessStringConvertible, Sendable { 7 | // It would be great if this were in a standard library built into the swift toolchain (aka Foundation) 8 | // but alas, it's not. There are multiple versions of this kind of type from different libraries: 9 | // WebURL has one at https://karwa.github.io/swift-url/main/documentation/weburl/ipv4address/ 10 | // NIOCore has one at https://github.com/apple/swift-nio/blob/main/Sources/NIOCore/SocketAddresses.swift 11 | // and Apple's Network library as one. 12 | 13 | public var description: String { 14 | "\(octets.0).\(octets.1).\(octets.2).\(octets.3)" 15 | } 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case a = "a" 19 | case b = "b" 20 | case c = "c" 21 | case d = "d" 22 | } 23 | 24 | let octets: (UInt8, UInt8, UInt8, UInt8) 25 | 26 | public init?(_ stringRep: String) { 27 | let octetRef = Reference(UInt8.self) 28 | let myBuilderRegex = Regex { 29 | TryCapture(as: octetRef) { 30 | OneOrMore(.digit) 31 | } transform: { str -> UInt8? in 32 | guard let intValue = UInt8(str) else { 33 | return nil 34 | } 35 | return intValue 36 | } 37 | "." 38 | TryCapture(as: octetRef) { 39 | OneOrMore(.digit) 40 | } transform: { str -> UInt8? in 41 | guard let intValue = UInt8(str) else { 42 | return nil 43 | } 44 | return intValue 45 | } 46 | "." 47 | TryCapture(as: octetRef) { 48 | OneOrMore(.digit) 49 | } transform: { str -> UInt8? in 50 | guard let intValue = UInt8(str) else { 51 | return nil 52 | } 53 | return intValue 54 | } 55 | "." 56 | TryCapture(as: octetRef) { 57 | OneOrMore(.digit) 58 | } transform: { str -> UInt8? in 59 | guard let intValue = UInt8(str) else { 60 | return nil 61 | } 62 | return intValue 63 | } 64 | } 65 | guard let matches = try? myBuilderRegex.wholeMatch(in: stringRep) else { 66 | return nil 67 | } 68 | octets = (matches.1, matches.2, matches.3, matches.4) 69 | } 70 | 71 | public init(_ octets: (UInt8, UInt8, UInt8, UInt8)) { 72 | self.octets = octets 73 | } 74 | 75 | public static let localhost = IPv4Address((127, 0, 0, 1)) 76 | } 77 | } 78 | 79 | extension RemoteHost.IPv4Address: ExpressibleByArgument {} 80 | 81 | extension RemoteHost.IPv4Address: Hashable { 82 | /// Returns a Boolean value that indicates whether two IPv4 addresses are equal. 83 | /// - Parameters: 84 | /// - lhs: the first address to compare. 85 | /// - rhs: the second address to compare. 86 | public static func == (lhs: Self, rhs: Self) -> Bool { 87 | lhs.octets == rhs.octets 88 | } 89 | 90 | /// Calculates the hash value for the IPv4 address. 91 | /// - Parameter hasher: The hasher to combine the values with. 92 | public func hash(into hasher: inout Hasher) { 93 | hasher.combine(octets.0) 94 | hasher.combine(octets.1) 95 | hasher.combine(octets.2) 96 | hasher.combine(octets.3) 97 | } 98 | } 99 | 100 | extension RemoteHost.IPv4Address: Codable { 101 | /// Creates an IPv4 address from a decoder. 102 | /// - Parameter decoder: The decoder to read data from. 103 | public init(from decoder: Decoder) throws { 104 | let values = try decoder.container(keyedBy: CodingKeys.self) 105 | let a = try values.decode(UInt8.self, forKey: .a) 106 | let b = try values.decode(UInt8.self, forKey: .b) 107 | let c = try values.decode(UInt8.self, forKey: .c) 108 | let d = try values.decode(UInt8.self, forKey: .d) 109 | octets = (a, b, c, d) 110 | } 111 | 112 | /// Encodes an IPv4 address to a decoder. 113 | /// - Parameter encoder: the encoder to write data to. 114 | public func encode(to encoder: Encoder) throws { 115 | var container = encoder.container(keyedBy: CodingKeys.self) 116 | let (a, b, c, d) = self.octets 117 | try container.encode(a, forKey: .a) 118 | try container.encode(b, forKey: .b) 119 | try container.encode(c, forKey: .c) 120 | try container.encode(d, forKey: .d) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/Formic/NetworkAddress.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import AsyncDNSResolver 3 | import Dependencies 4 | 5 | extension RemoteHost { 6 | /// A network address, either an IP address or a DNS name. 7 | public struct NetworkAddress: Sendable { 8 | public let address: IPv4Address 9 | public let dnsName: String? 10 | 11 | public init?(_ name: String) { 12 | if name == "localhost" { 13 | self.address = .localhost 14 | self.dnsName = "localhost" 15 | return 16 | } else if let nameIsIPAddress = IPv4Address(name) { 17 | self.address = nameIsIPAddress 18 | self.dnsName = nil 19 | return 20 | } 21 | return nil 22 | } 23 | 24 | public init(_ address: IPv4Address) { 25 | self.address = address 26 | self.dnsName = nil 27 | } 28 | 29 | public init?(_ address: IPv4Address?) { 30 | guard let address = address else { 31 | return nil 32 | } 33 | self.init(address) 34 | } 35 | 36 | internal init(_ address: IPv4Address, resolvedName: String) { 37 | self.address = address 38 | self.dnsName = resolvedName 39 | } 40 | 41 | public static let localhost = NetworkAddress(.localhost, resolvedName: "localhost") 42 | 43 | // MARK: Resolver 44 | 45 | public static func resolve(_ name: String?) async -> NetworkAddress? { 46 | 47 | @Dependency(\.localSystemAccess) var localSystem: any LocalSystemAccess 48 | 49 | guard let name = name else { 50 | return nil 51 | } 52 | 53 | if let nameIsIPAddress = IPv4Address(name) { 54 | return NetworkAddress(nameIsIPAddress) 55 | } 56 | 57 | do { 58 | let result: [ARecord] = try await localSystem.queryA(name: name) 59 | if let firstARecordAddress = result.first?.address, 60 | let ourIPv4Address = IPv4Address(firstARecordAddress.address) 61 | { 62 | return NetworkAddress(ourIPv4Address, resolvedName: name) 63 | } 64 | } catch { 65 | print("Unable to resolve \(name) as an IPv4 address: \(error)") 66 | } 67 | return nil 68 | } 69 | } 70 | } 71 | 72 | extension RemoteHost.NetworkAddress: ExpressibleByArgument { 73 | /// Creates a new network address from a string. 74 | /// - Parameter argument: The argument to parse as a network address. 75 | public init?(argument: String) { 76 | self.init(argument) 77 | } 78 | } 79 | 80 | extension RemoteHost.NetworkAddress: Codable {} 81 | extension RemoteHost.NetworkAddress: Hashable {} 82 | -------------------------------------------------------------------------------- /Sources/Formic/RemoteHost.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | 4 | /// A local or remote host. 5 | public struct RemoteHost: Sendable { 6 | /// The network address. 7 | public let networkAddress: NetworkAddress 8 | /// The port to use for SSH access. 9 | public let sshPort: Int 10 | /// The credentials to use for SSH access. 11 | public let sshAccessCredentials: SSHAccessCredentials 12 | /// A Boolean value that indicates whether to enable strict host checking during SSH connections. 13 | public let strictHostKeyChecking: Bool 14 | 15 | init( 16 | address: NetworkAddress, sshPort: Int, sshAccessCredentials: SSHAccessCredentials, 17 | strictHostKeyChecking: Bool 18 | ) { 19 | self.networkAddress = address 20 | self.sshPort = sshPort 21 | self.sshAccessCredentials = sshAccessCredentials 22 | self.strictHostKeyChecking = strictHostKeyChecking 23 | } 24 | 25 | /// Creates a new host without attempting DNS resolution. 26 | /// 27 | /// The initializer may return nil if the name isn't a valid network address. 28 | /// 29 | /// - Parameters: 30 | /// - name: The network address of the host. 31 | /// - sshPort: the ssh port, defaults to `22`. 32 | /// - sshUser: the ssh user, defaults to the username of the current user. 33 | /// - sshIdentityFile: The ssh identity file, defaults to standard key locations for ssh. 34 | /// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking during SSH connections. 35 | public init?( 36 | _ name: String, sshPort: Int = 22, sshUser: String? = nil, sshIdentityFile: String? = nil, 37 | strictHostKeyChecking: Bool = false 38 | ) throws { 39 | let creds = try SSHAccessCredentials(username: sshUser, identityFile: sshIdentityFile) 40 | guard let address = NetworkAddress(name) else { 41 | return nil 42 | } 43 | self.init( 44 | address: address, sshPort: sshPort, sshAccessCredentials: creds, 45 | strictHostKeyChecking: strictHostKeyChecking) 46 | } 47 | 48 | /// Creates a new host using the NetworkAddress you provide. 49 | /// 50 | /// Use the name `localhost` to ensure all commands are run locally. 51 | /// Use the name `127.0.0.1` to access a remote host through port forwarding. 52 | /// 53 | /// - Parameters: 54 | /// - networkAddress: The network address of the host. 55 | /// - sshPort: the ssh port, defaults to `22`. 56 | /// - sshUser: the ssh user, defaults to the username of the current user. 57 | /// - sshIdentityFile: The ssh identity file, defaults to standard key locations for ssh. 58 | /// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking during SSH connections. 59 | public init( 60 | _ networkAddress: NetworkAddress, 61 | sshPort: Int = 22, 62 | sshUser: String? = nil, 63 | sshIdentityFile: String? = nil, 64 | strictHostKeyChecking: Bool = false 65 | ) throws { 66 | let creds = try SSHAccessCredentials(username: sshUser, identityFile: sshIdentityFile) 67 | self.init( 68 | address: networkAddress, sshPort: sshPort, sshAccessCredentials: creds, 69 | strictHostKeyChecking: strictHostKeyChecking) 70 | } 71 | 72 | /// Creates a new host using DNS name resolution. 73 | /// 74 | /// - Parameters: 75 | /// - name: The DNS name or network address of the host. 76 | /// - sshPort: the ssh port, defaults to `22`. 77 | /// - sshUser: the ssh user, defaults to the username of the current user. 78 | /// - sshIdentityFile: The ssh identity file, defaults to standard key locations for ssh. 79 | /// - strictHostKeyChecking: A Boolean value that indicates whether to enable strict host checking during SSH connections. 80 | public static func resolve( 81 | _ name: String, 82 | sshPort: Int = 22, 83 | sshUser: String? = nil, 84 | sshIdentityFile: String? = nil, 85 | strictHostKeyChecking: Bool = false 86 | ) async throws -> RemoteHost { 87 | let creds = try SSHAccessCredentials(username: sshUser, identityFile: sshIdentityFile) 88 | if let address = await NetworkAddress.resolve(name) { 89 | return RemoteHost( 90 | address: address, sshPort: sshPort, sshAccessCredentials: creds, 91 | strictHostKeyChecking: strictHostKeyChecking) 92 | } else { 93 | throw CommandError.failedToResolveHost(name: name) 94 | } 95 | } 96 | } 97 | 98 | extension RemoteHost: CustomDebugStringConvertible { 99 | /// The debug description of the host. 100 | public var debugDescription: String { 101 | "host \(networkAddress)@\(sshPort), user: \(sshAccessCredentials), \(strictHostKeyChecking ? "strict key checking": "disabled key checking")" 102 | } 103 | } 104 | 105 | extension RemoteHost: CustomStringConvertible { 106 | /// The description of the host. 107 | public var description: String { 108 | return "\(self.sshAccessCredentials.username)@\(networkAddress.address)" 109 | } 110 | } 111 | 112 | extension RemoteHost: ExpressibleByArgument { 113 | /// Creates a new host from a string. 114 | /// - Parameter argument: The argument to parse as a host. 115 | public init?(argument: String) { 116 | try? self.init(argument) 117 | } 118 | } 119 | 120 | extension RemoteHost: Hashable {} 121 | extension RemoteHost: Codable {} 122 | -------------------------------------------------------------------------------- /Sources/Formic/ResourceTypes/Dpkg+Parsers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Parsing 3 | 4 | // https://swiftpackageindex.com/pointfreeco/swift-parsing#user-content-getting-started 5 | // https://pointfreeco.github.io/swift-parsing/0.10.0/documentation/parsing/ 6 | 7 | extension Dpkg { 8 | // parsing individual lines 9 | struct PackageCodes: Parser { 10 | var body: some Parser { 11 | OneOf { 12 | DesiredState.unknown.rawValue.map { DesiredState.unknown } 13 | DesiredState.install.rawValue.map { DesiredState.install } 14 | DesiredState.remove.rawValue.map { DesiredState.remove } 15 | DesiredState.purge.rawValue.map { DesiredState.purge } 16 | DesiredState.hold.rawValue.map { DesiredState.hold } 17 | } 18 | OneOf { 19 | StatusCode.notInstalled.rawValue.map { StatusCode.notInstalled } 20 | StatusCode.installed.rawValue.map { StatusCode.installed } 21 | StatusCode.configFiles.rawValue.map { StatusCode.configFiles } 22 | StatusCode.unpacked.rawValue.map { StatusCode.unpacked } 23 | StatusCode.halfInstalled.rawValue.map { StatusCode.halfInstalled } 24 | StatusCode.triggerAwait.rawValue.map { StatusCode.triggerAwait } 25 | StatusCode.triggerPending.rawValue.map { StatusCode.triggerPending } 26 | } 27 | OneOf { 28 | ErrCode.reinstall.rawValue.map { ErrCode.reinstall } 29 | ErrCode.none.rawValue.map { ErrCode.none } 30 | } 31 | } 32 | } 33 | 34 | struct PackageStatus: Parser { 35 | var body: some Parser { 36 | Parse(Dpkg.init) { 37 | PackageCodes() 38 | Skip { 39 | Whitespace() 40 | } 41 | // package name 42 | Prefix { !$0.isWhitespace }.map(String.init) 43 | Skip { 44 | Whitespace() 45 | } 46 | // version 47 | Prefix { !$0.isWhitespace }.map(String.init) 48 | Skip { 49 | Whitespace() 50 | } 51 | // architecture 52 | Prefix { !$0.isWhitespace }.map(String.init) 53 | Skip { 54 | Whitespace() 55 | } 56 | // description 57 | Prefix { 58 | $0 != "\n" 59 | }.map(String.init) 60 | } 61 | } 62 | } 63 | 64 | // parsing `dpkg -l` output 65 | struct DpkgHeader: Parser { 66 | var body: some Parser { 67 | Skip { 68 | PrefixThrough("========") 69 | } 70 | Skip { 71 | PrefixThrough("\n") 72 | } 73 | } 74 | } 75 | 76 | struct PackageList: Parser { 77 | var body: some Parser { 78 | Dpkg.DpkgHeader() 79 | Many(1...) { 80 | PackageStatus() 81 | } separator: { 82 | "\n" 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/Formic/ResourceTypes/Dpkg.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import Parsing 4 | 5 | // An example resource 6 | // - a collection of debian packages 7 | // - declared state of installed vs. not 8 | 9 | // ex: 10 | // > `docker-user@ubuntu:~$ dpkg -l docker-ce` 11 | // 12 | //Desired=Unknown/Install/Remove/Purge/Hold 13 | //| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend 14 | //|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) 15 | //||/ Name Version Architecture Description 16 | //+++-==============-=============================-============-==================================================== 17 | //ii docker-ce 5:27.3.1-1~ubuntu.24.04~noble arm64 Docker: the open-source application container engine 18 | 19 | /// A debian package resource. 20 | public struct Dpkg: Sendable, Hashable, Resource { 21 | /// Returns an inquiry command that retrieves the output to parse into a resource. 22 | /// - Parameter name: The name of the resource to find. 23 | public static func namedInquiry(_ name: String) -> (any Command) { 24 | ShellCommand("dpkg -l \(name)") 25 | } 26 | 27 | // this example works for when a package exists, but not when it doesn't... it'll (I think) 28 | // lean on throwing an exception. 29 | // ex: heckj@ubuntu:~$ dpkg -l fredbird 30 | // dpkg-query: no packages found matching fredbird 31 | 32 | /// The command to use to get the state for this resource. 33 | public var inquiry: (any Command) { 34 | Self.namedInquiry(name) 35 | } 36 | 37 | /// Returns the state of the resource from the output of the shell command. 38 | /// - Parameter output: The string output of the shell command. 39 | /// - Throws: Any errors parsing the output. 40 | public static func parse(_ output: Data) throws -> Dpkg? { 41 | guard let stringFromData: String = String(data: output, encoding: .utf8) else { 42 | throw ResourceError.notAString 43 | } 44 | let _ = try Dpkg.PackageList().parse(Substring(stringFromData)) 45 | fatalError("not implemented") 46 | } 47 | 48 | /// The desired state code from the Dpkg system. 49 | public enum DesiredState: String, Sendable, Hashable { 50 | case unknown = "u" 51 | case install = "i" 52 | case remove = "r" 53 | case purge = "p" 54 | case hold = "h" 55 | } 56 | 57 | /// The status code from the Dpkg system. 58 | public enum StatusCode: String, Sendable, Hashable { 59 | case notInstalled = "n" 60 | case installed = "i" 61 | case configFiles = "c" 62 | case unpacked = "u" 63 | case failedConfig = "f" 64 | case halfInstalled = "h" 65 | case triggerAwait = "w" 66 | case triggerPending = "t" 67 | } 68 | 69 | /// The error code from the Dpkg system. 70 | public enum ErrCode: String, Sendable, Hashable { 71 | case reinstall = "r" 72 | case none = " " 73 | } 74 | 75 | /// The desired state code of this resource from the Dpkg system. 76 | public let desiredState: DesiredState 77 | /// The status code of this resource from the Dpkg system. 78 | public let statusCode: StatusCode 79 | /// The error code of this resource from the Dpkg system. 80 | public let errCode: ErrCode 81 | 82 | /// The name of the package. 83 | public let name: String 84 | /// The version of the package. 85 | public let version: String 86 | /// The architecture the package supports. 87 | public let architecture: String 88 | /// The description of the package. 89 | public let description: String 90 | 91 | /// The declaration for a Debian package resource. 92 | public struct DebianPackageDeclaration: Hashable, Sendable, Command { 93 | public var id: UUID 94 | public var ignoreFailure: Bool 95 | public var retry: Backoff 96 | public var executionTimeout: Duration 97 | public func run(host: RemoteHost, logger: Logger?) async throws -> CommandOutput { 98 | if try await Dpkg.resolve(state: self, on: host, logger: logger) { 99 | return .generalSuccess(msg: "Resolved") 100 | } else { 101 | return .generalFailure(msg: "Failed") 102 | } 103 | } 104 | 105 | /// The configurable state of a debian package. 106 | public enum DesiredPackageState: String, Hashable, Sendable { 107 | /// The package exists and is installed. 108 | case present 109 | /// The package is not installed or removed. 110 | case absent 111 | } 112 | 113 | /// The name of the package. 114 | public var name: String 115 | /// The desired state of the package. 116 | public var declaredState: DesiredPackageState 117 | 118 | /// Creates a new declaration for a Debian package resource 119 | /// - Parameters: 120 | /// - name: The name of the package. 121 | /// - state: The desired state of the package. 122 | /// - retry: The retry settings for resolving the resource. 123 | /// - resolveTimeout: The execution timeout to allow the resource to resolve. 124 | public init( 125 | name: String, state: DesiredPackageState, retry: Backoff = .never, resolveTimeout: Duration = .seconds(60) 126 | ) { 127 | self.name = name 128 | self.declaredState = state 129 | // Command details 130 | self.id = UUID() 131 | self.ignoreFailure = false 132 | self.retry = retry 133 | self.executionTimeout = resolveTimeout 134 | } 135 | } 136 | } 137 | 138 | extension Dpkg: CollectionResource { 139 | 140 | /// The shell command to use to get the state for this resource. 141 | public static var collectionInquiry: (any Command) { 142 | ShellCommand("dpkg -l") 143 | } 144 | 145 | /// Returns a list of resources from the string output from a command. 146 | /// - Parameter output: The output from the command. 147 | public static func collectionParse(_ output: Data) throws -> [Dpkg] { 148 | guard let stringFromData: String = String(data: output, encoding: .utf8) else { 149 | throw ResourceError.notAString 150 | } 151 | let collection = try Dpkg.PackageList().parse(Substring(stringFromData)) 152 | return collection 153 | } 154 | } 155 | 156 | extension Dpkg: StatefulResource { 157 | /// Queries and returns the state of the resource identified by a declaration you provide. 158 | /// - Parameters: 159 | /// - state: The declaration that identifies the resource. 160 | /// - host: The host on which to find the resource. 161 | /// - logger: An optional logger to record the command output or errors. 162 | /// - Returns: A tuple of the resource state and a timestamp for the state. 163 | public static func query(state: DebianPackageDeclaration, from host: RemoteHost, logger: Logger?) async throws -> ( 164 | Dpkg?, Date 165 | ) { 166 | return try await Dpkg.query(state.name, from: host, logger: logger) 167 | } 168 | 169 | /// Queries and attempts to resolve the update to the desired state you provide. 170 | /// - Parameters: 171 | /// - state: The declaration that identifies the resource and its desired state. 172 | /// - host: The host on which to resolve the resource. 173 | /// - logger: An optional logger to record the command output or errors. 174 | /// - Returns: A tuple of the resource state and a timestamp for the state. 175 | public static func resolve(state: DebianPackageDeclaration, on host: RemoteHost, logger: Logger?) async throws 176 | -> Bool 177 | { 178 | let (currentState, _) = try await Dpkg.query(state.name, from: host, logger: logger) 179 | switch state.declaredState { 180 | case .present: 181 | if currentState?.desiredState == .install && currentState?.statusCode == .installed { 182 | return true 183 | } else { 184 | try await ShellCommand("apt-get install \(state.name)").run(host: host, logger: logger) 185 | let (updatedState, _) = try await Dpkg.query(state.name, from: host, logger: logger) 186 | if updatedState?.desiredState == .install && updatedState?.statusCode == .installed { 187 | return true 188 | } else { 189 | return false 190 | } 191 | } 192 | 193 | case .absent: 194 | if (currentState?.desiredState == .unknown || currentState?.desiredState == .remove) 195 | && currentState?.statusCode == .notInstalled 196 | { 197 | return true 198 | } else { 199 | // do the removal 200 | try await ShellCommand("apt-get remove \(state.name)").run(host: host, logger: logger) 201 | let (updatedState, _) = try await Dpkg.query(state.name, from: host, logger: logger) 202 | if (updatedState?.desiredState == .unknown || updatedState?.desiredState == .remove) 203 | && updatedState?.statusCode == .notInstalled 204 | { 205 | return true 206 | } else { 207 | return false 208 | } 209 | } 210 | // The 'absent' case works for removing an existing package, but if a package never existed 211 | // then there's no concept of 'absent' vs 'unknown' in the dpkg system, so it'll attempt 212 | // to invoke the `apt-get remove` regardless of it being missing. 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /Sources/Formic/ResourceTypes/OperatingSystem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Parsing 3 | 4 | /// The kind of operating system. 5 | public struct OperatingSystem: SingularResource { 6 | /// The kind of operating system. 7 | public let name: KindOfOperatingSystem 8 | 9 | /// The types of operating system this resource can represent. 10 | public enum KindOfOperatingSystem: String, Sendable { 11 | case macOS 12 | case linux 13 | case unknown 14 | } 15 | // ^^ provides a useful type that we can "parse" into and initialize the wrapping resource-type 16 | public static var inquiry: (any Command) { 17 | return ShellCommand("uname") 18 | } 19 | 20 | public var inquiry: (any Command) { 21 | return Self.inquiry 22 | } 23 | 24 | struct UnameParser: Parser { 25 | var body: some Parser { 26 | OneOf { 27 | "Darwin".map { KindOfOperatingSystem.macOS } 28 | "Linux".map { KindOfOperatingSystem.linux } 29 | "linux".map { KindOfOperatingSystem.linux } 30 | "macOS".map { KindOfOperatingSystem.macOS } 31 | } 32 | // NOTE: This parser *always* consumes the "\n" if it's there, 33 | // so it's not suitable to using in a Many() construct. 34 | Skip { 35 | Optionally { 36 | "\n" 37 | } 38 | } 39 | } 40 | } 41 | 42 | /// Returns the state of the resource from the output of the shell command. 43 | /// - Parameter output: The string output of the shell command. 44 | public static func parse(_ output: Data) -> OperatingSystem? { 45 | do { 46 | guard let stringFromData: String = String(data: output, encoding: .utf8) else { 47 | return Self(.unknown) 48 | } 49 | return Self(try UnameParser().parse(stringFromData)) 50 | } catch { 51 | return Self(.unknown) 52 | } 53 | } 54 | 55 | /// Creates a new resource instance for the kind of operating system you provide. 56 | /// - Parameter kind: The kind of operating system. 57 | public init(_ kind: KindOfOperatingSystem) { 58 | self.name = kind 59 | } 60 | 61 | /// Creates a new instance of the resource based on the string you provide. 62 | /// - Parameter name: The string that represents the kind of operating system. 63 | public init(_ name: String) { 64 | do { 65 | self.name = try UnameParser().parse(name) 66 | } catch { 67 | self.name = .unknown 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Formic/ResourceTypes/Swarm+Parsers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Parsing 3 | 4 | // https://swiftpackageindex.com/pointfreeco/swift-parsing#user-content-getting-started 5 | // https://pointfreeco.github.io/swift-parsing/0.10.0/documentation/parsing/ 6 | 7 | /// A parser that converts the STDOUT from a swarm init command into a ShellCommand. 8 | public struct SwarmJoinCommand: Parser { 9 | 10 | func convertToShellCommand(_ argTuple: (String, String, String, String, String, String)) -> ShellCommand { 11 | ShellCommand("\(argTuple.0) \(argTuple.1) \(argTuple.2) \(argTuple.3) \(argTuple.4) \(argTuple.5)") 12 | } 13 | 14 | public var body: some Parser { 15 | Parse(convertToShellCommand) { 16 | Skip { 17 | PrefixThrough("run the following command:\n\n") 18 | } 19 | Skip { 20 | Whitespace() 21 | } 22 | // docker 23 | Prefix { !$0.isWhitespace }.map(String.init) 24 | Skip { 25 | Whitespace() 26 | } 27 | // swarm 28 | Prefix { !$0.isWhitespace }.map(String.init) 29 | Skip { 30 | Whitespace() 31 | } 32 | // join 33 | Prefix { !$0.isWhitespace }.map(String.init) 34 | Skip { 35 | Whitespace() 36 | } 37 | //--token 38 | Prefix { !$0.isWhitespace }.map(String.init) 39 | Skip { 40 | Whitespace() 41 | } 42 | // token-value 43 | Prefix { !$0.isWhitespace }.map(String.init) 44 | Skip { 45 | Whitespace() 46 | } 47 | Prefix { !$0.isWhitespace }.map(String.init) 48 | // host-and-port 49 | Skip { 50 | Optionally { 51 | Rest() 52 | } 53 | } 54 | } 55 | } 56 | 57 | /// Creates a new parser for swarm init or join commands. 58 | public init() {} 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Formic/Resources/CollectionResource.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A collection of resources that can be found and queried from a host. 6 | public protocol CollectionResource: ParsedResource { 7 | /// The shell command to use to get the state for this resource. 8 | static var collectionInquiry: (any Command) { get } 9 | /// Returns a list of resources from the string output from a command. 10 | /// - Parameter output: The output from the command. 11 | static func collectionParse(_ output: Data) throws -> [Self] 12 | /// Returns a list of resources for the host you provide. 13 | /// - Parameter from: The host to inspect. 14 | /// - Parameter logger: An optional logger to record the command output or errors. 15 | static func queryCollection(from: RemoteHost, logger: Logger?) async throws -> ([Self], Date) 16 | /// Returns an inquiry command that retrieves the output to parse into a resource. 17 | /// - Parameter name: The name of the resource to find. 18 | static func namedInquiry(_ name: String) -> (any Command) 19 | } 20 | 21 | extension CollectionResource { 22 | /// Queries the state of the resource from the given host. 23 | /// - Parameter host: The host to inspect. 24 | /// - Parameter logger: An optional logger to record the command output or errors. 25 | /// - Returns: A list of the resources found, and the timestamp when it was checked. 26 | public static func queryCollection(from host: RemoteHost, logger: Logger?) async throws -> ([Self], Date) { 27 | // default implementation: 28 | 29 | @Dependency(\.date.now) var date 30 | // run the command on the relevant host, capturing the output 31 | let output: CommandOutput = try await collectionInquiry.run(host: host, logger: logger) 32 | // verify the return code is 0 33 | if output.returnCode != 0 { 34 | throw CommandError.commandFailed(rc: output.returnCode, errmsg: output.stderrString ?? "") 35 | } else { 36 | // then parse the output 37 | guard let stdout = output.stdOut else { 38 | throw CommandError.noOutputToParse( 39 | msg: 40 | "The command \(Self.collectionInquiry) to \(host) did not return any output. stdError: \(output.stderrString ?? "-none-")" 41 | ) 42 | } 43 | let parsedState = try Self.collectionParse(stdout) 44 | return (parsedState, date) 45 | } 46 | } 47 | 48 | /// Returns the individual resource, if it exists, and the timestamp of the check from a resource collection and host that you provide. 49 | /// - Parameter name: The name of the resource to find. 50 | /// - Parameter host: The host to inspect for the resource. 51 | /// - Parameter logger: An optional logger to record the command output or errors. 52 | static func query(_ name: String, from host: RemoteHost, logger: Logger?) async throws -> (Self?, Date) { 53 | // default implementation: 54 | 55 | @Dependency(\.date.now) var date 56 | // run the command on the relevant host, capturing the output 57 | let output: CommandOutput = try await Self.namedInquiry(name).run(host: host, logger: logger) 58 | // verify the return code is 0 59 | if output.returnCode != 0 { 60 | throw CommandError.commandFailed(rc: output.returnCode, errmsg: output.stderrString ?? "") 61 | } else { 62 | // then parse the output 63 | guard let stdout = output.stdOut else { 64 | throw CommandError.noOutputToParse( 65 | msg: 66 | "The command \(Self.collectionInquiry) to \(host) did not return any output. stdError: \(output.stderrString ?? "-none-")" 67 | ) 68 | } 69 | let parsedState = try Self.parse(stdout) 70 | return (parsedState, date) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Formic/Resources/Resource.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A type that can be queried from a host to provide information about itself. 6 | public protocol Resource: Hashable, Sendable { 7 | 8 | // IMPLEMENTATION NOTE: 9 | // A resource is fundamentally something you can query about a system to get details about it. 10 | // Any **instance** of a resource should be able to get updated details about itself. 11 | 12 | /// Queries the state of the resource from the given host. 13 | /// - Parameter from: The host to inspect. 14 | /// - Parameter logger: An optional logger to record the command output or errors. 15 | /// - Returns: The resource, if it exists, and a timestamp at which is was checked. 16 | func query(from: RemoteHost, logger: Logger?) async throws -> (Self?, Date) 17 | 18 | } 19 | 20 | /// A resource that provides an inquiry command and parser to return the state of the resource. 21 | public protocol ParsedResource: Resource { 22 | /// The command to use to get the state for this resource. 23 | var inquiry: (any Command) { get } 24 | /// Returns the resource, if it exists, from the output of the shell command, otherwise nil. 25 | /// - Parameter output: The string output of the shell command. 26 | /// - Throws: Any errors parsing the output. 27 | static func parse(_ output: Data) throws -> Self? 28 | } 29 | 30 | // IMPLEMENTATION NOTE: 31 | // Requirement 0 - persist-able and comparable 32 | // - `Hashable` - maybe `Codable` down the road. 33 | // (I want to be able to persist a "last known state" - or the information needed to determine 34 | // its state - and be able to read it back in again in another "run".) Right now, I'm only requiring 35 | // Hashable and Sendable while I work out how to use them in a larger scope, and where I might want 36 | // to apply persistence (Codable) down the road (expected in the "Operator" use case). 37 | 38 | // Requirement 1 - a way query the current state, the fundamental aspect of a resource. 39 | // - It's starting off with an expected pattern of a command (run on a host). 40 | // The command returns an output that can be parsed into the resource type. 41 | // Implementations should be able to provide a command to run, and a parser to do the 42 | // conversion, and a default implementation of query does the work. 43 | 44 | // Working from some initial implementations, I've broken this up into multiple protocols. 45 | // The baseline is a resource that, given an instance, you can query for the latest state 46 | // and associated details that might go along with the current state. 47 | // 48 | // There's a difference between resources that you only need to provide a "host" to get details 49 | // about, and resources that you need to provide a name or other identifier. 50 | // The first I've called "SingularResource" - the example is OperatingSystem providing a baseline. 51 | // The second I've called "CollectionResource" - the example is a DebianPackage. 52 | // To accommodate lists of resources within a single host, I went with "CollectionResource". 53 | // 54 | // (I think a single command will do what we need for resources within an OS, but for resources 55 | // that span multiple hosts, we will need something different) 56 | 57 | extension ParsedResource { 58 | /// Queries the state of the resource from the given host. 59 | /// - Parameter host: The host to inspect. 60 | /// - Parameter logger: An optional logger to record the command output or errors. 61 | /// - Returns: The the resource, if it exists, and the timestamp that it was last checked. 62 | public func query(from host: RemoteHost, logger: Logger?) async throws -> (Self?, Date) { 63 | // default implementation to get updated details from an _instance_ of a resource 64 | 65 | @Dependency(\.date.now) var date 66 | // run the command on the relevant host, capturing the output 67 | let output: CommandOutput = try await inquiry.run(host: host, logger: logger) 68 | // verify the return code is 0 69 | if output.returnCode != 0 { 70 | throw CommandError.commandFailed(rc: output.returnCode, errmsg: output.stderrString ?? "") 71 | } else { 72 | // then parse the output 73 | guard let stdout = output.stdOut else { 74 | throw CommandError.noOutputToParse( 75 | msg: 76 | "The command \(inquiry) to \(host) did not return any output. stdError: \(output.stderrString ?? "-none-")" 77 | ) 78 | } 79 | let parsedState = try Self.parse(stdout) 80 | return (parsedState, date) 81 | } 82 | } 83 | } 84 | 85 | // TODO(heckj): These additional requirements are going to be needed for resolvable state and actions 86 | // in a more "Operator" kind of pattern. These aren't yet fleshed out. 87 | // 88 | // Requirement 5 - a way to test the state of the resource (various diagnostic levels) 89 | // 90 | // Some resources - such as a file or the group settings of an OS - won't have deeper levels 91 | // of diagnostics available. You pretty much just get to look to see if the file is there and as 92 | // you expect, or not. Other services - running processes - _can_ have deeper diagnostics to 93 | // interrogate and "work" them to determine if they're fully operational. 94 | // This is potentially very interesting for dependencies between services, especially for 95 | // when dependencies span across multiple hosts. It could provide the way to "easily understand" 96 | // why a service is failing. 97 | -------------------------------------------------------------------------------- /Sources/Formic/Resources/ResourceError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An error that occurs when running a command. 4 | public enum ResourceError: LocalizedError { 5 | /// Failure due to the output from inquiry not being decodable as a UTF-8 string. 6 | case notAString 7 | 8 | /// The localized description. 9 | public var errorDescription: String? { 10 | switch self { 11 | case .notAString: 12 | "Inquiry output is not a UTF-8 string." 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Formic/Resources/SingularResource.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | 5 | /// A type of resource that exists in singular form on a Host. 6 | public protocol SingularResource: ParsedResource { 7 | 8 | // a singular resource should have a way to query for the kind of resource given 9 | // JUST the host, so any default query & parse setup should be accessible as 10 | // static variables or functions. 11 | 12 | /// The shell command to use to get the state for this resource. 13 | static var inquiry: (any Command) { get } 14 | /// Queries the state of the resource from the given host. 15 | /// - Parameter from: The host to inspect. 16 | /// - Parameter logger: An optional logger to record the command output or errors. 17 | /// - Returns: The state of the resource. 18 | static func query(from: RemoteHost, logger: Logger?) async throws -> (Self?, Date) 19 | } 20 | 21 | extension SingularResource { 22 | /// Queries the state of the resource from the given host. 23 | /// - Parameter host: The host to inspect. 24 | /// - Parameter logger: An optional logger to record the command output or errors. 25 | /// - Returns: The state of the resource and the time that it was last updated. 26 | public static func query(from host: RemoteHost, logger: Logger?) async throws -> (Self?, Date) { 27 | // default implementation: 28 | 29 | @Dependency(\.date.now) var date 30 | // run the command on the relevant host, capturing the output 31 | let output: CommandOutput = try await Self.inquiry.run(host: host, logger: logger) 32 | // verify the return code is 0 33 | if output.returnCode != 0 { 34 | throw CommandError.commandFailed(rc: output.returnCode, errmsg: output.stderrString ?? "") 35 | } else { 36 | // then parse the output 37 | guard let stdout = output.stdOut else { 38 | throw CommandError.noOutputToParse( 39 | msg: 40 | "The command \(Self.inquiry) to \(host) did not return any output. stdError: \(output.stderrString ?? "-none-")" 41 | ) 42 | } 43 | let parsedState = try Self.parse(stdout) 44 | return (parsedState, date) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Formic/Resources/StatefulResource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | /// A type of resource that can be retrieved and resolved to a desired state using a declaration. 5 | public protocol StatefulResource: Resource { 6 | associatedtype DeclarativeStateType: Sendable, Hashable 7 | // a declaration alone should be enough to get the resource and resolve it to 8 | // whatever state is desired and supported. 9 | 10 | // IMPLEMENTATION NOTES: 11 | // Requirement 2 - a declarative structure to represent what we want it to be 12 | // - name 13 | // - state (present|absent) 14 | // 15 | // The idea being that a declaration is tiny subset of all the detail possible from a 16 | // resource, but sufficient to identify - and to hold what states is can "resolve" 17 | // and set declaratively. This allows us to represent the resource types with strong 18 | // types in Swift, while exposing the details as properties. 19 | // 20 | // - additional state/information (not declarable, but available to be inspected) 21 | // - version 22 | // - architecture 23 | // - description 24 | // 25 | // We need enough of a name and context to be able to request information about 26 | // the resource. For software on a single OS, a type, name, and host address should be enough. 27 | // But for resources that span hosts, we might need something very different. 28 | // 29 | // Requirement 3 - a way to compute the changes needed to get into that desired state 30 | // - knowledge of (1), and `resolve()` 31 | // (a full representation of all the states and how to transition between them) 32 | // 33 | // Requirement 4 - the actions to take to make those changes 34 | // - `resolve()` 35 | // 36 | // Knowing the full state is likely an internal type on the resource, and mapping 37 | // the possible states to what can be declared is the core of the `resolve()` function. 38 | 39 | /// Queries and returns the resource, if it exists, identified by a declaration you provide. 40 | /// - Parameters: 41 | /// - state: The declaration that identifies the resource. 42 | /// - host: The host on which to find the resource. 43 | /// - logger: An optional logger to record the command output or errors. 44 | /// - Returns: A tuple of the resource, if it exists, and a timestamp of the check. 45 | static func query(state: DeclarativeStateType, from host: RemoteHost, logger: Logger?) async throws -> (Self?, Date) 46 | 47 | /// Queries and attempts to resolve the update to the desired state you provide. 48 | /// - Parameters: 49 | /// - state: The declaration that identifies the resource and its desired state. 50 | /// - host: The host on which to resolve the resource. 51 | /// - logger: An optional logger to record the command output or errors. 52 | /// - Returns: A tuple of the resource state and a timestamp for the state. 53 | static func resolve(state: DeclarativeStateType, on host: RemoteHost, logger: Logger?) async throws -> Bool 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Formic/SSHAccessCredentials.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | 4 | extension RemoteHost { 5 | 6 | // This loosely represents a username and credentials, built for pass through to a command 7 | // line version of `ssh` to invoke remote commands. Citadel SSH provides an API that requires 8 | // a bit more detail about credentials, as it wants (needs) to know the specific kind of key 9 | // (rsa vs dsa vs ed25519, etc) and loads the key into memory in order to pass it to the remote 10 | // SSH server for authentication. 11 | 12 | // If we want to leverage (or base more of the interactions) using Citadel SSH, this API 13 | // probably needs to get updated to encapsulate the kind of key that was loaded by 14 | // default. Currently, it tries to match how `ssh` on the CLI automatically looks for keys 15 | // in the .ssh directory). 16 | 17 | /// SSH Credentials for accessing a remote host. 18 | public struct SSHAccessCredentials: Sendable { 19 | public let username: String 20 | public let identityFile: String 21 | 22 | public init(username: String, identityFile: String) { 23 | self.username = username 24 | self.identityFile = identityFile 25 | } 26 | 27 | private static func defaultUsername() -> String? { 28 | @Dependency(\.localSystemAccess) var localHostAccess: any LocalSystemAccess 29 | return localHostAccess.username 30 | } 31 | 32 | private static func defaultIdentityFilePath() -> String? { 33 | @Dependency(\.localSystemAccess) var localHostAccess: any LocalSystemAccess 34 | 35 | let homeDirectory = localHostAccess.homeDirectory 36 | let rsaPath = homeDirectory.appendingPathComponent(".ssh/id_rsa").path 37 | if localHostAccess.fileExists(atPath: rsaPath) { 38 | return rsaPath 39 | } 40 | let dsaPath = homeDirectory.appendingPathComponent(".ssh/id_dsa").path 41 | if localHostAccess.fileExists(atPath: dsaPath) { 42 | return dsaPath 43 | } 44 | let ed25519Path = homeDirectory.appendingPathComponent(".ssh/id_ed25519").path 45 | if localHostAccess.fileExists(atPath: ed25519Path) { 46 | return ed25519Path 47 | } 48 | return nil 49 | } 50 | 51 | public init(username: String? = nil, identityFile: String? = nil) throws { 52 | let username = username ?? Self.defaultUsername() 53 | let identityFile = identityFile ?? Self.defaultIdentityFilePath() 54 | 55 | if let username = username, let identityFile = identityFile { 56 | self.init(username: username, identityFile: identityFile) 57 | } else { 58 | var msg: String = "" 59 | if username == nil { 60 | msg.append("The local username could not be determined as a default to access a remote host. ") 61 | } 62 | if identityFile == nil { 63 | msg.append( 64 | "A local SSH identity file could not be determined as a default to access a remote host. ") 65 | } 66 | throw CommandError.missingSSHAccessCredentials(msg: msg) 67 | } 68 | } 69 | } 70 | } 71 | 72 | extension RemoteHost.SSHAccessCredentials: Hashable {} 73 | extension RemoteHost.SSHAccessCredentials: Codable {} 74 | -------------------------------------------------------------------------------- /Tests/formicTests/BackoffTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import Formic 5 | 6 | @Test("verify backoff logic - .none") 7 | func testBackoffDelayLogicNone() async throws { 8 | let strategy = Backoff.Strategy.none 9 | #expect(strategy.delay(for: 0, withJitter: false) == .seconds(0)) 10 | #expect(strategy.delay(for: 10, withJitter: false) == .seconds(0)) 11 | } 12 | 13 | @Test("verify backoff builtins") 14 | func testBackoffBuiltings() async throws { 15 | let backoff = Backoff.never 16 | #expect(backoff.maxRetries == 0) 17 | #expect(backoff.strategy == .none) 18 | 19 | let backoff2 = Backoff.default 20 | #expect(backoff2.maxRetries == 3) 21 | #expect(backoff2.strategy == .fibonacci(maxDelay: .seconds(10))) 22 | } 23 | 24 | @Test("verify backoff initializer with negative value") 25 | func testBackoffDelayInitnegative() async throws { 26 | let negBackoff = Backoff(maxRetries: -1, strategy: .none) 27 | #expect(negBackoff.maxRetries == 0) 28 | } 29 | 30 | @Test("verify backoff logic - .constant") 31 | func testBackoffDelayLogicConstant() async throws { 32 | let strategy = Backoff.Strategy.constant(delay: .seconds(3.5)) 33 | #expect(strategy.delay(for: 0, withJitter: false) == .seconds(3.5)) 34 | #expect(strategy.delay(for: 10, withJitter: false) == .seconds(3.5)) 35 | } 36 | 37 | @Test("verify backoff logic - .linear") 38 | func testBackoffDelayLogicLinear() async throws { 39 | let strategy = Backoff.Strategy.linear(increment: .seconds(2), maxDelay: .seconds(3.5)) 40 | #expect(strategy.delay(for: 0, withJitter: false) == .seconds(0)) 41 | #expect(strategy.delay(for: 1, withJitter: false) == .seconds(2)) 42 | #expect(strategy.delay(for: 10, withJitter: false) == .seconds(3.5)) 43 | 44 | #expect(strategy.delay(for: 1, withJitter: true) != .seconds(2)) 45 | } 46 | 47 | @Test("verify backoff logic - .fibonacci") 48 | func testBackoffDelayLogicFibonacci() async throws { 49 | let strategy = Backoff.Strategy.fibonacci(maxDelay: .seconds(3.5)) 50 | #expect(strategy.delay(for: 0, withJitter: false) == .seconds(0)) 51 | #expect(strategy.delay(for: 1, withJitter: false) == .seconds(1)) 52 | #expect(strategy.delay(for: 2, withJitter: false) == .seconds(1)) 53 | #expect(strategy.delay(for: 3, withJitter: false) == .seconds(2)) 54 | #expect(strategy.delay(for: 4, withJitter: false) == .seconds(3)) 55 | #expect(strategy.delay(for: 10, withJitter: false) == .seconds(3.5)) 56 | 57 | #expect(strategy.delay(for: 3, withJitter: true) != .seconds(2)) 58 | #expect(strategy.delay(for: 20, withJitter: true) <= .seconds(3.5)) 59 | } 60 | 61 | @Test("verify backoff logic - .exponential") 62 | func testBackoffDelayLogicExponential() async throws { 63 | let strategy = Backoff.Strategy.exponential(maxDelay: .seconds(3.5)) 64 | #expect(strategy.delay(for: 0, withJitter: false) == .seconds(0)) 65 | #expect(strategy.delay(for: 1, withJitter: false) == .seconds(1)) 66 | #expect(strategy.delay(for: 2, withJitter: false) == .seconds(2)) 67 | #expect(strategy.delay(for: 3, withJitter: false) == .seconds(3.5)) 68 | #expect(strategy.delay(for: 4, withJitter: false) == .seconds(3.5)) 69 | #expect(strategy.delay(for: 10, withJitter: false) == .seconds(3.5)) 70 | 71 | #expect(strategy.delay(for: 2, withJitter: true) != .seconds(2)) 72 | #expect(strategy.delay(for: 20, withJitter: true) <= .seconds(3.5)) 73 | } 74 | 75 | @Test("verify jitter logic") 76 | func testBackoffDelayJitter() async throws { 77 | for _ in 0..<100 { 78 | let jitterValue = Backoff.Strategy.jitterValue(base: .seconds(2), max: .seconds(2)) 79 | // plus or minus 5 % of the base value = +/- 0.1 seconds, but never above max 80 | #expect(jitterValue >= .seconds(1.9)) 81 | #expect(jitterValue <= .seconds(2.0)) 82 | } 83 | 84 | for _ in 0..<100 { 85 | let jitterValue = Backoff.Strategy.jitterValue(base: .seconds(2), max: .seconds(3)) 86 | // plus or minus 5 % of the base value = +/- 0.1 seconds, but never above max 87 | #expect(jitterValue >= .seconds(1.9)) 88 | #expect(jitterValue <= .seconds(2.1)) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Tests/formicTests/CommandInvokerTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | import SwiftLogTesting 5 | import Testing 6 | 7 | @testable import Formic 8 | 9 | @Test( 10 | "invoking a command", 11 | .enabled(if: ProcessInfo.processInfo.environment.keys.contains("CI")), 12 | .timeLimit(.minutes(1)), 13 | .tags(.functionalTest)) 14 | func invokeBasicCommandLocally() async throws { 15 | let shellResult = try await ProcessCommandInvoker().localShell(cmd: ["uname"], stdIn: nil, env: nil) 16 | 17 | // print("rc: \(shellResult.returnCode)") 18 | // print("out: \(shellResult.stdoutString ?? "nil")") 19 | // print("err: \(shellResult.stderrString ?? "nil")") 20 | 21 | // results expected on a Linux host only 22 | #expect(shellResult.returnCode == 0) 23 | let stdout = try #require(shellResult.stdoutString) 24 | #expect(stdout.contains("Linux")) 25 | #expect(shellResult.stderrString == nil) 26 | } 27 | 28 | @Test( 29 | "invoking a remote command", 30 | .enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")), 31 | .timeLimit(.minutes(1)), 32 | .tags(.integrationTest)) 33 | func invokeRemoteCommand() async throws { 34 | let shellResult = try await ProcessCommandInvoker().remoteShell( 35 | host: "127.0.0.1", user: "heckj", identityFile: "~/.orbstack/ssh/id_ed25519", port: 32222, 36 | cmd: "ls -al", env: nil, logger: nil) 37 | print("rc: \(shellResult.returnCode)") 38 | print("out: \(shellResult.stdoutString ?? "nil")") 39 | print("err: \(shellResult.stderrString ?? "nil")") 40 | } 41 | 42 | @Test( 43 | "invoking a remote command with Env", 44 | .enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")), 45 | .timeLimit(.minutes(1)), 46 | .tags(.integrationTest)) 47 | func invokeRemoteCommandWithEnv() async throws { 48 | let shellResult = try await ProcessCommandInvoker().remoteShell( 49 | host: "127.0.0.1", user: "heckj", identityFile: "~/.orbstack/ssh/id_ed25519", port: 32222, 50 | cmd: "echo ${FIDDLY}", env: ["FIDDLY": "FADDLY"], logger: nil) 51 | print("rc: \(shellResult.returnCode)") 52 | print("out: \(shellResult.stdoutString ?? "nil")") 53 | print("err: \(shellResult.stderrString ?? "nil")") 54 | } 55 | 56 | @Test( 57 | "invoking a remote command w/ tilde", 58 | .enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")), 59 | .timeLimit(.minutes(1)), 60 | .tags(.integrationTest)) 61 | func invokeRemoteCommandWithTilde() async throws { 62 | let shellResult = try await ProcessCommandInvoker().remoteShell( 63 | host: "172.190.172.6", user: "docker-user", identityFile: "~/.ssh/bastion_id_ed25519", 64 | cmd: "mkdir -p ~/.ssh", env: nil, logger: nil) 65 | print("rc: \(shellResult.returnCode)") 66 | print("out: \(shellResult.stdoutString ?? "nil")") 67 | print("err: \(shellResult.stderrString ?? "nil")") 68 | } 69 | 70 | @Test( 71 | "invoking a remote command w/ tilde", 72 | .enabled(if: ProcessInfo.processInfo.environment.keys.contains("INTEGRATION_ENABLED")), 73 | .timeLimit(.minutes(1)), 74 | .tags(.integrationTest)) 75 | func invokeVerifyAccess() async throws { 76 | let engine = Engine() 77 | let orbStackAddress = try #require(Formic.RemoteHost.NetworkAddress("127.0.0.1")) 78 | let orbStackHost = Formic.RemoteHost( 79 | address: orbStackAddress, 80 | sshPort: 32222, 81 | sshAccessCredentials: .init( 82 | username: "heckj", 83 | identityFile: "/Users/heckj/.orbstack/ssh/id_ed25519"), 84 | strictHostKeyChecking: false) 85 | 86 | let result = try await engine.run(host: orbStackHost, command: VerifyAccess()) 87 | print(result.consoleOutput(detailLevel: .verbose(emoji: true))) 88 | } 89 | -------------------------------------------------------------------------------- /Tests/formicTests/CommandOutputTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | @testable import Formic 4 | 5 | @Test("CommandOutput initializer") 6 | func initCommandOutput() async throws { 7 | let output = CommandOutput( 8 | returnCode: 0, 9 | stdOut: "Darwin\n".data(using: .utf8), 10 | stdErr: nil 11 | ) 12 | #expect(output.returnCode == 0) 13 | #expect(output.stdoutString == "Darwin\n") 14 | #expect(output.stderrString == nil) 15 | } 16 | 17 | @Test("CommandOutput builtins") 18 | func testCommandOutputBuiltins() async throws { 19 | #expect(CommandOutput.empty.returnCode == 0) 20 | #expect(CommandOutput.empty.stdoutString == nil) 21 | #expect(CommandOutput.empty.stderrString == nil) 22 | 23 | #expect(CommandOutput.generalFailure(msg: "A").returnCode == -1) 24 | #expect(CommandOutput.generalFailure(msg: "A").stdoutString == nil) 25 | #expect(CommandOutput.generalFailure(msg: "A").stderrString == "A") 26 | 27 | #expect(CommandOutput.generalSuccess(msg: "A").returnCode == 0) 28 | #expect(CommandOutput.generalSuccess(msg: "A").stdoutString == "A") 29 | #expect(CommandOutput.generalSuccess(msg: "A").stderrString == nil) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Tests/formicTests/Commands/AnyCommandTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("initializing a generic AnyCommand") 8 | func anyCommandInit() async throws { 9 | let command = AnyCommand(name: "myName", ignoreFailure: false, retry: .never, executionTimeout: .seconds(60)) { 10 | _, _ in 11 | return CommandOutput.generalSuccess(msg: "done") 12 | } 13 | 14 | #expect(command.ignoreFailure == false) 15 | #expect(command.retry == .never) 16 | #expect(command.description == "myName") 17 | 18 | let localhost: RemoteHost = try withDependencies { 19 | $0.localSystemAccess = TestFileSystemAccess() 20 | } operation: { 21 | try RemoteHost(RemoteHost.NetworkAddress.localhost) 22 | } 23 | 24 | let output = try await command.run(host: localhost, logger: nil) 25 | #expect(output.returnCode == 0) 26 | #expect(output.stdoutString == "done") 27 | } 28 | 29 | @Test("hashable AnyCommand") 30 | func anyCommandHashEquatable() async throws { 31 | let command1 = AnyCommand(name: "myName", ignoreFailure: false, retry: .never, executionTimeout: .seconds(60)) { 32 | _, _ in 33 | return CommandOutput.generalSuccess(msg: "done") 34 | } 35 | 36 | let command2 = AnyCommand(name: "myName", ignoreFailure: true, retry: .default, executionTimeout: .seconds(60)) { 37 | _, _ in 38 | return CommandOutput.generalSuccess(msg: "done") 39 | } 40 | 41 | let command3 = AnyCommand(name: "myName", ignoreFailure: true, retry: .default, executionTimeout: .seconds(60)) { 42 | _, _ in 43 | return CommandOutput.generalSuccess(msg: "yep") 44 | } 45 | 46 | #expect(command1 != command2) 47 | #expect(command1.hashValue != command2.hashValue) 48 | 49 | // only because "ID" inside is different 50 | #expect(command2.hashValue != command3.hashValue) 51 | } 52 | -------------------------------------------------------------------------------- /Tests/formicTests/Commands/CopyFromTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("initializing a copy command") 8 | func copyFromCommandDeclarationTest() async throws { 9 | 10 | let url: URL = try #require(URL(string: "http://somehost.com/datafile")) 11 | let command = CopyFrom(into: "/dest/path", from: url) 12 | #expect(command.retry == .never) 13 | #expect(command.from == url) 14 | #expect(command.destinationPath == "/dest/path") 15 | #expect(command.env == nil) 16 | 17 | #expect(command.description == "scp http://somehost.com/datafile to remote host:/dest/path") 18 | } 19 | 20 | @Test("initializing a copy command with all options") 21 | func copyFromCommandFullDeclarationTest() async throws { 22 | let url: URL = try #require(URL(string: "http://somehost.com/datafile")) 23 | let command = CopyFrom( 24 | into: "/dest/path", from: url, 25 | retry: Backoff(maxRetries: 100, strategy: .fibonacci(maxDelay: .seconds(60)))) 26 | #expect(command.from == url) 27 | #expect(command.destinationPath == "/dest/path") 28 | #expect(command.env == nil) 29 | #expect(command.retry == Backoff(maxRetries: 100, strategy: .fibonacci(maxDelay: .seconds(60)))) 30 | 31 | #expect(command.description == "scp http://somehost.com/datafile to remote host:/dest/path") 32 | } 33 | 34 | @Test("test invoking a copyFrom command") 35 | func testInvokingCopyFromCommand() async throws { 36 | 37 | let url: URL = try #require(URL(string: "http://somewhere.com/datafile")) 38 | let testInvoker = TestCommandInvoker() 39 | .addData(url: url, data: "file contents".data(using: .utf8)) 40 | 41 | let host = try await withDependencies { dependencyValues in 42 | dependencyValues.localSystemAccess = TestFileSystemAccess( 43 | dnsName: "somewhere.com", ipAddressesToUse: ["8.8.8.8"]) 44 | } operation: { 45 | try await RemoteHost.resolve("somewhere.com") 46 | } 47 | 48 | let cmdOut = try await withDependencies { 49 | $0.commandInvoker = testInvoker 50 | } operation: { 51 | try await CopyFrom(into: "/dest/path", from: url).run(host: host, logger: nil) 52 | } 53 | 54 | #expect(cmdOut.returnCode == 0) 55 | } 56 | -------------------------------------------------------------------------------- /Tests/formicTests/Commands/CopyIntoTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("initializing a copy command") 8 | func copyCommandDeclarationTest() async throws { 9 | let command = CopyInto(location: "two", from: "one") 10 | #expect(command.retry == .never) 11 | #expect(command.from == "one") 12 | #expect(command.destinationPath == "two") 13 | #expect(command.env == nil) 14 | 15 | #expect(command.description == "scp one to remote host:two") 16 | } 17 | 18 | @Test("initializing a copy command with all options") 19 | func copyCommandFullDeclarationTest() async throws { 20 | let command = CopyInto( 21 | location: "two", from: "one", 22 | retry: Backoff(maxRetries: 100, strategy: .fibonacci(maxDelay: .seconds(60)))) 23 | #expect(command.from == "one") 24 | #expect(command.destinationPath == "two") 25 | #expect(command.env == nil) 26 | #expect(command.retry == Backoff(maxRetries: 100, strategy: .fibonacci(maxDelay: .seconds(60)))) 27 | 28 | #expect(command.description == "scp one to remote host:two") 29 | } 30 | 31 | @Test("test invoking a copyInt command") 32 | func testInvokingCopyIntoCommand() async throws { 33 | 34 | let testInvoker = TestCommandInvoker() 35 | 36 | let host = try await withDependencies { dependencyValues in 37 | dependencyValues.localSystemAccess = TestFileSystemAccess( 38 | dnsName: "somewhere.com", ipAddressesToUse: ["8.8.8.8"]) 39 | } operation: { 40 | try await RemoteHost.resolve("somewhere.com") 41 | } 42 | 43 | let cmdOut = try await withDependencies { 44 | $0.commandInvoker = testInvoker 45 | } operation: { 46 | try await CopyInto(location: "/etc/configFile", from: "~/datafile").run(host: host, logger: nil) 47 | } 48 | 49 | #expect(cmdOut.returnCode == 0) 50 | } 51 | -------------------------------------------------------------------------------- /Tests/formicTests/Commands/ShellCommandTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("initializing a shell command") 8 | func shellCommandDeclarationTest() async throws { 9 | let command = ShellCommand("uname") 10 | #expect(command.retry == .never) 11 | #expect(command.commandString == "uname") 12 | #expect(command.env == nil) 13 | #expect(command.id != nil) 14 | 15 | #expect(command.description == "uname") 16 | } 17 | 18 | @Test("unique command ids by instance (Identifiable)") 19 | func verifyIdentifiableCommands() async throws { 20 | let command1 = ShellCommand("uname") 21 | let command2 = ShellCommand("uname") 22 | try #require(command1.id != command2.id) 23 | } 24 | 25 | @Test("initializing a shell command with all options") 26 | func shellCommandFullDeclarationTest() async throws { 27 | let command = ShellCommand( 28 | "ls", env: ["PATH": "/usr/bin"], 29 | retry: Backoff(maxRetries: 200, strategy: .exponential(maxDelay: .seconds(60)))) 30 | #expect(command.commandString == "ls") 31 | #expect(command.env == ["PATH": "/usr/bin"]) 32 | #expect(command.retry == Backoff(maxRetries: 200, strategy: .exponential(maxDelay: .seconds(60)))) 33 | #expect(command.description == "ls") 34 | } 35 | 36 | @Test("test invoking a shell command") 37 | func testInvokingShellCommand() async throws { 38 | 39 | let testInvoker = TestCommandInvoker() 40 | 41 | let host = try await withDependencies { dependencyValues in 42 | dependencyValues.localSystemAccess = TestFileSystemAccess( 43 | dnsName: "somewhere.com", ipAddressesToUse: ["8.8.8.8"]) 44 | } operation: { 45 | try await RemoteHost.resolve("somewhere.com") 46 | } 47 | 48 | let cmdOut = try await withDependencies { 49 | $0.commandInvoker = testInvoker 50 | } operation: { 51 | try await ShellCommand("ls -altr").run(host: host, logger: nil) 52 | } 53 | 54 | #expect(cmdOut.returnCode == 0) 55 | } 56 | -------------------------------------------------------------------------------- /Tests/formicTests/Commands/VerifyAccessCommandTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("test invoking a verify access command with failure response") 8 | func testInvokingVerifyAccessFail() async throws { 9 | 10 | let testInvoker = TestCommandInvoker() 11 | 12 | let host = try await withDependencies { dependencyValues in 13 | dependencyValues.localSystemAccess = TestFileSystemAccess( 14 | dnsName: "somewhere.com", ipAddressesToUse: ["8.8.8.8"]) 15 | } operation: { 16 | try await RemoteHost.resolve("somewhere.com") 17 | } 18 | 19 | let cmdOut = try await withDependencies { 20 | $0.commandInvoker = testInvoker 21 | } operation: { 22 | try await VerifyAccess().run(host: host, logger: nil) 23 | } 24 | #expect(cmdOut.stderrString == "Unable to verify access.") 25 | #expect(cmdOut.returnCode == -1) 26 | } 27 | 28 | @Test("test invoking a verify access command success") 29 | func testInvokingVerifyAccessSuccess() async throws { 30 | 31 | let testInvoker = TestCommandInvoker() 32 | 33 | let host = try await withDependencies { dependencyValues in 34 | dependencyValues.localSystemAccess = TestFileSystemAccess( 35 | dnsName: "somewhere.com", ipAddressesToUse: ["8.8.8.8"]) 36 | } operation: { 37 | try await RemoteHost.resolve("somewhere.com") 38 | } 39 | 40 | let cmdOut = try await withDependencies { 41 | $0.commandInvoker = testInvoker.addSuccess(command: "echo 'hello'", presentOutput: "hello\n") 42 | } operation: { 43 | try await VerifyAccess().run(host: host, logger: nil) 44 | } 45 | #expect(cmdOut.stdoutString == "hello") 46 | #expect(cmdOut.returnCode == 0) 47 | } 48 | -------------------------------------------------------------------------------- /Tests/formicTests/Fixtures/id_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACDb7vdmJJ/WK+48XplfTFVzyvJ3IYqBZD1LO6wvuWLrPQAAAJhIV4KSSFeC 4 | kgAAAAtzc2gtZWQyNTUxOQAAACDb7vdmJJ/WK+48XplfTFVzyvJ3IYqBZD1LO6wvuWLrPQ 5 | AAAECXwjm3gDh1mh4fd94KjuHduS/gXTiwWV2NzLKb31R90tvu92Ykn9Yr7jxemV9MVXPK 6 | 8nchioFkPUs7rC+5Yus9AAAAE2hlY2tqQFNwYXJyb3cubG9jYWwBAg== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /Tests/formicTests/Fixtures/id_ed25519.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvu92Ykn9Yr7jxemV9MVXPK8nchioFkPUs7rC+5Yus9 heckj@Sparrow.local 2 | -------------------------------------------------------------------------------- /Tests/formicTests/HostTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("Host initializer") 8 | func initHost() async throws { 9 | 10 | let host = try withDependencies { dependencyValues in 11 | dependencyValues.localSystemAccess = TestFileSystemAccess() 12 | } operation: { 13 | try RemoteHost("localhost") 14 | } 15 | 16 | #expect(host != nil) 17 | #expect(host?.networkAddress.address.description == "127.0.0.1") 18 | #expect(host?.networkAddress.dnsName == "localhost") 19 | #expect(host?.sshPort == 22) 20 | #expect(host?.sshAccessCredentials != nil) 21 | #expect(host?.sshAccessCredentials.username == "docker-user") 22 | #expect(host?.sshAccessCredentials.identityFile == "/home/docker-user/.ssh/id_dsa") 23 | } 24 | 25 | @Test("Host initializer using localhost name should be marked local") 26 | func initHostWithLocalHostName() async throws { 27 | let host = try withDependencies { dependencyValues in 28 | dependencyValues.localSystemAccess = TestFileSystemAccess() 29 | } operation: { 30 | try RemoteHost("localhost") 31 | } 32 | #expect(host != nil) 33 | } 34 | 35 | @Test("Host initializer using localhost address should be marked remote") 36 | func initHostWithLocalHostAddress() async throws { 37 | let host = try withDependencies { dependencyValues in 38 | dependencyValues.localSystemAccess = TestFileSystemAccess() 39 | } operation: { 40 | try RemoteHost("127.0.0.1") 41 | } 42 | #expect(host != nil) 43 | } 44 | -------------------------------------------------------------------------------- /Tests/formicTests/IPv4AddressTests.swift: -------------------------------------------------------------------------------- 1 | import Formic 2 | import Testing 3 | 4 | @Test("parsing ipv4 address - good address") 5 | func validAddressInit() async throws { 6 | let goodSample = "192.168.0.1" 7 | let first = RemoteHost.IPv4Address(goodSample) 8 | #expect(first?.description == goodSample) 9 | 10 | } 11 | 12 | @Test("parsing ipv4 address - invalid address") 13 | func failingAddressInit() async throws { 14 | let badSample1 = "256.0.0.1" 15 | let second = RemoteHost.IPv4Address(badSample1) 16 | #expect(second == nil) 17 | } 18 | 19 | @Test("parsing ipv4 address - localhost") 20 | func localHostValidation() async throws { 21 | #expect(RemoteHost.IPv4Address.localhost.description == "127.0.0.1") 22 | } 23 | 24 | @Test("equatable ipv4 address") 25 | func checkIPv4AddressEquatable() async throws { 26 | let first = RemoteHost.IPv4Address("127.0.0.1") 27 | let second = RemoteHost.IPv4Address("192.168.0.1") 28 | #expect(first != second) 29 | } 30 | 31 | @Test("hashable ipv4 address") 32 | func checkIPv4AddressHashable() async throws { 33 | let first = RemoteHost.IPv4Address("127.0.0.1") 34 | let second = RemoteHost.IPv4Address("192.168.0.1") 35 | #expect(first?.hashValue != second.hashValue) 36 | } 37 | -------------------------------------------------------------------------------- /Tests/formicTests/IntegrationTests/CIIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Logging 4 | import SwiftLogTesting 5 | import Testing 6 | 7 | @testable import Formic 8 | 9 | @Test( 10 | "CI INTEGRATION: Invoking a command over SSH", 11 | .enabled(if: ProcessInfo.processInfo.environment.keys.contains("CI")), 12 | .timeLimit(.minutes(1)), 13 | .tags(.integrationTest)) 14 | func invokeBasicCommandOverSSH() async throws { 15 | 16 | TestLogMessages.bootstrap() 17 | // TestLogMessages is meant to verify that the "right" things get logged out 18 | // examples of using it are in the repo for the project: 19 | // https://github.com/neallester/swift-log-testing/blob/master/Tests/SwiftLogTestingTests/ExampleTests.swift 20 | 21 | // Use set (logLevel:, forLabel:) to set the level for newly created loggers 22 | // Messages with priority below logLevel: are not placed in the TestLogMessages.Container 23 | // Does not affect behavior of existing loggers. 24 | TestLogMessages.set(logLevel: .trace, forLabel: "MyTestLabel") 25 | 26 | let logger = Logger(label: "MyTestLabel") 27 | 28 | let container = TestLogMessages.container(forLabel: "MyTestLabel") 29 | container.reset() // Wipes out any existing messages 30 | 31 | // To check this test locally, run a local SSH server in docker: 32 | // docker run --name openSSH-server -d -p 2222:2222 -e USER_NAME=fred -e PUBLIC_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvu92Ykn9Yr7jxemV9MVXPK8nchioFkPUs7rC+5Yus9 heckj@Sparrow.local' lscr.io/linuxserver/openssh-server:latest 33 | // 34 | // Then set up the relevant environment variables the CI test attempts to load: 35 | // export SSH_HOST=127.0.0.1 36 | // export SSH_PORT=2222 37 | // export SSH_USERNAME=fred 38 | // export CI=true 39 | // 40 | // Verifying SSH access on CLI: 41 | // `ssh fred@localhost -p 2222 -i Tests/formicTests/Fixtures/id_ed25519 uname -a` 42 | // 43 | // swift test --filter FormicTests.invokeBasicCommandOverSSH 44 | // 45 | // When done, tear down the container: 46 | // `docker rm -f openSSH-server` 47 | guard 48 | let hostname = ProcessInfo.processInfo.environment["SSH_HOST"] 49 | else { 50 | throw CITestError.general(msg: "MISSING ENVIRONMENT VARIABLE - SSH_HOST") 51 | } 52 | guard 53 | let _port = ProcessInfo.processInfo.environment["SSH_PORT"], 54 | let port = Int(_port) 55 | else { 56 | throw CITestError.general(msg: "MISSING ENVIRONMENT VARIABLE - SSH_PORT") 57 | } 58 | guard 59 | let username = ProcessInfo.processInfo.environment["SSH_USERNAME"] 60 | else { 61 | throw CITestError.general(msg: "MISSING ENVIRONMENT VARIABLE - SSH_USERNAME") 62 | } 63 | 64 | let host: Formic.RemoteHost? = try await withDependencies { dependencyValues in 65 | dependencyValues.localSystemAccess = LiveLocalSystemAccess() 66 | } operation: { 67 | try await Formic.RemoteHost.resolve( 68 | hostname, sshPort: port, sshUser: username, sshIdentityFile: "Tests/formicTests/Fixtures/id_ed25519", 69 | strictHostKeyChecking: false) 70 | } 71 | let explicitHost: Formic.RemoteHost = try #require(host) 72 | 73 | let output: CommandOutput = try await withDependencies { dependencyValues in 74 | dependencyValues.commandInvoker = ProcessCommandInvoker() 75 | } operation: { 76 | try await ShellCommand("uname -a").run(host: explicitHost, logger: logger) 77 | } 78 | 79 | print("===TEST DEBUGGING===") 80 | print("host: \(explicitHost.debugDescription)") 81 | print("rc: \(output.returnCode)") 82 | print("out: \(output.stdoutString ?? "nil")") 83 | print("err: \(output.stderrString ?? "nil")") 84 | print("log container messages:") 85 | container.print() 86 | print("===TEST DEBUGGING===") 87 | 88 | // results expected on a Linux host only 89 | #expect(output.returnCode == 0) 90 | let stdout = try #require(output.stdoutString) 91 | #expect(stdout.contains("Linux")) 92 | } 93 | 94 | //@Test( 95 | // "CI INTEGRATION: Invoking a command using Citadel SSH", 96 | // .enabled(if: ProcessInfo.processInfo.environment.keys.contains("CI")), 97 | // .timeLimit(.minutes(1)), 98 | // .tags(.integrationTest)) 99 | //func invokeBasicCommandOverCitadelSSH() async throws { 100 | // 101 | // TestLogMessages.bootstrap() 102 | // // TestLogMessages is meant to verify that the "right" things get logged out 103 | // // examples of using it are in the repo for the project: 104 | // // https://github.com/neallester/swift-log-testing/blob/master/Tests/SwiftLogTestingTests/ExampleTests.swift 105 | // 106 | // // Use set (logLevel:, forLabel:) to set the level for newly created loggers 107 | // // Messages with priority below logLevel: are not placed in the TestLogMessages.Container 108 | // // Does not affect behavior of existing loggers. 109 | // TestLogMessages.set(logLevel: .trace, forLabel: "MyTestLabel") 110 | // 111 | // let logger = Logger(label: "MyTestLabel") 112 | // 113 | // let container = TestLogMessages.container(forLabel: "MyTestLabel") 114 | // container.reset() // Wipes out any existing messages 115 | // 116 | // // To check this test locally, run a local SSH server in docker: 117 | // // docker run --name openSSH-server -d -p 2222:2222 -e USER_NAME=fred -e PUBLIC_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvu92Ykn9Yr7jxemV9MVXPK8nchioFkPUs7rC+5Yus9 heckj@Sparrow.local' lscr.io/linuxserver/openssh-server:latest 118 | // // 119 | // // Then set up the relevant environment variables the CI test attempts to load: 120 | // // export SSH_HOST=127.0.0.1 121 | // // export SSH_PORT=2222 122 | // // export SSH_USERNAME=fred 123 | // // export CI=true 124 | // // 125 | // // Verifying SSH access on CLI: 126 | // // `chmod 600 Tests/formicTests/Fixtures/id_ed25519` // make sure permissions are secure 127 | // // `ssh fred@localhost -p 2222 -i Tests/formicTests/Fixtures/id_ed25519` 128 | // // 129 | // // swift test --filter FormicTests.invokeBasicCommandOverCitadelSSH 130 | // // 131 | // // When done, tear down the container: 132 | // // `docker rm -f openSSH-server` 133 | // guard 134 | // let hostname = ProcessInfo.processInfo.environment["SSH_HOST"] 135 | // else { 136 | // throw CITestError.general(msg: "MISSING ENVIRONMENT VARIABLE - SSH_HOST") 137 | // } 138 | // guard 139 | // let _port = ProcessInfo.processInfo.environment["SSH_PORT"], 140 | // let port = Int(_port) 141 | // else { 142 | // throw CITestError.general(msg: "MISSING ENVIRONMENT VARIABLE - SSH_PORT") 143 | // } 144 | // guard 145 | // let username = ProcessInfo.processInfo.environment["SSH_USERNAME"] 146 | // else { 147 | // throw CITestError.general(msg: "MISSING ENVIRONMENT VARIABLE - SSH_USERNAME") 148 | // } 149 | // 150 | // let host: Formic.Host? = try await withDependencies { dependencyValues in 151 | // dependencyValues.localSystemAccess = LiveLocalSystemAccess() 152 | // } operation: { 153 | // try await Formic.Host.resolve( 154 | // hostname, sshPort: port, sshUser: username, sshIdentityFile: "Tests/formicTests/Fixtures/id_ed25519", 155 | // strictHostKeyChecking: false) 156 | // } 157 | // let explicitHost: Formic.Host = try #require(host) 158 | // 159 | // let output: CommandOutput = try await SSHCommand("uname").run(host: explicitHost, logger: logger) 160 | // 161 | // print("===TEST DEBUGGING===") 162 | // print("\(explicitHost.debugDescription)") 163 | // print("rc: \(output.returnCode)") 164 | // print("out: \(output.stdoutString ?? "nil")") 165 | // print("err: \(output.stderrString ?? "nil")") 166 | // print("log container messages:") 167 | // container.print() 168 | // print("===TEST DEBUGGING===") 169 | // 170 | // // results expected on a Linux host only 171 | // #expect(output.returnCode == 0) 172 | // let stdout = try #require(output.stdoutString) 173 | // #expect(stdout.contains("Linux")) 174 | //} 175 | -------------------------------------------------------------------------------- /Tests/formicTests/NetworkAddressTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Testing 3 | 4 | @testable import Formic 5 | 6 | @Test("initializing network address") 7 | func validIPv4AddressAsStringInit() async throws { 8 | let goodSample = "192.168.0.1" 9 | 10 | // parsing path, checks IP v4 pattern first 11 | let first = RemoteHost.NetworkAddress(goodSample) 12 | #expect(first?.address.description == goodSample) 13 | } 14 | 15 | @Test("initializing network address - optionally valid IPv4 address") 16 | func validOptionalIPv4AddressInit() async throws { 17 | 18 | let goodSample = "192.168.0.1" 19 | 20 | // optional IPv4Address 21 | let third = RemoteHost.NetworkAddress(RemoteHost.IPv4Address(goodSample)) 22 | #expect(third?.address.description == goodSample) 23 | } 24 | 25 | @Test("initializing network address - fully valid IPv4 address") 26 | func validIPv4AddressInit() async throws { 27 | 28 | let goodSample = "192.168.0.1" 29 | 30 | // fully valid IPv4Address 31 | guard let validIPAddress = RemoteHost.IPv4Address(goodSample) else { 32 | Issue.record("\(goodSample) is not a valid IP address") 33 | return 34 | } 35 | let fourth = RemoteHost.NetworkAddress(validIPAddress) 36 | #expect(fourth.address.description == goodSample) 37 | } 38 | 39 | @Test( 40 | "failing initializing network address - invalid optional IPv4 address", 41 | .timeLimit(.minutes(1)), 42 | .tags(.functionalTest)) 43 | func nilOptionalIPv4Address() async throws { 44 | let invalid: RemoteHost.IPv4Address? = nil 45 | 46 | let result = RemoteHost.NetworkAddress(invalid) 47 | #expect(result == nil) 48 | } 49 | 50 | @Test("initializing network address - dns resolution") 51 | func initNetworkAddress4() async throws { 52 | 53 | let validDNSName = "google.com" 54 | 55 | let goodName = await withDependencies { dependencyValues in 56 | dependencyValues.localSystemAccess = TestFileSystemAccess(dnsName: validDNSName, ipAddressesToUse: ["8.8.8.8"]) 57 | } operation: { 58 | await RemoteHost.NetworkAddress.resolve(validDNSName) 59 | } 60 | 61 | #expect(goodName?.dnsName == validDNSName) 62 | } 63 | 64 | @Test("failing initializing network address - invalid DNS name") 65 | func invalidDNSNameResolution() async throws { 66 | 67 | let invalidDNSName = "indescribable.wurplefred" 68 | 69 | let badName = await withDependencies { dependencyValues in 70 | dependencyValues.localSystemAccess = TestFileSystemAccess() 71 | } operation: { 72 | await RemoteHost.NetworkAddress.resolve(invalidDNSName) 73 | } 74 | 75 | #expect(badName == nil) 76 | } 77 | 78 | @Test("failing resolve network address - nil") 79 | func nilNameResolve() async throws { 80 | let badName = await RemoteHost.NetworkAddress.resolve(nil) 81 | #expect(badName == nil) 82 | } 83 | 84 | @Test("failing initializing network address - invalid IPv4 address format") 85 | func invalidIPAddressResolver() async throws { 86 | let badSample1 = "256.0.0.1" 87 | 88 | // parsing path, checks IP v4 pattern first, but bad IP address is an invalid address 89 | let second = await withDependencies { dependencyValues in 90 | dependencyValues.localSystemAccess = TestFileSystemAccess() 91 | } operation: { 92 | await RemoteHost.NetworkAddress.resolve(badSample1) 93 | } 94 | 95 | #expect(second == nil) 96 | } 97 | 98 | @Test("localhost network address") 99 | func localhostNetworkAddressByStaticVar() async throws { 100 | #expect(RemoteHost.NetworkAddress.localhost.address.description == "127.0.0.1") 101 | #expect(RemoteHost.NetworkAddress.localhost.dnsName == "localhost") 102 | } 103 | 104 | @Test("localhost network address name") 105 | func localhostNetworkAddressByName() async throws { 106 | let example = RemoteHost.NetworkAddress("localhost") 107 | #expect(example?.address.description == "127.0.0.1") 108 | #expect(example?.dnsName == "localhost") 109 | } 110 | 111 | @Test("localhost network address name by address") 112 | func localhostNetworkAddressByAddress() async throws { 113 | let example = RemoteHost.NetworkAddress("127.0.0.1") 114 | #expect(example?.address.description == "127.0.0.1") 115 | #expect(example?.dnsName == nil) 116 | } 117 | -------------------------------------------------------------------------------- /Tests/formicTests/Resources/OperatingSystemTests.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("parse a string to determine type of operating system") 8 | func testOperatingSystemKindParser() async throws { 9 | let parser = OperatingSystem.UnameParser() 10 | #expect(try parser.parse("Linux\n") == .linux) 11 | #expect(try parser.parse("Darwin\n") == .macOS) 12 | #expect(try parser.parse("Linux") == .linux) 13 | #expect(try parser.parse("Darwin") == .macOS) 14 | #expect(try parser.parse("macOS") == .macOS) 15 | #expect(try parser.parse("linux") == .linux) 16 | 17 | #expect( 18 | throws: (any Error).self, 19 | performing: { 20 | try parser.parse("FreeBSD") 21 | }) 22 | } 23 | 24 | @Test("verify string based initializer for OperatingSystem") 25 | func testOSStringInitializer() async throws { 26 | #expect(OperatingSystem("linux").name == .linux) 27 | #expect(OperatingSystem("").name == .unknown) 28 | } 29 | 30 | @Test("verify the OperatingSystem.singularInquiry(_:String) function") 31 | func testOperatingSystemSingularInquiry() async throws { 32 | 33 | let localhost: RemoteHost = try withDependencies { 34 | $0.localSystemAccess = TestFileSystemAccess() 35 | } operation: { 36 | try RemoteHost(RemoteHost.NetworkAddress.localhost) 37 | } 38 | 39 | let shellResult: CommandOutput = try await withDependencies { 40 | $0.commandInvoker = TestCommandInvoker() 41 | .addSuccess(command: "uname", presentOutput: "Linux\n") 42 | } operation: { 43 | try await OperatingSystem.inquiry.run(host: localhost, logger: nil) 44 | } 45 | 46 | // results proxied for a linux host 47 | #expect(shellResult.returnCode == 0) 48 | #expect(shellResult.stdoutString == "Linux\n") 49 | #expect(shellResult.stderrString == nil) 50 | } 51 | 52 | @Test("verify the OperatingSystem.parse(_:String) function") 53 | func testOperatingSystemParse() async throws { 54 | let dataToParse: Data = try #require("Linux\n".data(using: .utf8)) 55 | #expect(OperatingSystem.parse(dataToParse)?.name == .linux) 56 | } 57 | 58 | @Test("test singular findResource for operating system") 59 | func testOperatingSystemQuery() async throws { 60 | 61 | let localhost: RemoteHost = try withDependencies { 62 | $0.localSystemAccess = TestFileSystemAccess() 63 | } operation: { 64 | try RemoteHost(RemoteHost.NetworkAddress.localhost) 65 | } 66 | 67 | let (parsedOS, _) = try await withDependencies { 68 | $0.commandInvoker = TestCommandInvoker() 69 | .addSuccess(command: "uname", presentOutput: "Linux\n") 70 | 71 | $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) 72 | } operation: { 73 | try await OperatingSystem.query(from: localhost, logger: nil) 74 | } 75 | 76 | let os = try #require(parsedOS) 77 | #expect(os.name == .linux) 78 | } 79 | 80 | @Test("test instance queryResource for operating system") 81 | func testOperatingSystemInstanceQuery() async throws { 82 | 83 | let instance = OperatingSystem(.macOS) 84 | 85 | let localhost: RemoteHost = try withDependencies { 86 | $0.localSystemAccess = TestFileSystemAccess() 87 | } operation: { 88 | try RemoteHost(RemoteHost.NetworkAddress.localhost) 89 | } 90 | 91 | let (parsedOS, _) = try await withDependencies { 92 | $0.commandInvoker = TestCommandInvoker() 93 | .addSuccess(command: "uname", presentOutput: "Linux\n") 94 | $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) 95 | } operation: { 96 | try await instance.query(from: localhost, logger: nil) 97 | } 98 | 99 | let os = try #require(parsedOS) 100 | #expect(os.name == .linux) 101 | } 102 | -------------------------------------------------------------------------------- /Tests/formicTests/Resources/PackagesTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Parsing // temp while I work out how to use the parser 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | let bigSample = """ 8 | Desired=Unknown/Install/Remove/Purge/Hold 9 | | Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend 10 | |/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) 11 | ||/ Name Version Architecture Description 12 | +++-=============================-=================================-============-===================================================================== 13 | ii adduser 3.137ubuntu1 all add and remove users and groups 14 | ii apparmor 4.0.1really4.0.1-0ubuntu0.24.04.3 arm64 user-space parser utility for AppArmor 15 | ii apt 2.7.14build2 arm64 commandline package manager 16 | ii apt-utils 2.7.14build2 arm64 package management related utility programs 17 | ii base-files 13ubuntu10.1 arm64 Debian base system miscellaneous files 18 | ii base-passwd 3.6.3build1 arm64 Debian base system master password and group files 19 | ii bash 5.2.21-2ubuntu4 arm64 GNU Bourne Again SHell 20 | ii bsdutils 1:2.39.3-9ubuntu6.1 arm64 basic utilities from 4.4BSD-Lite 21 | ii ca-certificates 20240203 all Common CA certificates 22 | ii console-setup 1.226ubuntu1 all console font and keymap setup program 23 | ii console-setup-linux 1.226ubuntu1 all Linux specific part of console-setup 24 | ii containerd.io 1.7.24-1 arm64 An open and reliable container runtime 25 | ii coreutils 9.4-3ubuntu6 arm64 GNU core utilities 26 | ii cron 3.0pl1-184ubuntu2 arm64 process scheduling daemon 27 | ii cron-daemon-common 3.0pl1-184ubuntu2 all process scheduling daemon's configuration files 28 | ii curl 8.5.0-2ubuntu10.5 arm64 command line tool for transferring data with URL syntax 29 | ii dash 0.5.12-6ubuntu5 arm64 POSIX-compliant shell 30 | ii dbus 1.14.10-4ubuntu4.1 arm64 simple interprocess messaging system (system message bus) 31 | ii dbus-bin 1.14.10-4ubuntu4.1 arm64 simple interprocess messaging system (command line utilities) 32 | """ 33 | 34 | @Test("package parsing - one line") 35 | func verifyParsingOneLine() async throws { 36 | let sample = 37 | "ii apt 2.7.14build2 arm64 commandline package manager" 38 | let result = try Dpkg.PackageStatus().parse(sample) 39 | //print(result) 40 | #expect(result.name == "apt") 41 | #expect(result.version == "2.7.14build2") 42 | #expect(result.architecture == "arm64") 43 | #expect(result.description == "commandline package manager") 44 | } 45 | 46 | @Test("matching the header") 47 | func verifyHeaderParse() async throws { 48 | let headerSample = """ 49 | Desired=Unknown/Install/Remove/Purge/Hold 50 | | Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend 51 | |/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) 52 | ||/ Name Version Architecture Description 53 | +++-=============================-=================================-============-===================================================================== 54 | what? 55 | """ 56 | var x: Substring = headerSample[...] 57 | try Dpkg.DpkgHeader().parse(&x) 58 | #expect(x == "what?") 59 | } 60 | 61 | @Test("parse single package - unknown") 62 | func verifyParsingUnknownSinglePackage() async throws { 63 | let sampleOutput = """ 64 | Desired=Unknown/Install/Remove/Purge/Hold 65 | | Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend 66 | |/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) 67 | ||/ Name Version Architecture Description 68 | +++-==============-============-============-================================= 69 | un docker (no description available) 70 | """ 71 | 72 | let result: [Dpkg] = try Dpkg.PackageList().parse(sampleOutput) 73 | //print(result) 74 | #expect(result.count == 1) 75 | } 76 | 77 | @Test("parse single package - known") 78 | func verifyParsingKnownSinglePackage() async throws { 79 | 80 | let sampleOutput = """ 81 | Desired=Unknown/Install/Remove/Purge/Hold 82 | | Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend 83 | |/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) 84 | ||/ Name Version Architecture Description 85 | +++-==============-=============================-============-==================================================== 86 | ii docker-ce 5:27.4.0-1~ubuntu.24.04~noble arm64 Docker: the open-source application container engine 87 | """ 88 | 89 | let result: [Dpkg] = try Dpkg.PackageList().parse(sampleOutput) 90 | //print(result) 91 | #expect(result.count == 1) 92 | } 93 | 94 | @Test("package parsing - dpkg output") 95 | func verifyParsingMultilineOutputString() async throws { 96 | let result: [Dpkg] = try Dpkg.PackageList().parse(bigSample) 97 | //print(result) 98 | #expect(result.count == 19) 99 | } 100 | -------------------------------------------------------------------------------- /Tests/formicTests/Resources/SwarmInitTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Parsing // temp while I work out how to use the parser 3 | import Testing 4 | 5 | @testable import Formic 6 | 7 | @Test("swarm init output parsing") 8 | func verifyParsingSwarmInitIntoWorkerCommand() async throws { 9 | let sample = """ 10 | Swarm initialized: current node (dl490ag3crgutgvzq91id7wo1) is now a manager. 11 | 12 | To add a worker to this swarm, run the following command: 13 | 14 | docker swarm join --token SWMTKN-1-4co3ccnbcdrww0iq7f9te0478286pd168bhzfx9oyc1wyws0vi-1p35bj1i57s9h9dpf57mmeqq0 198.19.249.61:2377 15 | 16 | To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions. 17 | """ 18 | let cmd = try SwarmJoinCommand().parse(sample) 19 | 20 | #expect( 21 | cmd.commandString.contains( 22 | "SWMTKN-1-4co3ccnbcdrww0iq7f9te0478286pd168bhzfx9oyc1wyws0vi-1p35bj1i57s9h9dpf57mmeqq0")) 23 | } 24 | 25 | @Test("docker swarm join-token worker parsing") 26 | func verifyParsingSwarmJoinTokenWorkerCommand() async throws { 27 | let sample = """ 28 | To add a worker to this swarm, run the following command: 29 | 30 | docker swarm join --token SWMTKN-1-4co3ccnbcdrww0iq7f9te0478286pd168bhzfx9oyc1wyws0vi-1p35bj1i57s9h9dpf57mmeqq0 198.19.249.61:2377 31 | """ 32 | let cmd = try SwarmJoinCommand().parse(sample) 33 | 34 | // #expect(cmd.args.count == 6) 35 | // #expect(cmd.args[4] == "SWMTKN-1-4co3ccnbcdrww0iq7f9te0478286pd168bhzfx9oyc1wyws0vi-1p35bj1i57s9h9dpf57mmeqq0") 36 | // #expect(cmd.args[5] == "198.19.249.61:2377") 37 | #expect( 38 | cmd.commandString.contains( 39 | "SWMTKN-1-4co3ccnbcdrww0iq7f9te0478286pd168bhzfx9oyc1wyws0vi-1p35bj1i57s9h9dpf57mmeqq0")) 40 | #expect(cmd.commandString.contains("198.19.249.61:2377")) 41 | 42 | } 43 | 44 | @Test("checking slicing") 45 | func checkSlicing() async throws { 46 | let hosts = ["host1", "host2", "host3", "host4"] 47 | let masters = Array(hosts[0...0]) 48 | let workers = Array(hosts[1...]) 49 | 50 | #expect(masters.count == 1) 51 | #expect(workers.count == 3) 52 | } 53 | -------------------------------------------------------------------------------- /Tests/formicTests/SSHAccessCredentialsTests.swift: -------------------------------------------------------------------------------- 1 | import AsyncDNSResolver 2 | import Dependencies 3 | import Foundation 4 | import Testing 5 | 6 | @testable import Formic 7 | 8 | @Test("initializing asserted network credentials") 9 | func validSSHCredentials() async throws { 10 | let assertedCredentials = RemoteHost.SSHAccessCredentials(username: "docker-user", identityFile: "~/.ssh/id_rsa") 11 | 12 | #expect(assertedCredentials.username == "docker-user") 13 | #expect(assertedCredentials.identityFile == "~/.ssh/id_rsa") 14 | } 15 | 16 | @Test("default home directory check") 17 | func homeDirDependencyOverride() async throws { 18 | // Dependency injection docs: 19 | // https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies 20 | let testCredentials: Formic.RemoteHost.SSHAccessCredentials = try withDependencies { dependencyValues in 21 | dependencyValues.localSystemAccess = TestFileSystemAccess(sshIdMatch: .rsa) 22 | } operation: { 23 | try RemoteHost.SSHAccessCredentials() 24 | } 25 | 26 | try #require(testCredentials != nil) 27 | #expect(testCredentials.username == "docker-user") 28 | #expect(testCredentials.identityFile == "/home/docker-user/.ssh/id_rsa") 29 | } 30 | 31 | @Test("default home directory w/ dsa id") 32 | func homeDirDependencyOverrideDSA() async throws { 33 | 34 | let testCredentials: Formic.RemoteHost.SSHAccessCredentials? = try withDependencies { dependencyValues in 35 | dependencyValues.localSystemAccess = TestFileSystemAccess(sshIdMatch: .dsa) 36 | } operation: { 37 | try RemoteHost.SSHAccessCredentials() 38 | } 39 | 40 | try #require(testCredentials != nil) 41 | #expect(testCredentials?.username == "docker-user") 42 | #expect(testCredentials?.identityFile == "/home/docker-user/.ssh/id_dsa") 43 | } 44 | 45 | @Test("default home directory w/ ed25519 id") 46 | func homeDirDependencyOverrideED25519() async throws { 47 | // Dependency injection docs: 48 | // https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies 49 | let testCredentials: Formic.RemoteHost.SSHAccessCredentials = try withDependencies { dependencyValues in 50 | dependencyValues.localSystemAccess = TestFileSystemAccess(sshIdMatch: .ed25519) 51 | } operation: { 52 | try RemoteHost.SSHAccessCredentials() 53 | } 54 | 55 | try #require(testCredentials != nil) 56 | #expect(testCredentials.username == "docker-user") 57 | #expect(testCredentials.identityFile == "/home/docker-user/.ssh/id_ed25519") 58 | } 59 | -------------------------------------------------------------------------------- /Tests/formicTests/TestDependencies.swift: -------------------------------------------------------------------------------- 1 | import AsyncDNSResolver 2 | import Dependencies 3 | import Foundation 4 | import Logging 5 | 6 | @testable import Formic 7 | 8 | struct TestCommandInvoker: CommandInvoker { 9 | func getDataAtURL(url: URL, logger: Logger?) async throws -> Data { 10 | if let data = proxyData[url] { 11 | return data 12 | } else { 13 | guard let sampleData = "SAMPLE".data(using: .utf8) else { 14 | fatalError("Some whack error converting a string into data.") 15 | } 16 | return sampleData 17 | } 18 | } 19 | 20 | // proxyResults is keyed by arguments, returns a tuple of seconds delay to apply, then the result 21 | var proxyResults: [String: (Duration, CommandOutput)] 22 | var proxyErrors: [String: (any Error)] 23 | var proxyData: [URL: Data] 24 | 25 | func remoteCopy( 26 | host: String, user: String, identityFile: String?, port: Int?, strictHostKeyChecking: Bool, 27 | localPath: String, 28 | remotePath: String, 29 | logger: Logger? 30 | ) async throws -> Formic.CommandOutput { 31 | if let errorToThrow = proxyErrors["\(localPath) \(remotePath)"] { 32 | throw errorToThrow 33 | } 34 | if let (delay, storedResponse) = proxyResults["\(localPath) \(remotePath)"] { 35 | try await Task.sleep(for: delay) 36 | return storedResponse 37 | } 38 | return CommandOutput(returnCode: 0, stdOut: "".data(using: .utf8), stdErr: nil) 39 | } 40 | 41 | func remoteShell( 42 | host: String, user: String, identityFile: String?, port: Int?, strictHostKeyChecking: Bool, 43 | cmd: String, env: [String: String]?, logger: Logger? 44 | ) async throws -> Formic.CommandOutput { 45 | if let errorToThrow = proxyErrors[cmd] { 46 | throw errorToThrow 47 | } 48 | if let (delay, storedResponse) = proxyResults[cmd] { 49 | try await Task.sleep(for: delay) 50 | return storedResponse 51 | } 52 | // default to a null, success response 53 | return CommandOutput(returnCode: 0, stdOut: "".data(using: .utf8), stdErr: nil) 54 | } 55 | 56 | func localShell(cmd: [String], stdIn: Pipe?, env: [String: String]?, logger: Logger?) async throws 57 | -> Formic.CommandOutput 58 | { 59 | let hashKey = cmd.joined(separator: " ") 60 | if let errorToThrow = proxyErrors[hashKey] { 61 | throw errorToThrow 62 | } 63 | if let (delay, storedResponse) = proxyResults[hashKey] { 64 | try await Task.sleep(for: delay) 65 | return storedResponse 66 | } 67 | // default to a null, success response 68 | return CommandOutput(returnCode: 0, stdOut: "".data(using: .utf8), stdErr: nil) 69 | } 70 | 71 | init( 72 | _ outputs: [String: (Duration, CommandOutput)], 73 | _ errors: [String: (any Error)], 74 | _ data: [URL: Data] 75 | ) { 76 | proxyResults = outputs 77 | proxyErrors = errors 78 | proxyData = data 79 | } 80 | 81 | init() { 82 | proxyResults = [:] 83 | proxyErrors = [:] 84 | proxyData = [:] 85 | } 86 | 87 | func addSuccess(command: String, presentOutput: String, delay: Duration = .zero) -> Self { 88 | var existingResult = proxyResults 89 | existingResult[command] = ( 90 | delay, 91 | CommandOutput( 92 | returnCode: 0, 93 | stdOut: presentOutput.data(using: .utf8), 94 | stdErr: nil) 95 | ) 96 | return TestCommandInvoker(existingResult, proxyErrors, proxyData) 97 | } 98 | 99 | func addFailure(command: String, presentOutput: String, delay: Duration = .zero, returnCode: Int32 = -1) -> Self { 100 | var existingResult = proxyResults 101 | existingResult[command] = ( 102 | delay, 103 | CommandOutput( 104 | returnCode: returnCode, 105 | stdOut: nil, 106 | stdErr: 107 | presentOutput.data(using: .utf8)) 108 | ) 109 | return TestCommandInvoker(existingResult, proxyErrors, proxyData) 110 | } 111 | 112 | func addException(command: String, errorToThrow: (any Error)) -> Self { 113 | var existingErrors = proxyErrors 114 | existingErrors[command] = errorToThrow 115 | return TestCommandInvoker(proxyResults, existingErrors, proxyData) 116 | } 117 | 118 | func addData(url: URL, data: Data?) -> Self { 119 | guard let data = data else { 120 | return self 121 | } 122 | var existingData = proxyData 123 | existingData[url] = data 124 | return TestCommandInvoker(proxyResults, proxyErrors, existingData) 125 | } 126 | 127 | } 128 | 129 | struct TestFileSystemAccess: LocalSystemAccess { 130 | 131 | enum SSHId { 132 | case rsa 133 | case dsa 134 | case ed25519 135 | } 136 | let sshIDToMatch: SSHId 137 | let mockDNSresolution: [String: [String]] 138 | 139 | func fileExists(atPath: String) -> Bool { 140 | switch sshIDToMatch { 141 | case .rsa: 142 | atPath.contains("id_rsa") 143 | case .dsa: 144 | atPath.contains("id_dsa") 145 | case .ed25519: 146 | atPath.contains("id_ed25519") 147 | } 148 | } 149 | let homeDirectory: URL = URL(filePath: "/home/docker-user") 150 | let username: String? = "docker-user" 151 | func queryA(name: String) async throws -> [ARecord] { 152 | if let returnValues = mockDNSresolution[name] { 153 | return returnValues.map { ARecord(address: .init(address: $0), ttl: 999) } 154 | } else { 155 | return [] 156 | } 157 | } 158 | 159 | init(sshIdMatch: SSHId = .dsa) { 160 | sshIDToMatch = sshIdMatch 161 | mockDNSresolution = [:] 162 | } 163 | 164 | init(dnsName: String, ipAddressesToUse: [String], sshIdMatch: SSHId = .dsa) { 165 | mockDNSresolution = [dnsName: ipAddressesToUse] 166 | sshIDToMatch = sshIdMatch 167 | } 168 | } 169 | 170 | /// An error for testing error handling. 171 | public enum TestError: LocalizedError { 172 | case unknown(msg: String) 173 | 174 | /// The localized description. 175 | public var errorDescription: String? { 176 | switch self { 177 | case .unknown(let msg): 178 | "Unknown error: \(msg)" 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Tests/formicTests/TestError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Errors from Integration and Functional Tests in Continuous Integration. 4 | public enum CITestError: LocalizedError { 5 | /// A general error from a test setup or execution. 6 | case general(msg: String) 7 | 8 | /// The description of the error. 9 | public var errorDescription: String? { 10 | switch self { 11 | case .general(let msg): 12 | "general error: \(msg)" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/formicTests/TestTags.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | extension Tag { 4 | @Tag static var functionalTest: Self 5 | @Tag static var integrationTest: Self 6 | } 7 | -------------------------------------------------------------------------------- /examples/updateExample/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /examples/updateExample/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "lldb", 5 | "request": "launch", 6 | "args": [], 7 | "cwd": "${workspaceFolder:updateExample}", 8 | "name": "Debug updateExample", 9 | "program": "${workspaceFolder:updateExample}/.build/debug/updateExample", 10 | "preLaunchTask": "swift: Build Debug updateExample" 11 | }, 12 | { 13 | "type": "lldb", 14 | "request": "launch", 15 | "args": [], 16 | "cwd": "${workspaceFolder:updateExample}", 17 | "name": "Release updateExample", 18 | "program": "${workspaceFolder:updateExample}/.build/release/updateExample", 19 | "preLaunchTask": "swift: Build Release updateExample" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /examples/updateExample/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "29a090a14db8e5d1dbcd62ae163ca88c38514b0d281852eaa2de19c001393261", 3 | "pins" : [ 4 | { 5 | "identity" : "bigint", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/attaswift/BigInt.git", 8 | "state" : { 9 | "revision" : "114343a705df4725dfe7ab8a2a326b8883cfd79c", 10 | "version" : "5.5.1" 11 | } 12 | }, 13 | { 14 | "identity" : "citadel", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/orlandos-nl/Citadel/", 17 | "state" : { 18 | "revision" : "be1a5bc51b29d64e89a223a755159b8a8c678f47", 19 | "version" : "0.9.2" 20 | } 21 | }, 22 | { 23 | "identity" : "colorizeswift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/mtynior/ColorizeSwift.git", 26 | "state" : { 27 | "revision" : "4e7daa138510b77a3cce9f6a31a116f8536347dd", 28 | "version" : "1.7.0" 29 | } 30 | }, 31 | { 32 | "identity" : "combine-schedulers", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/combine-schedulers", 35 | "state" : { 36 | "revision" : "5928286acce13def418ec36d05a001a9641086f2", 37 | "version" : "1.0.3" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-argument-parser", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-argument-parser.git", 44 | "state" : { 45 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 46 | "version" : "1.5.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-async-dns-resolver", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-async-dns-resolver", 53 | "state" : { 54 | "revision" : "08c07ff31a745ee5e522ac10132fb4949834d925", 55 | "version" : "0.4.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-atomics", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-atomics.git", 62 | "state" : { 63 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 64 | "version" : "1.2.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-case-paths", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/pointfreeco/swift-case-paths", 71 | "state" : { 72 | "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", 73 | "version" : "1.6.1" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-clocks", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/pointfreeco/swift-clocks", 80 | "state" : { 81 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", 82 | "version" : "1.0.6" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-cmark", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/apple/swift-cmark.git", 89 | "state" : { 90 | "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", 91 | "version" : "0.5.0" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-collections", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-collections.git", 98 | "state" : { 99 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 100 | "version" : "1.1.4" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-concurrency-extras", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 107 | "state" : { 108 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", 109 | "version" : "1.3.1" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-crypto", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/apple/swift-crypto.git", 116 | "state" : { 117 | "revision" : "067254c79435de759aeef4a6a03e43d087d61312", 118 | "version" : "2.0.5" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-dependencies", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/pointfreeco/swift-dependencies", 125 | "state" : { 126 | "revision" : "52b5e1a09dc016e64ce253e19ab3124b7fae9ac9", 127 | "version" : "1.7.0" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-format", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/swiftlang/swift-format.git", 134 | "state" : { 135 | "revision" : "65f9da9aad84adb7e2028eb32ca95164aa590e3b", 136 | "version" : "600.0.0" 137 | } 138 | }, 139 | { 140 | "identity" : "swift-log", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/apple/swift-log.git", 143 | "state" : { 144 | "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", 145 | "version" : "1.6.2" 146 | } 147 | }, 148 | { 149 | "identity" : "swift-markdown", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/apple/swift-markdown.git", 152 | "state" : { 153 | "revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993", 154 | "version" : "0.5.0" 155 | } 156 | }, 157 | { 158 | "identity" : "swift-nio", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/apple/swift-nio.git", 161 | "state" : { 162 | "revision" : "c51907a839e63ebf0ba2076bba73dd96436bd1b9", 163 | "version" : "2.81.0" 164 | } 165 | }, 166 | { 167 | "identity" : "swift-nio-ssh", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/Joannis/swift-nio-ssh.git", 170 | "state" : { 171 | "revision" : "0b3992e7acfdbf765aecd5e20bd8831d33d42453", 172 | "version" : "0.3.3" 173 | } 174 | }, 175 | { 176 | "identity" : "swift-parsing", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/pointfreeco/swift-parsing", 179 | "state" : { 180 | "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b", 181 | "version" : "0.14.1" 182 | } 183 | }, 184 | { 185 | "identity" : "swift-syntax", 186 | "kind" : "remoteSourceControl", 187 | "location" : "https://github.com/swiftlang/swift-syntax", 188 | "state" : { 189 | "revision" : "0687f71944021d616d34d922343dcef086855920", 190 | "version" : "600.0.1" 191 | } 192 | }, 193 | { 194 | "identity" : "swift-system", 195 | "kind" : "remoteSourceControl", 196 | "location" : "https://github.com/apple/swift-system.git", 197 | "state" : { 198 | "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", 199 | "version" : "1.4.0" 200 | } 201 | }, 202 | { 203 | "identity" : "xctest-dynamic-overlay", 204 | "kind" : "remoteSourceControl", 205 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 206 | "state" : { 207 | "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938", 208 | "version" : "1.5.1" 209 | } 210 | } 211 | ], 212 | "version" : 3 213 | } 214 | -------------------------------------------------------------------------------- /examples/updateExample/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "updateExample", 7 | platforms: [.macOS(.v13)], 8 | dependencies: [ 9 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), 10 | .package(path: "../.."), //.package(url: "https://github.com/heckj/formic.git", branch: "main"), 11 | // enough to support the following commands: 12 | // swift package format-source-code --allow-writing-to-package-directory 13 | // swift package lint-source-code 14 | .package(url: "https://github.com/swiftlang/swift-format.git", 15 | .upToNextMajor(from: "600.0.0")), 16 | ], 17 | targets: [ 18 | .executableTarget( 19 | name: "updateExample", 20 | dependencies: [ 21 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 22 | .product(name: "Formic", package: "Formic"), 23 | ] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /examples/updateExample/Sources/updateExample.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Formic 3 | import Foundation 4 | import Logging 5 | 6 | // example: 7 | // swift run updateExample -v 172.174.57.17 /Users/heckj/.ssh/bastion_id_ed25519 8 | 9 | @main 10 | struct configureBastion: AsyncParsableCommand { 11 | @Option(name: .shortAndLong, help: "The user to connect as") var user: String = "docker-user" 12 | @Option(name: .shortAndLong, help: "The port to connect through") var port: Int = 22 13 | @Argument(help: "the hostname or IP address of the host to update") var hostname: String 14 | @Argument(help: "The path to the private SSH key to copy into bastion") var privateKeyLocation: String 15 | @Flag(name: .shortAndLong, help: "Run in verbose mode") var verbose: Bool = false 16 | 17 | mutating func run() async throws { 18 | var logger = Logger(label: "updateExample") 19 | logger.logLevel = verbose ? .trace : .info 20 | logger.info("Starting update for \(hostname) as \(user) on port \(port)") 21 | 22 | let engine = Engine(logger: logger) 23 | // per https://wiki.debian.org/Multistrap/Environment 24 | let debUnattended = ["DEBIAN_FRONTEND": "noninteractive", "DEBCONF_NONINTERACTIVE_SEEN": "true"] 25 | 26 | guard let hostAddress = RemoteHost.NetworkAddress(hostname) else { 27 | fatalError("Unable to parse the provided host address: \(hostname)") 28 | } 29 | 30 | let keyName = URL(fileURLWithPath: privateKeyLocation).lastPathComponent 31 | let bastionHost: RemoteHost = try RemoteHost(hostAddress, sshPort: port, sshUser: user, sshIdentityFile: privateKeyLocation) 32 | let detailLevel: CommandOutputDetail = verbose ? .debug(emoji: true) : .normal(emoji: true) 33 | 34 | try await engine.run( 35 | host: bastionHost, displayProgress: true, detailLevel: detailLevel, 36 | commands: [ 37 | ShellCommand("mkdir -p ~/.ssh"), // uses Process and forked 'ssh' locally 38 | ShellCommand("chmod 0700 ~/.ssh"), 39 | CopyInto(location: "~/.ssh/\(keyName)", from: privateKeyLocation), 40 | CopyInto(location: "~/.ssh/\(keyName).pub", from: "\(privateKeyLocation).pub"), 41 | ShellCommand("chmod 0600 ~/.ssh/\(keyName)"), 42 | 43 | // CopyFrom(into: "swiftly-install.sh", from: URL(string: "https://swiftlang.github.io/swiftly/swiftly-install.sh")!), 44 | // ShellCommand("chmod 0755 swiftly-install.sh"), 45 | // released version (0.3.0) doesn't support Ubuntu 24.04 - that's pending in 0.4.0... 46 | // And 0.4.0 changes its installation process anyway... 47 | 48 | // Stop unattended upgrades during the updates 49 | ShellCommand("sudo systemctl stop unattended-upgrades.service"), 50 | 51 | // Apply all current upgrades available 52 | ShellCommand("sudo apt-get update -q", env: debUnattended), 53 | ShellCommand("sudo apt-get upgrade -y -qq", env: debUnattended), 54 | 55 | // A reboot and "wait for it" to resume setup: 56 | // ShellCommand("sudo reboot"), 57 | // VerifyAccess(ignoreFailure: false, 58 | // retry: Backoff(maxRetries: 10, strategy: .fibonacci(maxDelay: .seconds(10)))), 59 | 60 | // "manual install" of Swift 61 | // from https://www.swift.org/install/linux/tarball/ for Ubuntu 24.04 62 | ShellCommand("sudo apt-get install -y -qq binutils", env: debUnattended), 63 | ShellCommand("sudo apt-get install -y -qq git", env: debUnattended), 64 | ShellCommand("sudo apt-get install -y -qq gnupg2", env: debUnattended), 65 | ShellCommand("sudo apt-get install -y -qq libcurl4-openssl-dev", env: debUnattended), 66 | ShellCommand("sudo apt-get install -y -qq libstdc++-13-dev", env: debUnattended), 67 | ShellCommand("sudo apt-get install -y -qq libc6-dev", env: debUnattended), 68 | ShellCommand("sudo apt-get install -y -qq libgcc-13-dev", env: debUnattended), 69 | ShellCommand("sudo apt-get install -y -qq libncurses-dev", env: debUnattended), 70 | ShellCommand("sudo apt-get install -y -qq libpython3-dev", env: debUnattended), 71 | ShellCommand("sudo apt-get install -y -qq libsqlite3-0", env: debUnattended), 72 | ShellCommand("sudo apt-get install -y -qq libxml2-dev", env: debUnattended), 73 | ShellCommand("sudo apt-get install -y -qq libz3-dev", env: debUnattended), 74 | ShellCommand("sudo apt-get install -y -qq zlib1g-dev", env: debUnattended), 75 | ShellCommand("sudo apt-get install -y -qq unzip", env: debUnattended), 76 | ShellCommand("sudo apt-get install -y -qq pkg-config", env: debUnattended), 77 | ShellCommand("sudo apt-get install -y -qq tzdata", env: debUnattended), 78 | ShellCommand("sudo apt-get install -y -qq libedit2", env: debUnattended), 79 | 80 | // restart the unattended upgrades 81 | ShellCommand("sudo systemctl start unattended-upgrades.service"), 82 | 83 | ShellCommand( 84 | "wget -nv https://download.swift.org/swift-6.0.3-release/ubuntu2404/swift-6.0.3-RELEASE/swift-6.0.3-RELEASE-ubuntu24.04.tar.gz" 85 | ), 86 | 87 | ShellCommand("tar xzf swift-6.0.3-RELEASE-ubuntu24.04.tar.gz"), 88 | 89 | ShellCommand("swift-6.0.3-RELEASE-ubuntu24.04/usr/bin/swift -version"), 90 | 91 | ShellCommand("sudo reboot"), 92 | ]) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /scripts/preflight.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eou pipefail 4 | 5 | # see https://stackoverflow.com/questions/4774054/reliable-way-for-a-bash-script-to-get-the-full-path-to-itself 6 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" 7 | 8 | cd "${THIS_SCRIPT_DIR}/.." 9 | 10 | swift package format-source-code --allow-writing-to-package-directory 11 | swift package lint-source-code 12 | --------------------------------------------------------------------------------