├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── address.go ├── address_test.go ├── archive.go ├── archive_write_unix.go ├── archive_write_windows.go ├── bytes_stringer.go ├── callback_writer.go ├── command.go ├── connect.go ├── debug_writer.go ├── demo.gif ├── distributed_lock.go ├── distributed_lock_node.go ├── file.go ├── format.go ├── go.mod ├── go.sum ├── heartbeat.go ├── json_output_writer.go ├── key.go ├── locked_writer.go ├── log.go ├── main.go ├── main_unix.go ├── main_windows.go ├── multiwrite_closer.go ├── nop_closer.go ├── protocol_node_writer.go ├── remote_execution.go ├── remote_execution_node.go ├── remote_execution_runner.go ├── run_tests ├── runner_factory.go ├── sshagent_unix.go ├── sshagent_windows.go ├── status_bar_update_writer.go ├── sync.go ├── sync_protocol.go ├── tests ├── .gitignore ├── build.sh ├── deps.sh ├── orgalorg.sh ├── setup.sh ├── ssh.sh ├── teardown.sh └── testcases │ ├── auth │ ├── can-authenticate-via-key-with-passphrase.test.sh │ └── can-authenticate-via-password.test.sh │ ├── can-skip-unreachable-servers-if-flag-is-given.test.sh │ ├── commandline │ ├── can-handle-common-mistakes-in-arguments.test.sh │ └── will-read-hosts-from-file-if-argument-starts-with-slash-or-dot-slash.test.sh │ ├── commands │ ├── can-aggregate-errors-on-the-end-of-execution.test.sh │ ├── can-escape-space-in-the-remote-command.test.sh │ ├── can-run-remote-command-and-pass-stdin-to-it.test.sh │ ├── can-run-remote-command.test.sh │ ├── can-run-shell-command.test.sh │ ├── should-properly-escape-shell-arguments.test.sh │ ├── should-return-non-zero-exit-code-if-command-fails.test.sh │ ├── should-use-default-user-directory-unless-specified.test.sh │ ├── should-use-specified-working-directory.test.sh │ └── will-flush-lines-so-they-do-not-interleave.test.sh │ ├── locking │ ├── can-acquire-global-lock.test.sh │ ├── can-detect-if-lock-process-is-aborted-by-remote-host-after-acquire.test.sh │ ├── lock.sh │ └── will-continue-execution-when-lock-failed-if-flag-is-specified.test.sh │ ├── newlines │ ├── do-append-newlines-in-non-quite-mode-when-necessary.test.sh │ └── do-not-append-newlines-if-they-are-not-present-in-remote-output-at-quiet-mode.test.sh │ ├── sudo │ ├── can-run-command-under-sudo.test.sh │ ├── can-upload-files-under-sudo.test.sh │ └── should-run-all-commands-under-sudo-if-specified.test.sh │ ├── sync │ ├── can-create-run-directory-under-sudo.test.sh │ ├── can-pass-additional-args-to-sync-tool.test.sh │ ├── can-pass-arguments-to-the-sync-tool-by-default.test.sh │ ├── can-run-simple-command-after-files-sync-even-in-protocol-mode.test.sh │ ├── can-run-simple-command-after-files-sync.test.sh │ ├── can-run-two-steps-with-synchronization-in-between.test.sh │ ├── can-upload-directory.test.sh │ ├── can-upload-file-by-absolute-path-by-default.test.sh │ ├── can-upload-single-file.test.sh │ ├── can-upload-two-files.test.sh │ ├── sync-tool-will-be-run-in-the-directory-with-uploaded-files.test.sh │ ├── will-print-uploaded-files-in-verbose-mode.test.sh │ ├── will-say-hello-to-sync-tool-at-start.test.sh │ ├── will-send-list-of-nodes-to-sync-tool.test.sh │ └── will-send-stderr-from-sync-tool-back-to-master.test.sh │ └── verbosity │ ├── can-log-stderr-and-stdout-from-remote-at-verbose-level-to-stderr.test.sh │ ├── can-output-ip-in-response-from-remote-server-when-running-command.test.sh │ ├── can-output-node-stdout-and-stderr-in-json-format.test.sh │ ├── can-output-verbose-debug-info-in-json-format.test.sh │ ├── can-print-debug-output.test.sh │ ├── can-report-internal-errors-in-json-format.test.sh │ ├── do-not-output-ip-in-quiet-mode.test.sh │ └── will-output-hierarchical-errors-by-default.test.sh ├── themes.go ├── thread_pool.go ├── timeouts.go ├── vendor.bash ├── .gitignore └── Makefile └── verbosity.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Set up Go 12 | uses: actions/setup-go@v5 13 | with: 14 | go-version-file: "go.mod" 15 | id: go 16 | 17 | - name: Build 18 | run: go build -v . 19 | 20 | - name: Test 21 | run: go test -v . 22 | 23 | - name: Upload build artifact 24 | uses: actions/upload-artifact@v2 25 | with: 26 | name: build 27 | path: orgalorg 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version-file: "go.mod" 17 | - 18 | name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v2 20 | with: 21 | version: latest 22 | args: release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lorg 2 | /.last-testcase 3 | /orgalorg 4 | /orgalorg.exe 5 | /.cover 6 | /coverage 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor.bash/github.com/reconquest/import.bash"] 2 | path = vendor.bash/github.com/reconquest/import.bash 3 | url = https://github.com/reconquest/import.bash 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: orgalorg 2 | builds: 3 | - env: 4 | - CGO_ENABLED=0 5 | - GO111MODULE=on 6 | goos: 7 | - linux 8 | - darwin 9 | - windows 10 | goarch: 11 | - amd64 12 | flags: 13 | - -mod=readonly 14 | ldflags: 15 | - -X=main.version={{.Tag}} 16 | checksum: 17 | name_template: 'sha256sums.txt' 18 | algorithm: sha256 19 | snapshot: 20 | name_template: "{{ .Tag }}-next" 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Stanislav Seletskiy 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build -mod=mod -ldflags -X=main.version=$$(git describe --tags --abbrev=6) 3 | 4 | test: 5 | ./run_tests -vvvv 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # orgalorg [![goreport](https://goreportcard.com/badge/github.com/reconquest/orgalorg)](https://goreportcard.com/report/github.com/reconquest/orgalorg) [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/reconquest/orgalorg/master/LICENSE) 2 | 3 |

4 | orgalorg can run command and upload files in parallel by SSH on many hosts 5 |

6 | 7 |

8 | 9 |

10 | 11 | # Features 12 | 13 | * Zero-configuration. No config files. Everything is done via command line 14 | flags. 15 | 16 | * Running SSH commands or shell scripts on any number of hosts in parallel. All 17 | output from nodes will be returned back, keeping stdout and stderr streams 18 | mapping of original commands. 19 | 20 | * Synchronizing files and directories across cluster with prior global cluster 21 | locking. 22 | After synchronization is done, arbitrary command can be evaluated. 23 | 24 | * Synchronizing files and directories with subsequent run of complex multi-step 25 | scenario with steps synchronization across cluster. 26 | 27 | * User-friendly progress indication. 28 | 29 | * Both strict or loose modes of failover to be sure that everything will either 30 | fail on any error or try to complete, no matter of what. 31 | 32 | * Interactive password authentication as well as SSH public key authentication. 33 | Will use ssh-agent if present. On Windows, orgalorg can connect to 34 | **pageant** or **openssh agent**. 35 | 36 | * Ability to run commands through `sudo`. 37 | 38 | * Grouped mode of output, so stdout and stderr from nodes will be grouped by 39 | node name. Alternatively, output can be returned as soon as node returns 40 | something. 41 | 42 | # Installation 43 | 44 | ## go install 45 | 46 | ```bash 47 | go install github.com/reconquest/orgalorg@latest 48 | ``` 49 | 50 | # Alternatives 51 | 52 | * ansible: intended to apply complex DSL-based scenarios of actions; 53 | orgalorg aimed only on running commands and synchronizing files in parallel. 54 | orgalorg can accept target hosts list on stdin and can provide realtime 55 | output from commands, which ansible can't do (like running `tail -f`). 56 | orgalorg also uses same argument semantic as `ssh`: 57 | `orgalorg ... -C tail -f '/var/log/*.log'` will do exactly the same. 58 | 59 | * clusterssh / cssh: will open number of xterm terminals to all nodes. 60 | orgalorg intended to use in batch mode, no GUI is assumed. orgalorg, however, 61 | can be used in interactive mode (see example section below). 62 | 63 | * pssh: buggy, uses binary ssh, which is not resource efficient. 64 | orgalorg uses native SSH protocol implementation, so safe and fast to use 65 | on thousand of nodes. 66 | 67 | * dsh / gsh / pdsh: not maintained. 68 | 69 | # Example usages 70 | 71 | `-o ...` in later examples will mean any supported combination of 72 | host-specification arguments, like 73 | `-o node1.example.com -o node2.example.com`. 74 | 75 | ## Evaluating command on hosts in parallel 76 | 77 | ```bash 78 | orgalorg -o ... -C uptime 79 | ``` 80 | 81 | ## Evaluating command on hosts given by stdin 82 | 83 | `axfr` is a tool of your choice for retrieving domain information from your 84 | infrastructure DNS. 85 | 86 | ```bash 87 | axfr | grep phpnode | orgalorg -s -C uptime 88 | ``` 89 | 90 | ## Evaluate command under root (passwordless sudo required) 91 | 92 | ```bash 93 | orgalorg -o ... -x -C whoami 94 | ``` 95 | 96 | ## Tailing logs from many hosts in realtime 97 | 98 | ```bash 99 | orgalorg -o ... -C tail -f /var/log/syslog 100 | ``` 101 | 102 | ## Copying SSH public key for remote authentication 103 | 104 | ```bash 105 | orgalorg -o ... -p -i ~/.ssh/id_rsa.pub -C tee -a ~/.ssh/authorized_keys 106 | ``` 107 | 108 | ## Synchronizing configs and then reloading service (like nginx) 109 | 110 | ```bash 111 | orgalorg -o ... -xn 'systemctl reload nginx' -S /etc/nginx.conf 112 | ``` 113 | 114 | ## Evaluating shell script 115 | 116 | ```bash 117 | orgalorg -o ... -i script.bash -C bash 118 | ``` 119 | 120 | ## Install package on all nodes and get combined output from each node 121 | 122 | ```bash 123 | orgalorg -o ... -lx -C pacman -Sy my-package --noconfirm 124 | ``` 125 | 126 | ## Evaluating shell oneliner 127 | 128 | ```bash 129 | orgalorg -o ... -C sleep '$(($RANDOM % 10))' '&&' echo done 130 | ``` 131 | 132 | ## Running poor-man interactive parallel shell 133 | 134 | ```bash 135 | orgalorg -o ... -i /dev/stdin -C bash -s 136 | ``` 137 | 138 | ## Obtaining global cluster lock 139 | 140 | ```bash 141 | orgalorg -o ... -L 142 | ``` 143 | 144 | Next orgalorg calls will fail with message, that lock is already acquired, 145 | until first instance will be stopped. 146 | 147 | Useful for setting cluster into maintenance state. 148 | 149 | ## Obtaining global cluster lock on custom directory 150 | 151 | ```bash 152 | orgalorg -o ... -L -r /etc 153 | ``` 154 | 155 | # Description 156 | 157 | orgalorg provides easy way of synchronizing files across cluster and running 158 | arbitrary SSH commands. 159 | 160 | orgalorg works through SSH & tar, so no unexpected protocol errors will arise. 161 | 162 | In default mode of operation (lately referred as sync mode) orgalorg will 163 | perform steps in the following order: 164 | 165 | 1. Acquire global cluster lock (check more detailed info above). 166 | 2. Create, upload and extract specified files in streaming mode to the 167 | specified nodes into temporary run directory. 168 | 3. Start synchronization tool on each node, that should relocate files from 169 | temporary run directory to the destination. 170 | 171 | So, orgalorg expected to work with third-party synchronization tool, that 172 | will do actual files relocation and can be quite intricate, **but orgalorg can 173 | work without that tool and perform simple files sync (more on this later)**. 174 | 175 | ## Global Cluster Lock 176 | 177 | Before doing anything else orgalorg will perform global cluster lock. That lock 178 | is acquired atomically, and no other orgalorg instance can acquire lock if it 179 | is already acquired. 180 | 181 | Locking is done via flock'ing specified file or directory on each of target 182 | nodes, and will fail, if flock fails on at least one node. 183 | 184 | Directory can be used as lock target as well as ordinary file. `--lock-file` 185 | can be used to specify lock target different from `/`. 186 | 187 | After acquiring lock, orgalorg will run heartbeat process, which will check, 188 | that lock is still intact. By default, that check will be performed every 10 189 | seconds. If at least one heartbeat is failed, then orgalorg will abort entire 190 | sync procedure. 191 | 192 | User can stop there by using `--lock` or `-L` flag, effectively transform 193 | orgalorg to the distributed locking tool. 194 | 195 | ## File Upload 196 | 197 | Files will be sent from local node to the amount of specified nodes. 198 | 199 | orgalorg will perform streaming transfer, so it's safe to synchronize large 200 | files without major memory consumption. 201 | 202 | By default, orgalorg will upload files to the temporary run directory. That 203 | behaviour can be changed by using `--root` or `-r` flag. Then, files will be 204 | uploaded to the specified directory. 205 | 206 | User can specify `--upload` or `-U` flag to transform orgalorg to the simple 207 | file upload tool. In that mode orgalorg will upload files to the specified 208 | directory and then exit. 209 | 210 | orgalorg preserves all file attributes while transfer as well as user and group 211 | IDs. That behaviour can be changed by using `--no-preserve-uid` and 212 | `--no-preseve-gid` command line options. These flags are ignored when orgalorg 213 | is ran from Windows. 214 | 215 | By default, orgalorg will keep source file paths as is, creating same directory 216 | layout on the target nodes. E.g., if orgalorg told to upload file `a` while 217 | current working directory is `/b/c/`, orgalorg will upload file to the 218 | `/b/c/a` on the remote nodes. That behaviour can be changed by 219 | specifying `--relative` or `-e` flag. Then, orgalorg will not preserve source 220 | file base directory. 221 | 222 | orgalorg will try to upload files under specified user (current user by 223 | default). However, if user has `NOPASSWD` record in the sudoers file on the 224 | remote nodes, `--sudo` or `-x` can be used to elevate to root before uploading 225 | files. It makes possible to login to the remote nodes under normal user and 226 | rewrite system files. 227 | 228 | ## Synchronization Tool 229 | 230 | After file upload orgalorg will execute synchronization tool 231 | (`/usr/lib/orgalorg/sync`). That tool is expected to relocate synced files from 232 | temporary directory to the target directory. However, that tool can perform 233 | arbitrary actions, like reloading system services. 234 | 235 | To specify custom synchronization tool user can use `--sync-cmd` or `-n` flag. 236 | Full shell syntax is supported in the argument to that option. 237 | 238 | Tool is also expected to communicate with orgalorg using sync protocol 239 | (described below), however, it's not required. If not specified, orgalorg will 240 | communicate with that tool using stdin/stdout streams. User can change that 241 | behaviour using `--simple` or `-m` flag, which will cause orgalorg to treat 242 | specified sync tool as simple shell command. User can even provide stdin 243 | to that program by using `--stdin` or `-i` flag. 244 | 245 | Tool can accept number of arguments, which can be specified by using `-g` or 246 | `--arg` flags. 247 | 248 | # Synchronization Protocol 249 | 250 | orgalorg will communicate with given sync tool using special sync protocol, 251 | which gives possibility to perform some actions with synchronization across 252 | entire cluster. 253 | 254 | orgalorg will start sync tool as it specified in the command line, without 255 | any modification. 256 | 257 | After start, orgalorg will communicate with running sync tool using stdin 258 | and stdout streams. stderr will be passed to user untouched. 259 | 260 | All communication messages should be prefixed by special prefix, which is 261 | send by orgalorg in the hello message. All lines on stdout that are not match 262 | given prefix will be printed as is, untouched. 263 | 264 | Communication begins from the hello message. 265 | 266 | ## Protocol 267 | 268 | ### HELLO 269 | 270 | `orgalorg -> sync tool` 271 | 272 | ``` 273 | HELLO 274 | ``` 275 | 276 | Start communication session. All further messages should be prefixed with given 277 | prefix. 278 | 279 | ### NODE 280 | 281 | `orgalorg -> sync tool` 282 | 283 | ``` 284 | NODE [CURRENT] 285 | ``` 286 | 287 | orgalorg will send node list to the sync tools on each running node. 288 | 289 | `CURRENT` flag will be present next to the node which is currently receiving 290 | protocol messages. 291 | 292 | ### START 293 | 294 | `orgalorg -> sync tool` 295 | 296 | ``` 297 | START 298 | ``` 299 | 300 | Start messages will be sent at the end of the nodes list and means that sync 301 | tool can start doing actions. 302 | 303 | ### SYNC 304 | 305 | `sync tool -> orgalorg` 306 | 307 | ``` 308 | SYNC 309 | ``` 310 | 311 | Sync tool can send sync messages after some steps are done to be sure, that 312 | every node in cluster are performing steps gradually, in order. 313 | 314 | When orgalorg receives sync message, it will be broadcasted to every connected 315 | sync tool. 316 | 317 | ### SYNC (broadcasted) 318 | 319 | `orgalorg -> sync tool` 320 | 321 | ``` 322 | SYNC 323 | ``` 324 | 325 | orgalorg will retransmit incoming sync message from one node to every connected 326 | node (including node, that is sending sync). 327 | 328 | Sync tools can wait for specific number of the incoming sync messages to 329 | continue to the next step of execution process. 330 | 331 | ## Example 332 | 333 | `<-` are outgoing messages (from orgalorg to sync tools). 334 | 335 | ``` 336 | <- ORGALORG:132464327653 HELLO 337 | <- ORGALORG:132464327653 NODE [user@node1:22] 338 | <- ORGALORG:132464327653 NODE [user@node2:1234] CURRENT 339 | <- ORGALORG:132464327653 START 340 | -> (from node1) ORGALORG:132464327653 SYNC phase 1 completed 341 | <- ORGALORG:132464327653 SYNC [user@node1:22] phase 1 completed 342 | -> (from node2) ORGALORG:132464327653 SYNC phase 1 completed 343 | <- ORGALORG:132464327653 SYNC [user@node2:1234] phase 1 completed 344 | ``` 345 | 346 | # Testing 347 | 348 | To run tests it's enough to: 349 | 350 | ``` 351 | ./run_tests 352 | ``` 353 | 354 | ## Requirements 355 | 356 | Testcases are run through [tests.sh](https://github.com/reconquest/tests.sh) 357 | library. 358 | 359 | For every testcase new set of temporary containers will be initialized through 360 | [hastur](https://github.com/seletskiy/hastur), so `systemd` is required for 361 | running test suite. 362 | 363 | orgalorg testcases are close to reality as possible, so orgalorg will really 364 | connect via SSH to cluster of containers in each testcase. 365 | 366 | ## Coverage 367 | 368 | Run following command to calculate total coverage (available after running 369 | testsuite): 370 | 371 | ```bash 372 | make coverage.total 373 | ``` 374 | 375 | Current coverage level is something about **85%**. 376 | -------------------------------------------------------------------------------- /address.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | 8 | "github.com/reconquest/hierr-go" 9 | ) 10 | 11 | var ( 12 | hostRegexp = regexp.MustCompile(`^(?:([^@]+)@)?(.*?)(?::(\d+))?$`) 13 | ) 14 | 15 | type address struct { 16 | user string 17 | domain string 18 | port int 19 | 20 | host string 21 | } 22 | 23 | func (address address) String() string { 24 | return fmt.Sprintf( 25 | "[%s@%s:%d]", 26 | address.user, 27 | address.domain, 28 | address.port, 29 | ) 30 | } 31 | 32 | func parseAddress( 33 | host string, defaultUser string, defaultPort int, 34 | ) (address, error) { 35 | matches := hostRegexp.FindStringSubmatch(host) 36 | 37 | var ( 38 | user = defaultUser 39 | domain = matches[2] 40 | rawPort = matches[3] 41 | port = defaultPort 42 | ) 43 | 44 | if matches[1] != "" { 45 | user = matches[1] 46 | } 47 | 48 | if rawPort != "" { 49 | var err error 50 | port, err = strconv.Atoi(rawPort) 51 | if err != nil { 52 | return address{}, hierr.Errorf( 53 | err, 54 | `can't parse port number: '%s'`, rawPort, 55 | ) 56 | } 57 | } 58 | 59 | return address{ 60 | user: user, 61 | domain: domain, 62 | port: port, 63 | host: host, 64 | }, nil 65 | } 66 | 67 | func getUniqueAddresses(addresses []address) []address { 68 | result := []address{} 69 | 70 | for _, origin := range addresses { 71 | keep := true 72 | 73 | for _, another := range result { 74 | if origin.user != another.user { 75 | continue 76 | } 77 | 78 | if origin.domain != another.domain { 79 | continue 80 | } 81 | 82 | if origin.port != another.port { 83 | continue 84 | } 85 | 86 | keep = false 87 | } 88 | 89 | if keep { 90 | result = append(result, origin) 91 | } 92 | } 93 | 94 | return result 95 | } 96 | -------------------------------------------------------------------------------- /address_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseAddress_ParseValidDomainAddressWithDefaults(t *testing.T) { 10 | assertions := assert.New(t) 11 | 12 | tests := []struct { 13 | Host string 14 | DefaultUser string 15 | DefaultPort int 16 | 17 | ExpectedUser string 18 | ExpectedDomain string 19 | ExpectedPort int 20 | }{ 21 | {"example.com", "_", 23, 22 | "_", "example.com", 23}, 23 | 24 | {"user@example.com", "_", 23, 25 | "user", "example.com", 23}, 26 | 27 | {"example.com:1234", "_", 23, 28 | "_", "example.com", 1234}, 29 | 30 | {"user@example.com:1234", "_", 23, 31 | "user", "example.com", 1234}, 32 | 33 | {"example.com:1234", "user", 23, 34 | "user", "example.com", 1234}, 35 | 36 | {"user2@example.com:1234", "user", 23, 37 | "user2", "example.com", 1234}, 38 | } 39 | 40 | for _, test := range tests { 41 | parsed, err := parseAddress( 42 | test.Host, test.DefaultUser, test.DefaultPort, 43 | ) 44 | 45 | assertions.Nil(err) 46 | assertions.Equal(test.ExpectedUser, parsed.user) 47 | assertions.Equal(test.ExpectedPort, parsed.port) 48 | assertions.Equal(test.ExpectedDomain, parsed.domain) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /archive.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/tar" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | 10 | "github.com/reconquest/hierr-go" 11 | "github.com/reconquest/lineflushwriter-go" 12 | "github.com/reconquest/prefixwriter-go" 13 | ) 14 | 15 | func startArchiveReceivers( 16 | cluster *distributedLock, 17 | rootDir string, 18 | sudo bool, 19 | serial bool, 20 | ) (*remoteExecution, error) { 21 | command := []string{ 22 | "mkdir", "-p", rootDir, "&&", "tar", "--directory", rootDir, "-x", 23 | } 24 | 25 | if verbose >= verbosityDebug { 26 | command = append(command, `--verbose`) 27 | } 28 | 29 | logMutex := &sync.Mutex{} 30 | 31 | runner := &remoteExecutionRunner{ 32 | command: command, 33 | serial: serial, 34 | shell: defaultRemoteExecutionShell, 35 | sudo: sudo, 36 | } 37 | 38 | execution, err := runner.run( 39 | cluster, 40 | func(node *remoteExecutionNode) { 41 | node.stdout = lineflushwriter.New( 42 | prefixwriter.New(node.stdout, "{tar} "), 43 | logMutex, 44 | true, 45 | ) 46 | 47 | node.stderr = lineflushwriter.New( 48 | prefixwriter.New(node.stderr, "{tar} "), 49 | logMutex, 50 | true, 51 | ) 52 | }, 53 | ) 54 | if err != nil { 55 | return nil, hierr.Errorf( 56 | err, 57 | `can't start tar extraction command: '%v'`, 58 | command, 59 | ) 60 | } 61 | 62 | return execution, nil 63 | } 64 | 65 | func archiveFilesToWriter( 66 | target io.WriteCloser, 67 | files []file, 68 | preserveUID, preserveGID bool, 69 | ) error { 70 | workDir, err := os.Getwd() 71 | if err != nil { 72 | return hierr.Errorf( 73 | err, 74 | `can't get current working directory`, 75 | ) 76 | } 77 | 78 | status := &struct { 79 | Phase string 80 | Total int 81 | Fails int 82 | Success int 83 | Written bytesStringer 84 | Bytes bytesStringer 85 | }{ 86 | Phase: "upload", 87 | Total: len(files), 88 | } 89 | 90 | setStatus(status) 91 | 92 | for _, file := range files { 93 | status.Bytes.Amount += file.size 94 | } 95 | 96 | archive := tar.NewWriter(target) 97 | stream := io.MultiWriter(archive, callbackWriter( 98 | func(data []byte) (int, error) { 99 | status.Written.Amount += len(data) 100 | 101 | drawStatus() 102 | 103 | return len(data), nil 104 | }, 105 | )) 106 | 107 | for fileIndex, file := range files { 108 | infof( 109 | "%5d/%d sending file: '%s'", 110 | fileIndex+1, 111 | len(files), 112 | file.path, 113 | ) 114 | 115 | err = writeFileToArchive( 116 | file.path, 117 | stream, 118 | archive, 119 | workDir, 120 | preserveUID, 121 | preserveGID, 122 | ) 123 | if err != nil { 124 | return hierr.Errorf( 125 | err, 126 | `can't write file to archive: '%s'`, 127 | file.path, 128 | ) 129 | } 130 | 131 | status.Success++ 132 | } 133 | 134 | tracef("closing archive stream, %d files sent", len(files)) 135 | 136 | err = archive.Close() 137 | if err != nil { 138 | return hierr.Errorf( 139 | err, 140 | `can't close tar stream`, 141 | ) 142 | } 143 | 144 | err = target.Close() 145 | if err != nil { 146 | return hierr.Errorf( 147 | err, 148 | `can't close target stdin`, 149 | ) 150 | } 151 | 152 | return nil 153 | } 154 | 155 | func getFilesList(relative bool, sources ...string) ([]file, error) { 156 | files := []file{} 157 | 158 | for _, source := range sources { 159 | err := filepath.Walk( 160 | source, 161 | func(path string, info os.FileInfo, err error) error { 162 | if err != nil { 163 | return err 164 | } 165 | 166 | if info.IsDir() { 167 | return nil 168 | } 169 | 170 | if !relative { 171 | path, err = filepath.Abs(path) 172 | if err != nil { 173 | return hierr.Errorf( 174 | err, 175 | `can't get absolute path for local file: '%s'`, 176 | path, 177 | ) 178 | } 179 | } 180 | 181 | files = append(files, file{ 182 | path: path, 183 | size: int(info.Size()), 184 | }) 185 | 186 | return nil 187 | }, 188 | ) 189 | if err != nil { 190 | return nil, err 191 | } 192 | } 193 | 194 | return files, nil 195 | } 196 | -------------------------------------------------------------------------------- /archive_write_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package main 4 | 5 | import ( 6 | "archive/tar" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "syscall" 12 | 13 | "github.com/reconquest/hierr-go" 14 | ) 15 | 16 | func writeFileToArchive( 17 | fileName string, 18 | stream io.Writer, 19 | archive *tar.Writer, 20 | workDir string, 21 | preserveUID, preserveGID bool, 22 | ) error { 23 | fileInfo, err := os.Stat(fileName) 24 | if err != nil { 25 | return hierr.Errorf( 26 | err, 27 | `can't stat file for archiving: '%s`, fileName, 28 | ) 29 | } 30 | 31 | // avoid tar warnings about leading slash 32 | tarFileName := fileName 33 | if tarFileName[0] == '/' { 34 | tarFileName = tarFileName[1:] 35 | 36 | fileName, err = filepath.Rel(workDir, fileName) 37 | if err != nil { 38 | return hierr.Errorf( 39 | err, 40 | `can't make relative path from: '%s'`, 41 | fileName, 42 | ) 43 | } 44 | } 45 | 46 | header := &tar.Header{ 47 | Name: tarFileName, 48 | Mode: int64(fileInfo.Sys().(*syscall.Stat_t).Mode), 49 | Size: fileInfo.Size(), 50 | 51 | ModTime: fileInfo.ModTime(), 52 | } 53 | 54 | if preserveUID { 55 | header.Uid = int(fileInfo.Sys().(*syscall.Stat_t).Uid) 56 | } 57 | 58 | if preserveGID { 59 | header.Gid = int(fileInfo.Sys().(*syscall.Stat_t).Gid) 60 | } 61 | 62 | tracef( 63 | hierr.Errorf( 64 | fmt.Sprintf( 65 | "size: %d bytes; mode: %o; uid/gid: %d/%d; modtime: %s", 66 | header.Size, 67 | header.Mode, 68 | header.Uid, 69 | header.Gid, 70 | header.ModTime, 71 | ), 72 | `local file: %s; remote file: %s`, 73 | fileName, 74 | tarFileName, 75 | ).Error(), 76 | ) 77 | 78 | err = archive.WriteHeader(header) 79 | 80 | if err != nil { 81 | return hierr.Errorf( 82 | err, 83 | `can't write tar header for fileName: '%s'`, fileName, 84 | ) 85 | } 86 | 87 | fileToArchive, err := os.Open(fileName) 88 | if err != nil { 89 | return hierr.Errorf( 90 | err, 91 | `can't open fileName for reading: '%s'`, 92 | fileName, 93 | ) 94 | } 95 | 96 | _, err = io.Copy(stream, fileToArchive) 97 | if err != nil { 98 | return hierr.Errorf( 99 | err, 100 | `can't copy file to the archive: '%s'`, 101 | fileName, 102 | ) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /archive_write_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | import ( 6 | "archive/tar" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/reconquest/hierr-go" 13 | ) 14 | 15 | func writeFileToArchive( 16 | fileName string, 17 | stream io.Writer, 18 | archive *tar.Writer, 19 | workDir string, 20 | preserveUID, preserveGID bool, 21 | ) error { 22 | fileInfo, err := os.Stat(fileName) 23 | if err != nil { 24 | return hierr.Errorf( 25 | err, 26 | `can't stat file for archiving: '%s`, fileName, 27 | ) 28 | } 29 | 30 | // avoid tar warnings about leading slash 31 | tarFileName := fileName 32 | if tarFileName[0] == '/' { 33 | tarFileName = tarFileName[1:] 34 | 35 | fileName, err = filepath.Rel(workDir, fileName) 36 | if err != nil { 37 | return hierr.Errorf( 38 | err, 39 | `can't make relative path from: '%s'`, 40 | fileName, 41 | ) 42 | } 43 | } 44 | 45 | header := &tar.Header{ 46 | Name: tarFileName, 47 | Size: fileInfo.Size(), 48 | 49 | ModTime: fileInfo.ModTime(), 50 | } 51 | 52 | tracef( 53 | hierr.Errorf( 54 | fmt.Sprintf( 55 | "size: %d bytes; modtime: %s", 56 | header.Size, 57 | header.ModTime, 58 | ), 59 | `local file: %s; remote file: %s`, 60 | fileName, 61 | tarFileName, 62 | ).Error(), 63 | ) 64 | 65 | err = archive.WriteHeader(header) 66 | 67 | if err != nil { 68 | return hierr.Errorf( 69 | err, 70 | `can't write tar header for fileName: '%s'`, fileName, 71 | ) 72 | } 73 | 74 | fileToArchive, err := os.Open(fileName) 75 | if err != nil { 76 | return hierr.Errorf( 77 | err, 78 | `can't open fileName for reading: '%s'`, 79 | fileName, 80 | ) 81 | } 82 | 83 | _, err = io.Copy(stream, fileToArchive) 84 | if err != nil { 85 | return hierr.Errorf( 86 | err, 87 | `can't copy file to the archive: '%s'`, 88 | fileName, 89 | ) 90 | } 91 | 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /bytes_stringer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type bytesStringer struct { 8 | Amount int 9 | } 10 | 11 | func (stringer bytesStringer) String() string { 12 | amount := float64(stringer.Amount) 13 | 14 | suffixes := map[string]string{ 15 | "b": "KiB", 16 | "KiB": "MiB", 17 | "MiB": "GiB", 18 | "GiB": "TiB", 19 | } 20 | 21 | suffix := "b" 22 | for amount >= 1024 { 23 | if newSuffix, ok := suffixes[suffix]; ok { 24 | suffix = newSuffix 25 | } else { 26 | break 27 | } 28 | 29 | amount /= 1024 30 | } 31 | 32 | return fmt.Sprintf("%.2f%s", amount, suffix) 33 | } 34 | -------------------------------------------------------------------------------- /callback_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type ( 4 | callbackWriter func([]byte) (int, error) 5 | ) 6 | 7 | func (writer callbackWriter) Write(data []byte) (int, error) { 8 | return writer(data) 9 | } 10 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "sync" 7 | 8 | "github.com/reconquest/hierr-go" 9 | "github.com/reconquest/karma-go" 10 | "github.com/reconquest/lineflushwriter-go" 11 | "github.com/reconquest/prefixwriter-go" 12 | "github.com/reconquest/runcmd" 13 | "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | type remoteNodesMap map[*distributedLockNode]*remoteExecutionNode 17 | 18 | type remoteNodes struct { 19 | *sync.Mutex 20 | 21 | nodes remoteNodesMap 22 | } 23 | 24 | func (nodes *remoteNodes) Set( 25 | node *distributedLockNode, 26 | remote *remoteExecutionNode, 27 | ) { 28 | nodes.Lock() 29 | defer nodes.Unlock() 30 | 31 | nodes.nodes[node] = remote 32 | } 33 | 34 | func runRemoteExecution( 35 | lockedNodes *distributedLock, 36 | command []string, 37 | setupCallback func(*remoteExecutionNode), 38 | serial bool, 39 | term bool, 40 | ) (*remoteExecution, error) { 41 | var ( 42 | stdins = []io.WriteCloser{} 43 | 44 | logLock = &sync.Mutex{} 45 | stdinsLock = &sync.Mutex{} 46 | outputLock = &sync.Mutex{} 47 | 48 | nodes = &remoteNodes{&sync.Mutex{}, remoteNodesMap{}} 49 | ) 50 | 51 | if !serial { 52 | outputLock = nil 53 | } 54 | 55 | status := &struct { 56 | sync.Mutex 57 | 58 | Phase string 59 | Total int 60 | Fails int 61 | Success int 62 | }{ 63 | Phase: `exec`, 64 | Total: len(lockedNodes.nodes), 65 | } 66 | 67 | setStatus(status) 68 | 69 | type nodeErr struct { 70 | err error 71 | node *distributedLockNode 72 | } 73 | 74 | errors := make(chan *nodeErr, 0) 75 | for _, node := range lockedNodes.nodes { 76 | go func(node *distributedLockNode) { 77 | pool.run(func() { 78 | tracef( 79 | "%s", 80 | hierr.Errorf( 81 | command, 82 | "%s starting command", 83 | node.String(), 84 | ).Error(), 85 | ) 86 | 87 | remoteNode, err := runRemoteExecutionNode( 88 | node, 89 | command, 90 | term, 91 | logLock, 92 | outputLock, 93 | ) 94 | if err != nil { 95 | errors <- &nodeErr{err, node} 96 | 97 | status.Lock() 98 | defer status.Unlock() 99 | 100 | status.Total-- 101 | status.Fails++ 102 | 103 | return 104 | } 105 | 106 | if setupCallback != nil { 107 | setupCallback(remoteNode) 108 | } 109 | 110 | remoteNode.command.SetStdout(remoteNode.stdout) 111 | remoteNode.command.SetStderr(remoteNode.stderr) 112 | 113 | err = remoteNode.command.Start() 114 | if err != nil { 115 | errors <- &nodeErr{ 116 | hierr.Errorf( 117 | err, 118 | `can't start remote command`, 119 | ), 120 | node, 121 | } 122 | 123 | status.Lock() 124 | defer status.Unlock() 125 | 126 | status.Total-- 127 | status.Fails++ 128 | 129 | return 130 | } 131 | 132 | nodes.Set(node, remoteNode) 133 | 134 | stdinsLock.Lock() 135 | defer stdinsLock.Unlock() 136 | 137 | stdins = append(stdins, remoteNode.stdin) 138 | 139 | status.Lock() 140 | defer status.Unlock() 141 | 142 | status.Success++ 143 | 144 | errors <- nil 145 | }) 146 | }(node) 147 | } 148 | 149 | for range lockedNodes.nodes { 150 | err := <-errors 151 | if err != nil { 152 | return nil, hierr.Errorf( 153 | err.err, 154 | `%s remote execution failed`, 155 | err.node, 156 | ) 157 | } 158 | } 159 | 160 | return &remoteExecution{ 161 | stdin: &multiWriteCloser{stdins}, 162 | 163 | nodes: nodes.nodes, 164 | }, nil 165 | } 166 | 167 | func runRemoteExecutionNode( 168 | node *distributedLockNode, 169 | command []string, 170 | term bool, 171 | logLock sync.Locker, 172 | outputLock sync.Locker, 173 | ) (*remoteExecutionNode, error) { 174 | remoteCommand := node.runner.Command(command[0], command[1:]...) 175 | 176 | if term { 177 | modes := ssh.TerminalModes{ 178 | ssh.ECHO: 0, // disable echoing 179 | ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud 180 | ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud 181 | } 182 | 183 | err := remoteCommand.(*runcmd.RemoteCmd).GetSession().RequestPty( 184 | "xterm", 185 | 40, 186 | 80, 187 | modes, 188 | ) 189 | if err != nil { 190 | return nil, karma.Format( 191 | err, 192 | "request for pseudo terminal failed", 193 | ) 194 | } 195 | } 196 | 197 | stdoutBackend := io.Writer(os.Stdout) 198 | stderrBackend := io.Writer(os.Stderr) 199 | 200 | if format == outputFormatJSON { 201 | stdoutBackend = &jsonOutputWriter{ 202 | stream: `stdout`, 203 | node: node.String(), 204 | host: node.address.host, 205 | 206 | output: os.Stdout, 207 | } 208 | 209 | stderrBackend = &jsonOutputWriter{ 210 | stream: `stderr`, 211 | node: node.String(), 212 | host: node.address.host, 213 | 214 | output: os.Stderr, 215 | } 216 | } 217 | 218 | var stdout io.WriteCloser 219 | var stderr io.WriteCloser 220 | switch { 221 | case verbose == verbosityQuiet || format == outputFormatJSON: 222 | stdout = lineflushwriter.New(nopCloser{stdoutBackend}, logLock, false) 223 | stderr = lineflushwriter.New(nopCloser{stderrBackend}, logLock, false) 224 | 225 | case verbose == verbosityNormal: 226 | stdout = lineflushwriter.New( 227 | prefixwriter.New( 228 | nopCloser{stdoutBackend}, 229 | node.address.domain+" ", 230 | ), 231 | logLock, 232 | true, 233 | ) 234 | 235 | stderr = lineflushwriter.New( 236 | prefixwriter.New( 237 | nopCloser{stderrBackend}, 238 | node.address.domain+" ", 239 | ), 240 | logLock, 241 | true, 242 | ) 243 | 244 | default: 245 | stdout = lineflushwriter.New( 246 | prefixwriter.New( 247 | newDebugWriter(logger), 248 | "{cmd} "+node.String()+" ", 249 | ), 250 | logLock, 251 | false, 252 | ) 253 | 254 | stderr = lineflushwriter.New( 255 | prefixwriter.New( 256 | newDebugWriter(logger), 257 | "{cmd} "+node.String()+" ", 258 | ), 259 | logLock, 260 | false, 261 | ) 262 | } 263 | 264 | stdout = &statusBarUpdateWriter{stdout} 265 | stderr = &statusBarUpdateWriter{stderr} 266 | 267 | if outputLock != (*sync.Mutex)(nil) { 268 | sharedLock := newSharedLock(outputLock, 2) 269 | 270 | stdout = newLockedWriter(stdout, sharedLock) 271 | stderr = newLockedWriter(stderr, sharedLock) 272 | } 273 | 274 | stdin, err := remoteCommand.StdinPipe() 275 | if err != nil { 276 | return nil, hierr.Errorf( 277 | err, 278 | `can't get stdin from remote command`, 279 | ) 280 | } 281 | 282 | return &remoteExecutionNode{ 283 | node: node, 284 | command: remoteCommand, 285 | 286 | stdin: stdin, 287 | stdout: stdout, 288 | stderr: stderr, 289 | }, nil 290 | } 291 | -------------------------------------------------------------------------------- /connect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "sync/atomic" 7 | "time" 8 | 9 | "github.com/reconquest/hierr-go" 10 | ) 11 | 12 | const ( 13 | longConnectionWarningTimeout = 2 * time.Second 14 | ) 15 | 16 | // connectToCluster tries to acquire atomic file lock on each of 17 | // specified remote nodes. lockFile is used to specify target lock file, it 18 | // must exist on every node. runnerFactory will be used to make connection 19 | // to remote node. If noLockFail is given, then only warning will be printed 20 | // if lock process has been failed. 21 | func connectToCluster( 22 | lockFile string, 23 | runnerFactory runnerFactory, 24 | addresses []address, 25 | noLock bool, 26 | noLockFail bool, 27 | noConnFail bool, 28 | heartbeat func(*distributedLockNode), 29 | ) (*distributedLock, error) { 30 | var ( 31 | cluster = &distributedLock{} 32 | 33 | errors = make(chan error, 0) 34 | 35 | nodeAddMutex = &sync.Mutex{} 36 | ) 37 | 38 | status := &struct { 39 | Phase string 40 | Total int64 41 | Fails int64 42 | Success int64 43 | }{ 44 | Phase: `lock`, 45 | Total: int64(len(addresses)), 46 | } 47 | 48 | if noLock { 49 | status.Phase = `connect` 50 | } 51 | 52 | setStatus(status) 53 | 54 | for _, nodeAddress := range addresses { 55 | go func(nodeAddress address) { 56 | pool.run(func() { 57 | failed := false 58 | 59 | node, err := connectToNode(cluster, runnerFactory, nodeAddress) 60 | if err != nil { 61 | atomic.AddInt64(&status.Fails, 1) 62 | atomic.AddInt64(&status.Total, -1) 63 | 64 | if noConnFail { 65 | failed = true 66 | warningln(err) 67 | } else { 68 | errors <- err 69 | return 70 | } 71 | } else { 72 | if !noLock { 73 | err = node.lock(lockFile) 74 | if err != nil { 75 | if noLockFail { 76 | warningln(err) 77 | } else { 78 | errors <- err 79 | return 80 | } 81 | } else { 82 | go heartbeat(node) 83 | } 84 | } 85 | } 86 | 87 | textStatus := "established" 88 | if failed { 89 | textStatus = "failed" 90 | } else { 91 | atomic.AddInt64(&status.Success, 1) 92 | 93 | nodeAddMutex.Lock() 94 | defer nodeAddMutex.Unlock() 95 | 96 | cluster.nodes = append(cluster.nodes, node) 97 | } 98 | 99 | debugf( 100 | `%4d/%d (%d failed) connection %s: %s`, 101 | status.Success, 102 | status.Total, 103 | status.Fails, 104 | textStatus, 105 | nodeAddress, 106 | ) 107 | 108 | errors <- nil 109 | }) 110 | }(nodeAddress) 111 | } 112 | 113 | erronous := 0 114 | topError := hierr.Push(`can't connect to nodes`) 115 | for range addresses { 116 | err := <-errors 117 | if err != nil { 118 | erronous++ 119 | 120 | topError = hierr.Push(topError, err) 121 | } 122 | } 123 | 124 | if erronous > 0 { 125 | return nil, hierr.Push( 126 | fmt.Errorf( 127 | `connection to %d of %d nodes failed`, 128 | erronous, 129 | len(addresses), 130 | ), 131 | topError, 132 | ) 133 | } 134 | 135 | return cluster, nil 136 | } 137 | 138 | func connectToNode( 139 | cluster *distributedLock, 140 | runnerFactory runnerFactory, 141 | address address, 142 | ) (*distributedLockNode, error) { 143 | tracef(`connecting to address: '%s'`, address) 144 | 145 | done := make(chan struct{}, 0) 146 | 147 | go func() { 148 | select { 149 | case <-done: 150 | return 151 | 152 | case <-time.After(longConnectionWarningTimeout): 153 | warningf( 154 | "still connecting to address after %s: %s", 155 | longConnectionWarningTimeout, 156 | address, 157 | ) 158 | 159 | <-done 160 | } 161 | }() 162 | 163 | defer func() { 164 | done <- struct{}{} 165 | }() 166 | 167 | runner, err := runnerFactory(address) 168 | if err != nil { 169 | return nil, hierr.Errorf( 170 | err, 171 | `can't connect to address: %s`, 172 | address, 173 | ) 174 | } 175 | 176 | return &distributedLockNode{ 177 | address: address, 178 | runner: runner, 179 | }, nil 180 | } 181 | -------------------------------------------------------------------------------- /debug_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/kovetskiy/lorg" 7 | ) 8 | 9 | type debugWriter struct { 10 | log *lorg.Log 11 | } 12 | 13 | func newDebugWriter(log *lorg.Log) debugWriter { 14 | return debugWriter{ 15 | log: log, 16 | } 17 | } 18 | 19 | func (writer debugWriter) Write(data []byte) (int, error) { 20 | writer.log.Debug(strings.TrimSuffix(string(data), "\n")) 21 | 22 | return len(data), nil 23 | } 24 | 25 | func (writer debugWriter) Close() error { 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reconquest/orgalorg/17aad3570a15099fc52949ac4359b350f045ca84/demo.gif -------------------------------------------------------------------------------- /distributed_lock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type distributedLock struct { 4 | nodes []*distributedLockNode 5 | } 6 | -------------------------------------------------------------------------------- /distributed_lock_node.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/reconquest/hierr-go" 11 | "github.com/reconquest/lineflushwriter-go" 12 | "github.com/reconquest/prefixwriter-go" 13 | "github.com/reconquest/runcmd" 14 | ) 15 | 16 | const ( 17 | lockAcquiredString = `acquired` 18 | lockLockedString = `locked` 19 | ) 20 | 21 | type distributedLockNode struct { 22 | address address 23 | runner runcmd.Runner 24 | 25 | connection *distributedLockConnection 26 | } 27 | 28 | func (node *distributedLockNode) String() string { 29 | return node.address.String() 30 | } 31 | 32 | type distributedLockConnection struct { 33 | stdin io.WriteCloser 34 | stdout io.Reader 35 | } 36 | 37 | func (node *distributedLockNode) lock( 38 | filename string, 39 | ) error { 40 | lockCommandLine := []string{ 41 | "sh", "-c", fmt.Sprintf( 42 | `flock -nx %s -c 'printf "%s\n" && cat' || printf "%s\n"`, 43 | filename, lockAcquiredString, lockLockedString, 44 | ), 45 | } 46 | 47 | logMutex := &sync.Mutex{} 48 | 49 | traceln(hierr.Errorf( 50 | lockCommandLine, 51 | `%s running lock command`, 52 | node, 53 | )) 54 | 55 | lockCommand := node.runner.Command( 56 | lockCommandLine[0], 57 | lockCommandLine[1:]..., 58 | ) 59 | 60 | stdout, err := lockCommand.StdoutPipe() 61 | if err != nil { 62 | return hierr.Errorf( 63 | err, 64 | `can't get control stdout pipe from lock process`, 65 | ) 66 | } 67 | 68 | stderr := lineflushwriter.New( 69 | prefixwriter.New( 70 | newDebugWriter(logger), 71 | fmt.Sprintf("%s {flock} ", node.String()), 72 | ), 73 | logMutex, 74 | true, 75 | ) 76 | 77 | lockCommand.SetStderr(stderr) 78 | 79 | stdin, err := lockCommand.StdinPipe() 80 | if err != nil { 81 | return hierr.Errorf( 82 | err, 83 | `can't get control stdin pipe to lock process`, 84 | ) 85 | } 86 | 87 | err = lockCommand.Start() 88 | if err != nil { 89 | return hierr.Errorf( 90 | err, 91 | `%s can't start lock command: '%s`, 92 | node, lockCommandLine, 93 | ) 94 | } 95 | 96 | line, err := bufio.NewReader(stdout).ReadString('\n') 97 | if err != nil { 98 | return hierr.Errorf( 99 | err, 100 | `%s can't read lock status line from lock process`, 101 | node, 102 | ) 103 | } 104 | 105 | switch strings.TrimSpace(line) { 106 | case lockAcquiredString: 107 | // pass 108 | 109 | case lockLockedString: 110 | return fmt.Errorf( 111 | `%s can't acquire lock, `+ 112 | `lock already obtained by another process `+ 113 | `or unavailable`, 114 | node, 115 | ) 116 | 117 | default: 118 | return fmt.Errorf( 119 | `%s unexpected reply string encountered `+ 120 | `instead of '%s' or '%s': '%s'`, 121 | node, lockAcquiredString, lockLockedString, 122 | line, 123 | ) 124 | } 125 | 126 | tracef(`lock acquired: '%s' on '%s'`, node, filename) 127 | 128 | node.connection = &distributedLockConnection{ 129 | stdin: stdin, 130 | stdout: stdout, 131 | } 132 | 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type file struct { 4 | path string 5 | size int 6 | } 7 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/reconquest/loreley" 5 | ) 6 | 7 | type ( 8 | outputFormat int 9 | ) 10 | 11 | const ( 12 | outputFormatText outputFormat = iota 13 | outputFormatJSON 14 | ) 15 | 16 | func parseOutputFormat( 17 | args map[string]interface{}, 18 | ) outputFormat { 19 | 20 | formatType := outputFormatText 21 | if args["--json"].(bool) { 22 | formatType = outputFormatJSON 23 | } 24 | 25 | return formatType 26 | } 27 | 28 | func parseColorMode(args map[string]interface{}) loreley.ColorizeMode { 29 | switch args["--color"].(string) { 30 | case "always": 31 | return loreley.ColorizeAlways 32 | 33 | case "auto": 34 | return loreley.ColorizeOnTTY 35 | 36 | case "never": 37 | return loreley.ColorizeNever 38 | } 39 | 40 | return loreley.ColorizeNever 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/reconquest/orgalorg 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.6.2 7 | github.com/ScaleFT/sshkeys v1.2.0 8 | github.com/davidmz/go-pageant v1.0.2 9 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 10 | github.com/kovetskiy/lorg v1.2.0 11 | github.com/mattn/go-shellwords v1.0.12 12 | github.com/reconquest/barely v0.0.0-20211011075640-c4e789dc39f8 13 | github.com/reconquest/colorgful v0.0.0-20210914131800-3b7d32bf77e5 14 | github.com/reconquest/hierr-go v0.0.0-20170824213838-7d09c0176fd2 15 | github.com/reconquest/karma-go v1.5.0 16 | github.com/reconquest/lineflushwriter-go v0.0.0-20200921103343-b9b8d10a6851 17 | github.com/reconquest/loreley v0.0.0-20211011075601-29b1d7b0ad91 18 | github.com/reconquest/prefixwriter-go v0.0.0-20220109120116-49d119edab5d 19 | github.com/reconquest/runcmd v0.0.0-20210624093503-38dc6a8c98bd 20 | github.com/stretchr/testify v1.8.2 21 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 22 | golang.org/x/crypto v0.26.0 23 | ) 24 | 25 | require ( 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | github.com/reconquest/nopio-go v0.0.0-20161213101805-20796acb207f // indirect 30 | github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446 // indirect 31 | golang.org/x/sys v0.24.0 // indirect 32 | golang.org/x/term v0.23.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= 2 | github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 4 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 5 | github.com/ScaleFT/sshkeys v1.2.0 h1:5BRp6rTVIhJzXT3VcUQrKgXR8zWA3sOsNeuyW15WUA8= 6 | github.com/ScaleFT/sshkeys v1.2.0/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= 11 | github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= 12 | github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a h1:saTgr5tMLFnmy/yg3qDTft4rE5DY2uJ/cCxCe3q0XTU= 13 | github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a/go.mod h1:Bw9BbhOJVNR+t0jCqx2GC6zv0TGBsShs56Y3gfSCvl0= 14 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= 15 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 16 | github.com/kovetskiy/lorg v1.2.0 h1:wNIUT/VOhcjKOmizDClZLvchbKFGW+dzf9fQXbSVS5E= 17 | github.com/kovetskiy/lorg v1.2.0/go.mod h1:rdiamaIRUCkX9HtFZd0D9dQqUbad21hipHk+sat7Z6s= 18 | github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= 19 | github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/reconquest/barely v0.0.0-20211011075640-c4e789dc39f8 h1:nKTbEVRQCCTLOTL45sE3QZQVFOu0VroPaA/BobaGJGU= 23 | github.com/reconquest/barely v0.0.0-20211011075640-c4e789dc39f8/go.mod h1:s9u11Az+/tGw07dFMZItVz57LvjhdeGG8gLRladQ170= 24 | github.com/reconquest/colorgful v0.0.0-20210914131800-3b7d32bf77e5 h1:zQyJkHMuhL9DZpK2TgepcpW5eRbuyzgBEaE1TMsemJc= 25 | github.com/reconquest/colorgful v0.0.0-20210914131800-3b7d32bf77e5/go.mod h1:osXRwScBYylQ/ZvfwWXOIqGcjRCk+p0nc5oFnqqgVAY= 26 | github.com/reconquest/hierr-go v0.0.0-20170824213838-7d09c0176fd2 h1:v3ctQXyIHprCI7s42LGmMYbulnnpjeD4zcvq78D1ung= 27 | github.com/reconquest/hierr-go v0.0.0-20170824213838-7d09c0176fd2/go.mod h1:dF8sYs86hXr+kKjDVvxDZYMUsTm5yr0PFQqLer/xsrk= 28 | github.com/reconquest/karma-go v0.0.0-20200928103525-22da92476de6/go.mod h1:yuQiKpTdmXSX7E+h+3dD4jx09P/gHc67mRxN3eFLt7o= 29 | github.com/reconquest/karma-go v1.5.0 h1:Chn4LtauwnvKfz13ZbmGNrRLKO1NciExHQSOBOsQqt4= 30 | github.com/reconquest/karma-go v1.5.0/go.mod h1:52XRXXa2ec/VNrlCirwasdJfNmjI1O87q098gmqILh0= 31 | github.com/reconquest/lineflushwriter-go v0.0.0-20200921103343-b9b8d10a6851 h1:o2J6abTvFylmmepI2GgNSeZZlUUz4xz3UStWIY6EXLk= 32 | github.com/reconquest/lineflushwriter-go v0.0.0-20200921103343-b9b8d10a6851/go.mod h1:tpO+vNC9ppBvJErZXrQpfwiVDuza5k1nRWH7SrutKtY= 33 | github.com/reconquest/loreley v0.0.0-20211011075601-29b1d7b0ad91 h1:HuOjsuPj5mcsq/EagIovIS240SNUA+u4vLak5GcS12s= 34 | github.com/reconquest/loreley v0.0.0-20211011075601-29b1d7b0ad91/go.mod h1:nz+IRceapGhOTxO5cOJiu0Xc2qfEkEnR+qS6damNixI= 35 | github.com/reconquest/nopio-go v0.0.0-20161213101805-20796acb207f h1:IA86P8l4xRMNbd021H3fGgdIT/B5D/bvdT0xsMqOC/c= 36 | github.com/reconquest/nopio-go v0.0.0-20161213101805-20796acb207f/go.mod h1:xyNcU6XK6bQpR3pVBIHFPi4Xh6NV/FBKnWkYV8lwv1k= 37 | github.com/reconquest/prefixwriter-go v0.0.0-20220109120116-49d119edab5d h1:RXcAP8D7DgwBgECxBvTKurPpWU+yGGEPg+HkvrF7hWI= 38 | github.com/reconquest/prefixwriter-go v0.0.0-20220109120116-49d119edab5d/go.mod h1:vJ27/NK0BF5L4HaVVIQZRqOrGnCMRD0CraEjyPjK5HI= 39 | github.com/reconquest/runcmd v0.0.0-20210624093503-38dc6a8c98bd h1:Smgn0s4JKslWhFSzODN7jqNCNYbr+OPqF5tuqFCsReY= 40 | github.com/reconquest/runcmd v0.0.0-20210624093503-38dc6a8c98bd/go.mod h1:Za2j8JxxWX/f/NIdRd5wPhxQVGLnZUH4xcaAPUxXJz4= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 43 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 44 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 45 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 46 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 47 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 48 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 49 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 50 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 51 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 52 | github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446 h1:75pcOSsb40+ub185cJI7g5uykl9Uu76rD5ONzK/4s40= 53 | github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446/go.mod h1:NtepZ8TEXErPsmQDMUoN72f8aIy4+xNinSJ3f1giess= 54 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 55 | golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 56 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 57 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 58 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 59 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 60 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 61 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 62 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 63 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 64 | golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 69 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 70 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 71 | golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= 72 | golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= 73 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 74 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 75 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 77 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 78 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 79 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 80 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 81 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 82 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 83 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | -------------------------------------------------------------------------------- /heartbeat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/reconquest/hierr-go" 11 | "github.com/reconquest/runcmd" 12 | ) 13 | 14 | const ( 15 | heartbeatPing = "PING" 16 | ) 17 | 18 | // heartbeat runs infinite process of sending test messages to the connected 19 | // node. All heartbeats to all nodes are connected to each other, so if one 20 | // heartbeat routine exits, all heartbeat routines will exit, because in that 21 | // case orgalorg can't guarantee global lock. 22 | func heartbeat( 23 | period time.Duration, 24 | node *distributedLockNode, 25 | canceler *sync.Cond, 26 | ) { 27 | abort := make(chan struct{}, 0) 28 | 29 | // Internal go-routine for listening abort broadcast and finishing current 30 | // heartbeat process. 31 | go func() { 32 | canceler.L.Lock() 33 | canceler.Wait() 34 | canceler.L.Unlock() 35 | 36 | abort <- struct{}{} 37 | }() 38 | 39 | // Finish finishes current go-routine and send abort broadcast to all 40 | // connected go-routines. 41 | finish := func(code int) { 42 | canceler.L.Lock() 43 | canceler.Broadcast() 44 | canceler.L.Unlock() 45 | 46 | <-abort 47 | 48 | if remote, ok := node.runner.(*runcmd.Remote); ok { 49 | tracef("%s closing connection", node.String()) 50 | err := remote.CloseConnection() 51 | if err != nil { 52 | warningf( 53 | "%s", 54 | hierr.Errorf( 55 | err, 56 | "%s error while closing connection", 57 | node.String(), 58 | ), 59 | ) 60 | } 61 | } 62 | 63 | exit(code) 64 | } 65 | 66 | ticker := time.Tick(period) 67 | 68 | // Infinite loop of heartbeating. It will send heartbeat message, wait 69 | // fraction of send timeout time and try to receive heartbeat response. 70 | // If no response received, heartbeat process aborts. 71 | for { 72 | _, err := io.WriteString(node.connection.stdin, heartbeatPing+"\n") 73 | if err != nil { 74 | errorf( 75 | "%s", 76 | hierr.Errorf( 77 | err, 78 | `%s can't send heartbeat`, 79 | node.String(), 80 | ), 81 | ) 82 | 83 | finish(2) 84 | } 85 | 86 | select { 87 | case <-abort: 88 | return 89 | 90 | case <-ticker: 91 | // pass 92 | } 93 | 94 | ping, err := bufio.NewReader(node.connection.stdout).ReadString('\n') 95 | if err != nil { 96 | errorf( 97 | "%s", 98 | hierr.Errorf( 99 | err, 100 | `%s can't receive heartbeat`, 101 | node.String(), 102 | ), 103 | ) 104 | 105 | finish(2) 106 | } 107 | 108 | if strings.TrimSpace(ping) != heartbeatPing { 109 | errorf( 110 | `%s received unexpected heartbeat ping: '%s'`, 111 | node.String(), 112 | ping, 113 | ) 114 | 115 | finish(2) 116 | } 117 | 118 | tracef(`%s heartbeat`, node.String()) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /json_output_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | ) 7 | 8 | type jsonOutputWriter struct { 9 | stream string 10 | node string 11 | host string 12 | 13 | output io.Writer 14 | } 15 | 16 | func (writer *jsonOutputWriter) Write(data []byte) (int, error) { 17 | if len(data) == 0 { 18 | return 0, nil 19 | } 20 | 21 | message := map[string]interface{}{ 22 | "stream": writer.stream, 23 | } 24 | 25 | if writer.node == "" { 26 | message["node"] = nil 27 | } else { 28 | message["node"] = writer.node 29 | } 30 | 31 | if writer.host == "" { 32 | message["host"] = nil 33 | } else { 34 | message["host"] = writer.host 35 | } 36 | 37 | message["body"] = string(data) 38 | 39 | jsonMessage, err := json.Marshal(message) 40 | if err != nil { 41 | return 0, err 42 | } 43 | 44 | _, err = writer.output.Write(append(jsonMessage, '\n')) 45 | if err != nil { 46 | return 0, err 47 | } 48 | 49 | return len(data), nil 50 | } 51 | -------------------------------------------------------------------------------- /key.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/pem" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | 11 | "github.com/ScaleFT/sshkeys" 12 | "github.com/reconquest/hierr-go" 13 | "github.com/youmark/pkcs8" 14 | "golang.org/x/crypto/ssh" 15 | "golang.org/x/crypto/ssh/agent" 16 | "golang.org/x/crypto/ssh/terminal" 17 | ) 18 | 19 | type sshKey struct { 20 | raw []byte 21 | block *pem.Block 22 | extra []byte 23 | private interface{} 24 | passphrase []byte 25 | } 26 | 27 | func (key *sshKey) validate() error { 28 | if len(key.extra) != 0 { 29 | return hierr.Errorf( 30 | errors.New(string(key.extra)), 31 | `extra data found in the SSH key`, 32 | ) 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (key *sshKey) isOpenSSH() bool { 39 | return key.block.Type == "OPENSSH PRIVATE KEY" 40 | } 41 | 42 | func (key *sshKey) isPKCS8() bool { 43 | return key.block.Type == "ENCRYPTED PRIVATE KEY" || 44 | key.block.Type == "PRIVATE KEY" 45 | } 46 | 47 | func (key *sshKey) isEncrypted() bool { 48 | if key.block.Type == "ENCRYPTED PRIVATE KEY" { 49 | return true 50 | } 51 | 52 | if strings.Contains(key.block.Headers["Proc-Type"], "ENCRYPTED") { 53 | return true 54 | } 55 | 56 | if key.isOpenSSH() { 57 | _, err := ssh.ParseRawPrivateKey([]byte(key.raw)) 58 | return err != nil 59 | } 60 | 61 | return false 62 | } 63 | 64 | func (key *sshKey) parse() error { 65 | var err error 66 | switch { 67 | case key.isOpenSSH() && key.isEncrypted(): 68 | key.private, err = sshkeys.ParseEncryptedRawPrivateKey( 69 | []byte(key.raw), 70 | key.passphrase, 71 | ) 72 | 73 | case key.isPKCS8() && key.isEncrypted(): 74 | key.private, err = pkcs8.ParsePKCS8PrivateKey( 75 | key.block.Bytes, 76 | key.passphrase, 77 | ) 78 | 79 | case key.isPKCS8(): 80 | key.private, err = pkcs8.ParsePKCS8PrivateKey( 81 | key.block.Bytes, 82 | nil, 83 | ) 84 | 85 | case key.isEncrypted(): 86 | key.private, err = ssh.ParseRawPrivateKeyWithPassphrase( 87 | []byte(key.raw), 88 | key.passphrase, 89 | ) 90 | 91 | default: 92 | key.private, err = ssh.ParseRawPrivateKey( 93 | []byte(key.raw), 94 | ) 95 | } 96 | return err 97 | } 98 | 99 | func readSSHKey(keyring agent.Agent, path string) error { 100 | var key sshKey 101 | var err error 102 | 103 | key.raw, err = ioutil.ReadFile(path) 104 | if err != nil { 105 | return hierr.Errorf( 106 | err, 107 | `can't read SSH key from file`, 108 | ) 109 | } 110 | 111 | key.block, key.extra = pem.Decode(key.raw) 112 | 113 | err = key.validate() 114 | if err != nil { 115 | return err 116 | } 117 | 118 | if key.isEncrypted() { 119 | key.passphrase, err = readPassword(sshPassphrasePrompt) 120 | if err != nil { 121 | return hierr.Errorf( 122 | err, 123 | `can't read key passphrase`, 124 | ) 125 | } 126 | } 127 | 128 | err = key.parse() 129 | if err != nil { 130 | if err == sshkeys.ErrIncorrectPassword { 131 | err = errors.New("invalid passphrase for private key specified") 132 | } 133 | 134 | return hierr.Errorf( 135 | err, 136 | "unable to parse ssh key", 137 | ) 138 | } 139 | 140 | keyring.Add(agent.AddedKey{ 141 | PrivateKey: key.private, 142 | Comment: "passed by orgalorg", 143 | }) 144 | 145 | return nil 146 | } 147 | 148 | func readPassword(prompt string) ([]byte, error) { 149 | fmt.Fprintf(os.Stderr, prompt) 150 | 151 | tty, err := os.Open("/dev/tty") 152 | if err != nil { 153 | return nil, hierr.Errorf( 154 | err, 155 | `TTY is required for reading password, `+ 156 | `but /dev/tty can't be opened`, 157 | ) 158 | } 159 | 160 | password, err := terminal.ReadPassword(int(tty.Fd())) 161 | if err != nil { 162 | return nil, hierr.Errorf( 163 | err, 164 | `can't read password`, 165 | ) 166 | } 167 | 168 | if prompt != "" { 169 | fmt.Fprintln(os.Stderr) 170 | } 171 | 172 | return password, nil 173 | } 174 | -------------------------------------------------------------------------------- /locked_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | type sharedLock struct { 9 | sync.Locker 10 | 11 | held *struct { 12 | sync.Locker 13 | 14 | clients int 15 | locked bool 16 | } 17 | } 18 | 19 | func newSharedLock(lock sync.Locker, clients int) *sharedLock { 20 | return &sharedLock{ 21 | Locker: lock, 22 | 23 | held: &struct { 24 | sync.Locker 25 | 26 | clients int 27 | locked bool 28 | }{ 29 | Locker: &sync.Mutex{}, 30 | 31 | clients: clients, 32 | locked: false, 33 | }, 34 | } 35 | } 36 | 37 | func (mutex *sharedLock) Lock() { 38 | mutex.held.Lock() 39 | defer mutex.held.Unlock() 40 | 41 | if !mutex.held.locked { 42 | mutex.Locker.Lock() 43 | 44 | mutex.held.locked = true 45 | } 46 | } 47 | 48 | func (mutex *sharedLock) Unlock() { 49 | mutex.held.Lock() 50 | defer mutex.held.Unlock() 51 | 52 | mutex.held.clients-- 53 | 54 | if mutex.held.clients == 0 && mutex.held.locked { 55 | mutex.held.locked = false 56 | 57 | mutex.Locker.Unlock() 58 | } 59 | } 60 | 61 | type lockedWriter struct { 62 | writer io.WriteCloser 63 | 64 | lock sync.Locker 65 | } 66 | 67 | func newLockedWriter( 68 | writer io.WriteCloser, 69 | lock sync.Locker, 70 | ) *lockedWriter { 71 | return &lockedWriter{ 72 | writer: writer, 73 | lock: lock, 74 | } 75 | } 76 | 77 | func (writer *lockedWriter) Write(data []byte) (int, error) { 78 | writer.lock.Lock() 79 | 80 | return writer.writer.Write(data) 81 | } 82 | 83 | func (writer *lockedWriter) Close() error { 84 | writer.lock.Unlock() 85 | 86 | return writer.writer.Close() 87 | } 88 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/kovetskiy/lorg" 9 | "github.com/reconquest/hierr-go" 10 | ) 11 | 12 | func setLoggerOutputFormat(logger *lorg.Log, format outputFormat) { 13 | if format == outputFormatJSON { 14 | logger.SetOutput(&jsonOutputWriter{ 15 | stream: `stderr`, 16 | node: ``, 17 | output: os.Stderr, 18 | }) 19 | } 20 | } 21 | 22 | func setLoggerVerbosity(level verbosity, logger *lorg.Log) { 23 | logger.SetLevel(lorg.LevelWarning) 24 | 25 | switch { 26 | case level >= verbosityTrace: 27 | logger.SetLevel(lorg.LevelTrace) 28 | 29 | case level >= verbosityDebug: 30 | logger.SetLevel(lorg.LevelDebug) 31 | 32 | case level >= verbosityNormal: 33 | logger.SetLevel(lorg.LevelInfo) 34 | } 35 | } 36 | 37 | func setLoggerStyle(logger *lorg.Log, style lorg.Formatter) { 38 | logger.SetFormat(style) 39 | logger.SetIndentLines(true) 40 | 41 | logger.SetShiftIndent(28) 42 | } 43 | 44 | func tracef(format string, args ...interface{}) { 45 | args = serializeErrors(args) 46 | 47 | logger.Tracef(format, args...) 48 | 49 | drawStatus() 50 | } 51 | 52 | func traceln(args ...interface{}) { 53 | tracef("%s", fmt.Sprint(serializeErrors(args)...)) 54 | } 55 | 56 | func debugf(format string, args ...interface{}) { 57 | args = serializeErrors(args) 58 | 59 | logger.Debugf(format, args...) 60 | 61 | drawStatus() 62 | } 63 | 64 | func debugln(args ...interface{}) { 65 | debugf("%s", fmt.Sprint(serializeErrors(args)...)) 66 | } 67 | 68 | func infof(format string, args ...interface{}) { 69 | args = serializeErrors(args) 70 | 71 | logger.Infof(format, args...) 72 | 73 | drawStatus() 74 | } 75 | 76 | func infoln(args ...interface{}) { 77 | infof("%s", fmt.Sprint(serializeErrors(args)...)) 78 | } 79 | 80 | func warningf(format string, args ...interface{}) { 81 | args = serializeErrors(args) 82 | 83 | if verbose <= verbosityQuiet { 84 | return 85 | } 86 | 87 | logger.Warningf(format, args...) 88 | 89 | drawStatus() 90 | } 91 | 92 | func warningln(args ...interface{}) { 93 | warningf("%s", fmt.Sprint(serializeErrors(args)...)) 94 | } 95 | 96 | func errorf(format string, args ...interface{}) { 97 | args = serializeErrors(args) 98 | 99 | logger.Errorf(format, args...) 100 | } 101 | 102 | func errorln(args ...interface{}) { 103 | errorf("%s", fmt.Sprint(serializeErrors(args)...)) 104 | } 105 | 106 | func fatalf(format string, args ...interface{}) { 107 | args = serializeErrors(args) 108 | 109 | clearStatus() 110 | 111 | logger.Fatalf(format, args...) 112 | 113 | exit(1) 114 | } 115 | 116 | func fatalln(args ...interface{}) { 117 | fatalf("%s", fmt.Sprint(serializeErrors(args)...)) 118 | } 119 | 120 | func serializeErrors(args []interface{}) []interface{} { 121 | for i, arg := range args { 122 | if err, ok := arg.(error); ok { 123 | args[i] = serializeError(err) 124 | } 125 | } 126 | 127 | return args 128 | } 129 | 130 | func setStatus(status interface{}) { 131 | if statusbar == nil { 132 | return 133 | } 134 | 135 | clearStatus() 136 | 137 | statusbar.SetStatus(status) 138 | 139 | drawStatus() 140 | } 141 | 142 | func shouldDrawStatus() bool { 143 | if statusbar == nil { 144 | return false 145 | } 146 | 147 | if format != outputFormatText { 148 | return false 149 | } 150 | 151 | if verbose <= verbosityQuiet { 152 | return false 153 | } 154 | 155 | return true 156 | } 157 | 158 | func drawStatus() { 159 | if !shouldDrawStatus() { 160 | return 161 | } 162 | 163 | err := statusbar.Render(os.Stderr) 164 | if err != nil { 165 | errorf( 166 | "%s", hierr.Errorf( 167 | err, 168 | `can't draw status bar`, 169 | ), 170 | ) 171 | } 172 | } 173 | 174 | func clearStatus() { 175 | if !shouldDrawStatus() { 176 | return 177 | } 178 | 179 | statusbar.Clear(os.Stderr) 180 | } 181 | 182 | func serializeError(err error) string { 183 | if format == outputFormatText { 184 | return fmt.Sprint(err) 185 | } 186 | 187 | if hierarchicalError, ok := err.(hierr.Error); ok { 188 | serializedError := fmt.Sprint(hierarchicalError.Nested) 189 | switch nested := hierarchicalError.Nested.(type) { 190 | case error: 191 | serializedError = serializeError(nested) 192 | 193 | case []hierr.NestedError: 194 | serializeErrorParts := []string{} 195 | 196 | for _, nestedPart := range nested { 197 | serializedPart := fmt.Sprint(nestedPart) 198 | switch part := nestedPart.(type) { 199 | case error: 200 | serializedPart = serializeError(part) 201 | 202 | case string: 203 | serializedPart = part 204 | } 205 | 206 | serializeErrorParts = append( 207 | serializeErrorParts, 208 | serializedPart, 209 | ) 210 | } 211 | 212 | serializedError = strings.Join(serializeErrorParts, "; ") 213 | } 214 | 215 | return hierarchicalError.Message + ": " + serializedError 216 | } 217 | 218 | return err.Error() 219 | } 220 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "os/user" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/docopt/docopt-go" 15 | "github.com/kovetskiy/lorg" 16 | "github.com/mattn/go-shellwords" 17 | "github.com/reconquest/barely" 18 | "github.com/reconquest/hierr-go" 19 | "github.com/reconquest/loreley" 20 | "github.com/reconquest/runcmd" 21 | "golang.org/x/crypto/ssh/agent" 22 | ) 23 | 24 | var version = "[manual build]" 25 | 26 | const usage = `orgalorg - files synchronization on many hosts. 27 | 28 | First of all, orgalorg will try to acquire global cluster lock by flock'ing 29 | file, specified by '--lock-file' on each host. If at least one flock fails, 30 | then orgalorg will stop, unless '-t' flag is specified. 31 | 32 | orgalorg will create tar-archive from specified files, keeping file attributes 33 | and ownerships, then upload archive in parallel to the specified hosts and 34 | unpack it in the temporary directory (see '-r'). No further actions will be 35 | done until all hosts will unpack the archive. 36 | 37 | If '-S' flag specified, then sync command tool will be launched after upload 38 | (see '--sync-cmd'). Sync command tool can send stdout and stderr back to the 39 | orgalorg, but it needs to be compatible with following procotol. 40 | 41 | First of all, sync command tool and orgalorg communicate through stdout/stdin. 42 | All lines, that are not match protocol will be printed untouched. 43 | 44 | orgalorg first send hello message to the each running node, where '' 45 | is an unique string 46 | 47 | HELLO 48 | 49 | All consequent communication must be prefixed by that prefix, followed by 50 | space. 51 | 52 | Then, orgalorg will pass nodes list to each running node by sending 'NODE' 53 | commands, where '' is unique node identifier: 54 | 55 | NODE [CURRENT] 56 | 57 | 'CURRENT' will present next to the node record which currently executes sync 58 | tool. 59 | 60 | After nodes list is exchausted, orgalorg will send 'START' marker, that means 61 | sync tool may proceed with execution. 62 | 63 | START 64 | 65 | Then, sync command tool can reply with 'SYNC' messages, that will be 66 | broadcasted to all connected nodes by orgalorg: 67 | 68 | SYNC 69 | 70 | Broadcasted sync message will contain source node: 71 | 72 | SYNC 73 | 74 | Each node can decide, when to wait synchronizations, based on amount of 75 | received sync messages. 76 | 77 | Usage: 78 | orgalorg -h | --help 79 | orgalorg [options] [-v]... (-o ...|-s) -r= -U ... 80 | orgalorg [options] [-v]... (-o ...|-s) [-r=] [-g=]... -S [...|--no-upload] 81 | orgalorg [options] [-v]... (-o ...|-s) [-r=] -C [--] ... 82 | orgalorg [options] [-v]... (-o ...|-s) -L 83 | 84 | Operation mode options: 85 | -S --sync Sync. 86 | Synchronizes files on the specified hosts via 3-stage 87 | process: 88 | * global cluster locking (use -L to stop here); 89 | * tar-ing files on local machine, transmitting and 90 | unpacking files to the intermediate directory 91 | (-U to stop here); 92 | * launching sync command tool such as gunter; 93 | -L --lock Will stop right after locking, e.g. will not try to 94 | do sync whatsoever. Will keep lock until interrupted. 95 | -U --upload Upload files to specified directory and exit. 96 | -C --command Run specified command on all hosts and exit. 97 | 98 | Required options: 99 | -o --host Target host in format [@][:]. 100 | If value is started from '/' or from './', then it's 101 | considered file which should be used to read hosts 102 | from. 103 | -s --read-stdin Read hosts from stdin in addition to other flags. 104 | 105 | Options: 106 | -h --help Show this help. 107 | -k --key Identity file (private key), which will be used for 108 | authentication. This is default way of 109 | authentication. 110 | [default: $HOME/.ssh/id_rsa] 111 | -p --password Enable password authentication. 112 | Exclude '-k' option. 113 | Interactive TTY is required for reading password. 114 | -x --sudo Obtain root via 'sudo -n'. 115 | By default, orgalorg will not obtain root and do 116 | all actions from specified user. To change that 117 | behaviour, this option can be used. 118 | -y --no-lock Do not lock at all. 119 | -t --no-lock-fail Try to obtain global lock, but only print warning if 120 | it cannot be done, do not stop execution. 121 | -w --no-conn-fail Skip unreachable servers whatsoever. 122 | -r --root Specify root dir to extract files into. 123 | By default, orgalorg will create temporary directory 124 | inside of '$ROOT'. 125 | Removal of that directory is up to sync tool. 126 | -u --user Username used for connecting to all hosts by default. 127 | [default: $USER] 128 | -i --stdin Pass specified file as input for the command. 129 | -l --serial Run commands in serial mode, so they output will not 130 | interleave each other. Only one node is allowed to 131 | output, all other nodes will wait that node to 132 | finish. 133 | -q --quiet Be quiet, in command mode do not use prefixes. 134 | -v --verbose Print debug information on stderr. 135 | -V --version Print program version. 136 | 137 | Advanced options: 138 | --lock-file File to put lock onto. If not specified, value of '-r' 139 | will be used. If '-r' is not specified too, then 140 | use "$LOCK" as lock file. 141 | -e --relative Upload files by relative path. By default, all 142 | specified files will be uploaded on the target 143 | hosts by absolute paths, e.g. if you running 144 | orgalorg from '/tmp' dir with argument '-S x', 145 | then file will be uploaded into '/tmp/x' on the 146 | remote hosts. That option switches off that 147 | behavior. 148 | -n --sync-cmd Run specified sync command tool on each remote node. 149 | Orgalorg will communicate with sync command tool via 150 | stdin. See 'Protocol commands' below. 151 | [default: /usr/lib/orgalorg/sync "${@}"] 152 | -g --arg Arguments to pass untouched to the sync command tool. 153 | No modification will be done to the passed arg, so 154 | take care about escaping. 155 | -m --simple Treat sync command as simple tool, which is not 156 | support specified protocol messages. No sync 157 | is possible in that case and all stdout and stderr 158 | will be passed untouched back to the orgalorg. 159 | --shell Use following shell wrapper. '{}' will be replaced 160 | with properly escaped command. If empty, then no 161 | shell wrapper will be used. If any args are given 162 | using '-g', they will be appended to shell 163 | invocation. 164 | [default: ` + defaultRemoteExecutionShell + `] 165 | -d --threads Set threads count which will be used for connection, 166 | locking and execution commands. 167 | [default: 16]. 168 | ` + helpNoPreserveUidGid + ` --no-upload Do not upload files while syncing. 169 | 170 | Output format and colors options: 171 | --json Output everything in line-by-line JSON format, 172 | printing objects with fields: 173 | * 'stream' = 'stdout' | 'stderr'; 174 | * 'node' = | null (if internal output); 175 | * 'body' = 176 | --bar-format Format for the status bar. 177 | Full Go template syntax is available with delims 178 | of '{' and '}'. 179 | See https://github.com/reconquest/barely for more 180 | info. 181 | For example, run orgalorg with '-vv' flag. 182 | Two embedded themes are available by their names: 183 | ` + themeDark + ` and ` + themeLight + ` 184 | [default: ` + themeDefault + `] 185 | --log-format Format for the logs. 186 | See https://github.com/reconquest/colorgful for more 187 | info. 188 | [default: ` + themeDefault + `] 189 | --colors-dark Set all available formats to predefined dark theme. 190 | --colors-light Set all available formats to predefined light theme. 191 | --color Specify, whether to use colors: 192 | * never - disable colors; 193 | * auto - use colors only when TTY presents. 194 | * always - always use colorized output. 195 | [default: auto] 196 | 197 | Timeout options: 198 | -c --conn-timeout Remote host connection timeout in milliseconds. 199 | [default: 10000] 200 | -j --send-timeout Remote host connection data sending timeout in 201 | milliseconds. [default: 60000] 202 | NOTE: send timeout will be also used for the 203 | heartbeat messages, that orgalorg and connected nodes 204 | exchanges through synchronization process. 205 | -z --recv-timeout Remote host connection data receiving timeout in 206 | milliseconds. [default: 60000] 207 | -a --keep-alive How long to keep connection keeped alive after session 208 | ends. [default: 10000] 209 | ` 210 | 211 | const ( 212 | defaultSSHPort = 22 213 | 214 | // heartbeatTimeoutCoefficient will be multiplied to send timeout and 215 | // resulting value will be used as time interval between heartbeats. 216 | heartbeatTimeoutCoefficient = 0.8 217 | 218 | runsDirectory = "/var/run/orgalorg/" 219 | 220 | defaultLockFile = "/" 221 | 222 | defaultRemoteExecutionShell = "bash -c '{}'" 223 | ) 224 | 225 | var ( 226 | sshPasswordPrompt = "Password: " 227 | sshPassphrasePrompt = "Key passphrase: " 228 | ) 229 | 230 | var ( 231 | logger = lorg.NewLog() 232 | verbose = verbosityNormal 233 | format = outputFormatText 234 | 235 | pool *threadPool 236 | statusbar *barely.StatusBar 237 | ) 238 | 239 | var exit = os.Exit 240 | 241 | func main() { 242 | args := parseArgs() 243 | 244 | verbose = parseVerbosity(args) 245 | 246 | setLoggerVerbosity(verbose, logger) 247 | 248 | format = parseOutputFormat(args) 249 | 250 | setLoggerOutputFormat(logger, format) 251 | 252 | loreley.Colorize = parseColorMode(args) 253 | 254 | loggerStyle, err := getLoggerTheme(parseTheme("log", args)) 255 | if err != nil { 256 | fatalln(hierr.Errorf( 257 | err, 258 | `can't use given logger style`, 259 | )) 260 | } 261 | 262 | setLoggerStyle(logger, loggerStyle) 263 | 264 | poolSize, err := parseThreadPoolSize(args) 265 | if err != nil { 266 | errorln(hierr.Errorf( 267 | err, 268 | `--threads given invalid value`, 269 | )) 270 | } 271 | 272 | pool = newThreadPool(poolSize) 273 | 274 | setupInteractiveMode(args) 275 | 276 | switch { 277 | case args["--upload"].(bool): 278 | fallthrough 279 | case args["--lock"].(bool): 280 | fallthrough 281 | case args["--sync"].(bool): 282 | err = handleSynchronize(args) 283 | case args["--command"].(bool): 284 | err = handleEvaluate(args) 285 | } 286 | 287 | if err != nil { 288 | fatalln(err) 289 | } 290 | 291 | clearStatus() 292 | } 293 | 294 | func parseArgs() map[string]interface{} { 295 | usage, err := formatUsage(string(usage)) 296 | if err != nil { 297 | fatalln(hierr.Errorf( 298 | err, 299 | `can't format usage`, 300 | )) 301 | } 302 | 303 | args, err := docopt.Parse(usage, nil, true, version, true) 304 | if err != nil { 305 | panic(err) 306 | } 307 | 308 | return args 309 | } 310 | 311 | func formatUsage(template string) (string, error) { 312 | currentUser, err := user.Current() 313 | if err != nil { 314 | return "", hierr.Errorf( 315 | err, 316 | `can't get current user`, 317 | ) 318 | } 319 | 320 | usage := template 321 | 322 | usage = strings.Replace(usage, "$USER", currentUser.Username, -1) 323 | usage = strings.Replace(usage, "$HOME", currentUser.HomeDir, -1) 324 | usage = strings.Replace(usage, "$ROOT", runsDirectory, -1) 325 | usage = strings.Replace(usage, "$LOCK", defaultLockFile, -1) 326 | 327 | return usage, nil 328 | } 329 | 330 | func handleEvaluate(args map[string]interface{}) error { 331 | var ( 332 | stdin, hasStdin = args["--stdin"].(string) 333 | rootDir, _ = args["--root"].(string) 334 | sudo = args["--sudo"].(bool) 335 | shell = args["--shell"].(string) 336 | serial = args["--serial"].(bool) 337 | 338 | command = args[""].([]string) 339 | ) 340 | 341 | canceler := sync.NewCond(&sync.Mutex{}) 342 | 343 | cluster, err := connectAndLock(args, canceler) 344 | if err != nil { 345 | return err 346 | } 347 | 348 | runner := &remoteExecutionRunner{ 349 | shell: shell, 350 | sudo: sudo, 351 | command: command, 352 | directory: rootDir, 353 | serial: serial, 354 | term: !hasStdin, 355 | } 356 | 357 | return run(cluster, runner, stdin) 358 | } 359 | 360 | func run( 361 | cluster *distributedLock, 362 | runner *remoteExecutionRunner, 363 | stdin string, 364 | ) error { 365 | execution, err := runner.run(cluster, nil) 366 | if err != nil { 367 | return hierr.Errorf( 368 | err, 369 | `can't run remote execution on %d nodes`, 370 | len(cluster.nodes), 371 | ) 372 | } 373 | 374 | if stdin != "" { 375 | var inputFile *os.File 376 | 377 | inputFile, err = os.Open(stdin) 378 | if err != nil { 379 | return hierr.Errorf( 380 | err, 381 | `can't open file for passing as stdin: '%s'`, 382 | stdin, 383 | ) 384 | } 385 | 386 | defer inputFile.Close() 387 | 388 | _, err = io.Copy(execution.stdin, inputFile) 389 | if err != nil { 390 | return hierr.Errorf( 391 | err, 392 | `can't copy input file to the execution processes`, 393 | ) 394 | } 395 | 396 | } 397 | 398 | debugf(`commands are running, waiting for finish`) 399 | 400 | err = execution.stdin.Close() 401 | if err != nil { 402 | return hierr.Errorf( 403 | err, 404 | `can't close stdin`, 405 | ) 406 | } 407 | 408 | err = execution.wait() 409 | if err != nil { 410 | return hierr.Errorf( 411 | err, 412 | `remote execution failed, because one of `+ 413 | `command has been exited with non-zero exit `+ 414 | `code (or timed out) at least on one node`, 415 | ) 416 | } 417 | 418 | return nil 419 | } 420 | 421 | func handleSynchronize(args map[string]interface{}) error { 422 | var ( 423 | stdin, _ = args["--stdin"].(string) 424 | rootDir, _ = args["--root"].(string) 425 | lockOnly = args["--lock"].(bool) 426 | uploadOnly = args["--upload"].(bool) 427 | relative = args["--relative"].(bool) 428 | 429 | isSimpleCommand = args["--simple"].(bool) 430 | 431 | commandString = args["--sync-cmd"].(string) 432 | commandArgs = args["--arg"].([]string) 433 | 434 | shell = args["--shell"].(string) 435 | 436 | sudo = args["--sudo"].(bool) 437 | serial = args["--serial"].(bool) 438 | 439 | fileSources = args[""].([]string) 440 | 441 | noUpload = args["--no-upload"].(bool) 442 | ) 443 | 444 | var ( 445 | filesList = []file{} 446 | 447 | err error 448 | ) 449 | 450 | if !noUpload { 451 | if !lockOnly { 452 | debugf(`building files list from %d sources`, len(fileSources)) 453 | filesList, err = getFilesList(relative, fileSources...) 454 | if err != nil { 455 | return hierr.Errorf( 456 | err, 457 | `can't build files list`, 458 | ) 459 | } 460 | 461 | debugf(`file list contains %d files`, len(filesList)) 462 | tracef(`files to upload: %+v`, filesList) 463 | } 464 | } 465 | 466 | canceler := sync.NewCond(&sync.Mutex{}) 467 | 468 | cluster, err := connectAndLock(args, canceler) 469 | if err != nil { 470 | return err 471 | } 472 | 473 | if lockOnly { 474 | warningf("-L|--lock was passed, waiting for interrupt...") 475 | 476 | canceler.L.Lock() 477 | canceler.Wait() 478 | canceler.L.Unlock() 479 | 480 | return nil 481 | } 482 | 483 | if !noUpload { 484 | err = upload(args, cluster, filesList) 485 | if err != nil { 486 | return hierr.Errorf( 487 | err, 488 | `can't upload files on the remote nodes`, 489 | ) 490 | } 491 | 492 | tracef(`upload done`) 493 | } 494 | 495 | if uploadOnly { 496 | return nil 497 | } 498 | 499 | tracef(`starting sync tool`) 500 | 501 | command, err := shellwords.NewParser().Parse(commandString) 502 | if err != nil { 503 | return hierr.Errorf( 504 | err, 505 | `can't parse sync tool command: '%s'`, 506 | commandString, 507 | ) 508 | } 509 | 510 | runner := &remoteExecutionRunner{ 511 | shell: shell, 512 | sudo: sudo, 513 | command: command, 514 | args: commandArgs, 515 | directory: rootDir, 516 | serial: serial, 517 | } 518 | 519 | if isSimpleCommand { 520 | return run(cluster, runner, stdin) 521 | } 522 | 523 | err = runSyncProtocol(cluster, runner) 524 | if err != nil { 525 | return hierr.Errorf( 526 | err, 527 | `failed to run sync command`, 528 | ) 529 | } 530 | 531 | return nil 532 | } 533 | 534 | func upload( 535 | args map[string]interface{}, 536 | cluster *distributedLock, 537 | filesList []file, 538 | ) error { 539 | var ( 540 | rootDir, _ = args["--root"].(string) 541 | sudo = args["--sudo"].(bool) 542 | 543 | preserveUID = !args["--no-preserve-uid"].(bool) 544 | preserveGID = !args["--no-preserve-gid"].(bool) 545 | 546 | serial = args["--serial"].(bool) 547 | ) 548 | 549 | if rootDir == "" { 550 | rootDir = filepath.Join(runsDirectory, generateRunID()) 551 | } 552 | 553 | debugf(`file upload started into: '%s'`, rootDir) 554 | 555 | receivers, err := startArchiveReceivers(cluster, rootDir, sudo, serial) 556 | if err != nil { 557 | return hierr.Errorf( 558 | err, 559 | `can't start archive receivers on the cluster`, 560 | ) 561 | } 562 | 563 | err = archiveFilesToWriter( 564 | receivers.stdin, 565 | filesList, 566 | preserveUID, 567 | preserveGID, 568 | ) 569 | if err != nil { 570 | return hierr.Errorf( 571 | err, 572 | `can't archive files and send to the remote nodes`, 573 | ) 574 | } 575 | 576 | tracef(`waiting file upload to finish`) 577 | 578 | err = receivers.stdin.Close() 579 | if err != nil { 580 | return hierr.Errorf( 581 | err, 582 | `can't close archive receiver stdin`, 583 | ) 584 | } 585 | 586 | err = receivers.wait() 587 | if err != nil { 588 | return hierr.Errorf( 589 | err, 590 | `archive upload failed`, 591 | ) 592 | } 593 | 594 | return nil 595 | } 596 | 597 | func connectAndLock( 598 | args map[string]interface{}, 599 | canceler *sync.Cond, 600 | ) (*distributedLock, error) { 601 | var ( 602 | hosts = args["--host"].([]string) 603 | 604 | sendTimeout = args["--send-timeout"].(string) 605 | defaultUser = args["--user"].(string) 606 | 607 | askPassword = args["--password"].(bool) 608 | fromStdin = args["--read-stdin"].(bool) 609 | 610 | rootDir, _ = args["--root"].(string) 611 | sshKeyPath, _ = args["--key"].(string) 612 | lockFile, _ = args["--lock-file"].(string) 613 | 614 | noConnFail = args["--no-conn-fail"].(bool) 615 | noLockFail = args["--no-lock-fail"].(bool) 616 | 617 | noLock = args["--no-lock"].(bool) 618 | ) 619 | 620 | addresses, err := parseAddresses(hosts, defaultUser, fromStdin) 621 | if err != nil { 622 | return nil, hierr.Errorf( 623 | err, 624 | `can't parse all specified addresses`, 625 | ) 626 | } 627 | 628 | timeouts, err := makeTimeouts(args) 629 | if err != nil { 630 | return nil, hierr.Errorf( 631 | err, 632 | `can't parse SSH connection timeouts`, 633 | ) 634 | } 635 | 636 | runners, err := createRunnerFactory(timeouts, sshKeyPath, askPassword) 637 | if err != nil { 638 | return nil, hierr.Errorf( 639 | err, 640 | `can't create runner factory`, 641 | ) 642 | } 643 | 644 | debugf(`using %d threads`, pool.size) 645 | 646 | debugf(`connecting to %d nodes`, len(addresses)) 647 | 648 | if lockFile == "" { 649 | if rootDir == "" { 650 | lockFile = defaultLockFile 651 | } else { 652 | lockFile = rootDir 653 | } 654 | } 655 | 656 | heartbeatMillisecondsBase, err := strconv.Atoi(sendTimeout) 657 | if err != nil { 658 | return nil, hierr.Errorf( 659 | err, 660 | `can't use --send-timeout as heartbeat timeout`, 661 | ) 662 | } 663 | 664 | heartbeatMilliseconds := time.Duration( 665 | float64(heartbeatMillisecondsBase)*heartbeatTimeoutCoefficient, 666 | ) * time.Millisecond 667 | 668 | cluster, err := connectToCluster( 669 | lockFile, 670 | runners, 671 | addresses, 672 | noLock, 673 | noLockFail, 674 | noConnFail, 675 | func(node *distributedLockNode) { 676 | heartbeat(heartbeatMilliseconds, node, canceler) 677 | }, 678 | ) 679 | if err != nil { 680 | return nil, hierr.Errorf( 681 | err, 682 | `connecting to cluster failed`, 683 | ) 684 | } 685 | 686 | if noLock { 687 | debugf(`connection established to %d nodes`, len(cluster.nodes)) 688 | } else { 689 | debugf(`global lock acquired on %d nodes`, len(cluster.nodes)) 690 | } 691 | 692 | return cluster, nil 693 | } 694 | 695 | func createRunnerFactory( 696 | timeout *runcmd.Timeout, 697 | sshKeyPath string, 698 | askPassword bool, 699 | ) (runnerFactory, error) { 700 | if askPassword { 701 | password, err := readPassword(sshPasswordPrompt) 702 | if err != nil { 703 | return nil, hierr.Errorf( 704 | err, 705 | `can't read password`, 706 | ) 707 | } 708 | 709 | return createRemoteRunnerFactoryWithPassword( 710 | string(password), 711 | timeout, 712 | ), nil 713 | } 714 | 715 | keyring, err := getSshAgent() 716 | if err != nil { 717 | debugf(`failed to connect to a ssh-agent: %s`, err) 718 | } 719 | keyListEmpty := true 720 | if keyring == nil { 721 | keyring = agent.NewKeyring() 722 | } else { 723 | li, err := keyring.List() 724 | if err != nil { 725 | debugf(` could not get agent keys list: %s`, err) 726 | } 727 | keyListEmpty = len(li) < 1 728 | if keyListEmpty { 729 | debugf(` agent keys list is empty`) 730 | } 731 | } 732 | 733 | if keyListEmpty && sshKeyPath != "" { 734 | err := readSSHKey(keyring, sshKeyPath) 735 | if err != nil { 736 | return nil, hierr.Errorf( 737 | err, 738 | `can't read SSH key: '%s'`, 739 | sshKeyPath, 740 | ) 741 | } 742 | } 743 | 744 | return createRemoteRunnerFactoryWithAgent( 745 | keyring, 746 | timeout, 747 | ), nil 748 | } 749 | 750 | func parseAddresses( 751 | hosts []string, 752 | defaultUser string, 753 | fromStdin bool, 754 | ) ([]address, error) { 755 | hostsToParse := []string{} 756 | 757 | if fromStdin { 758 | scanner := bufio.NewScanner(os.Stdin) 759 | for scanner.Scan() { 760 | hostsToParse = append(hostsToParse, scanner.Text()) 761 | } 762 | } 763 | 764 | for _, host := range hosts { 765 | if strings.HasPrefix(host, "/") || strings.HasPrefix(host, "./") { 766 | hostsFile, err := os.Open(host) 767 | if err != nil { 768 | return nil, hierr.Errorf( 769 | err, 770 | `can't open hosts file: '%s'`, 771 | host, 772 | ) 773 | } 774 | 775 | scanner := bufio.NewScanner(hostsFile) 776 | for scanner.Scan() { 777 | hostsToParse = append(hostsToParse, scanner.Text()) 778 | } 779 | } else { 780 | hostsToParse = append(hostsToParse, host) 781 | } 782 | } 783 | 784 | addresses := []address{} 785 | 786 | for _, host := range hostsToParse { 787 | parsedAddress, err := parseAddress( 788 | host, defaultUser, defaultSSHPort, 789 | ) 790 | if err != nil { 791 | return nil, hierr.Errorf( 792 | err, 793 | `can't parse specified address '%s'`, 794 | host, 795 | ) 796 | } 797 | 798 | addresses = append(addresses, parsedAddress) 799 | } 800 | 801 | return getUniqueAddresses(addresses), nil 802 | } 803 | 804 | func setupInteractiveMode(args map[string]interface{}) { 805 | var ( 806 | _, hasStdin = args["--stdin"].(string) 807 | 808 | barLock = &sync.Mutex{} 809 | ) 810 | 811 | barStyle, err := getStatusBarTheme(parseTheme("bar", args)) 812 | if err != nil { 813 | errorln(hierr.Errorf( 814 | err, 815 | `can't use given status bar style`, 816 | )) 817 | } 818 | 819 | if loreley.HasTTY(int(os.Stderr.Fd())) { 820 | statusbar = barely.NewStatusBar(barStyle.Template) 821 | statusbar.SetLock(barLock) 822 | } else { 823 | statusbar = nil 824 | 825 | sshPasswordPrompt = "" 826 | sshPassphrasePrompt = "" 827 | } 828 | 829 | if hasStdin && loreley.HasTTY(int(os.Stdin.Fd())) { 830 | statusbar = nil 831 | } 832 | } 833 | 834 | func generateRunID() string { 835 | return time.Now().Format("20060102150405.999999") 836 | } 837 | -------------------------------------------------------------------------------- /main_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package main 4 | 5 | const helpNoPreserveUidGid = ` --no-preserve-uid Do not preserve UIDs for transferred files. 6 | --no-preserve-gid Do not preserve GIDs for transferred files. 7 | ` 8 | -------------------------------------------------------------------------------- /main_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | const helpNoPreserveUidGid = `` 6 | -------------------------------------------------------------------------------- /multiwrite_closer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | ) 8 | 9 | type multiWriteCloser struct { 10 | writers []io.WriteCloser 11 | } 12 | 13 | func (closer *multiWriteCloser) Write(data []byte) (int, error) { 14 | errs := []string{} 15 | 16 | for _, writer := range closer.writers { 17 | _, err := writer.Write(data) 18 | if err != nil && err != io.EOF { 19 | errs = append(errs, err.Error()) 20 | } 21 | } 22 | 23 | if len(errs) > 0 { 24 | return 0, fmt.Errorf( 25 | "%d errors: %s", 26 | len(errs), 27 | strings.Join(errs, "; "), 28 | ) 29 | } 30 | 31 | return len(data), nil 32 | } 33 | 34 | func (closer *multiWriteCloser) Close() error { 35 | errs := []string{} 36 | 37 | for _, closer := range closer.writers { 38 | err := closer.Close() 39 | if err != nil && err != io.EOF { 40 | errs = append(errs, err.Error()) 41 | } 42 | } 43 | 44 | if len(errs) > 0 { 45 | return fmt.Errorf( 46 | "%d errors: %s", 47 | len(errs), 48 | strings.Join(errs, "; "), 49 | ) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /nop_closer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type nopCloser struct { 8 | io.Writer 9 | } 10 | 11 | func (closer nopCloser) Close() error { 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /protocol_node_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | ) 8 | 9 | type protocolNodeWriter struct { 10 | node *remoteExecutionNode 11 | protocol *syncProtocol 12 | 13 | stdout io.Writer 14 | 15 | buffer *bytes.Buffer 16 | } 17 | 18 | func newProtocolNodeWriter( 19 | node *remoteExecutionNode, 20 | protocol *syncProtocol, 21 | ) *protocolNodeWriter { 22 | return &protocolNodeWriter{ 23 | node: node, 24 | stdout: node.stdout, 25 | protocol: protocol, 26 | buffer: &bytes.Buffer{}, 27 | } 28 | } 29 | 30 | func (writer *protocolNodeWriter) Write(data []byte) (int, error) { 31 | written, err := writer.buffer.Write(data) 32 | if err != nil { 33 | return written, err 34 | } 35 | 36 | reader := bufio.NewReader(writer.buffer) 37 | 38 | for { 39 | line, err := reader.ReadString('\n') 40 | if err != nil { 41 | if err == io.EOF { 42 | _, err := io.WriteString(writer.buffer, line) 43 | if err != nil { 44 | return 0, err 45 | } 46 | 47 | break 48 | } 49 | } 50 | 51 | switch { 52 | case writer.protocol.IsSyncCommand(line): 53 | tracef( 54 | "%s sent sync command: '%s'", 55 | writer.node.String(), 56 | line, 57 | ) 58 | 59 | err := writer.protocol.SendSync(writer.node, line) 60 | 61 | if err != nil { 62 | return 0, err 63 | } 64 | default: 65 | _, err := io.WriteString(writer.stdout, line) 66 | if err != nil { 67 | return 0, err 68 | } 69 | } 70 | } 71 | 72 | return written, nil 73 | } 74 | 75 | func (writer *protocolNodeWriter) Close() error { 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /remote_execution.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "reflect" 7 | 8 | "github.com/reconquest/hierr-go" 9 | ) 10 | 11 | type remoteExecution struct { 12 | stdin io.WriteCloser 13 | nodes map[*distributedLockNode]*remoteExecutionNode 14 | } 15 | 16 | type remoteExecutionResult struct { 17 | node *remoteExecutionNode 18 | 19 | err error 20 | } 21 | 22 | func (execution *remoteExecution) wait() error { 23 | tracef(`waiting %d nodes to finish`, len(execution.nodes)) 24 | 25 | results := make(chan *remoteExecutionResult, 0) 26 | for _, node := range execution.nodes { 27 | go func(node *remoteExecutionNode) { 28 | results <- &remoteExecutionResult{node, node.wait()} 29 | }(node) 30 | } 31 | 32 | executionErrors := fmt.Errorf( 33 | `commands are exited with non-zero code`, 34 | ) 35 | 36 | var ( 37 | status = &struct { 38 | Phase string 39 | Total int 40 | Fails int 41 | Success int 42 | }{ 43 | Phase: `wait`, 44 | Total: len(execution.nodes), 45 | } 46 | 47 | exitCodes = map[int]int{} 48 | ) 49 | 50 | setStatus(status) 51 | 52 | for range execution.nodes { 53 | result := <-results 54 | if result.err != nil { 55 | exitCodes[result.node.exitCode]++ 56 | 57 | executionErrors = hierr.Push( 58 | executionErrors, 59 | hierr.Errorf( 60 | result.err, 61 | `%s has finished with error`, 62 | result.node.node.String(), 63 | ), 64 | ) 65 | 66 | status.Fails++ 67 | status.Total-- 68 | 69 | tracef( 70 | `%s finished with exit code: '%d'`, 71 | result.node.node.String(), 72 | result.node.exitCode, 73 | ) 74 | 75 | continue 76 | } 77 | 78 | status.Success++ 79 | 80 | tracef( 81 | `%s has successfully finished execution`, 82 | result.node.node.String(), 83 | ) 84 | } 85 | 86 | if status.Fails > 0 { 87 | if status.Fails == len(execution.nodes) { 88 | exitCodesValue := reflect.ValueOf(exitCodes) 89 | 90 | topError := fmt.Errorf( 91 | `commands are failed on all %d nodes`, 92 | len(execution.nodes), 93 | ) 94 | 95 | for _, key := range exitCodesValue.MapKeys() { 96 | topError = hierr.Push( 97 | topError, 98 | fmt.Sprintf( 99 | `code %d (%d nodes)`, 100 | key.Int(), 101 | exitCodesValue.MapIndex(key).Int(), 102 | ), 103 | ) 104 | } 105 | 106 | return topError 107 | } 108 | 109 | return hierr.Errorf( 110 | executionErrors, 111 | `commands are failed on %d out of %d nodes`, 112 | status.Fails, 113 | len(execution.nodes), 114 | ) 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /remote_execution_node.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "golang.org/x/crypto/ssh" 8 | 9 | "github.com/reconquest/hierr-go" 10 | "github.com/reconquest/runcmd" 11 | ) 12 | 13 | type remoteExecutionNode struct { 14 | node *distributedLockNode 15 | command runcmd.CmdWorker 16 | 17 | stdin io.WriteCloser 18 | stdout io.WriteCloser 19 | stderr io.WriteCloser 20 | 21 | exitCode int 22 | } 23 | 24 | func (node *remoteExecutionNode) wait() error { 25 | err := node.command.Wait() 26 | if err != nil { 27 | _ = node.stdout.Close() 28 | _ = node.stderr.Close() 29 | if sshErrors, ok := err.(*ssh.ExitError); ok { 30 | node.exitCode = sshErrors.Waitmsg.ExitStatus() 31 | 32 | return fmt.Errorf( 33 | `%s had failed to evaluate command, `+ 34 | `remote command exited with non-zero code: %d`, 35 | node.node.String(), 36 | node.exitCode, 37 | ) 38 | } 39 | 40 | return hierr.Errorf( 41 | err, 42 | `%s failed to finish execution, unexpected error`, 43 | node.node.String(), 44 | ) 45 | } 46 | 47 | err = node.stdout.Close() 48 | if err != nil { 49 | return hierr.Errorf( 50 | err, 51 | `%s can't close stdout`, 52 | node.node.String(), 53 | ) 54 | } 55 | 56 | err = node.stderr.Close() 57 | if err != nil { 58 | return hierr.Errorf( 59 | err, 60 | `%s can't close stderr`, 61 | node.node.String(), 62 | ) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (node *remoteExecutionNode) String() string { 69 | return node.node.String() 70 | } 71 | -------------------------------------------------------------------------------- /remote_execution_runner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/mattn/go-shellwords" 8 | "github.com/reconquest/hierr-go" 9 | ) 10 | 11 | var sudoCommand = []string{"sudo", "-n", "-E", "-H"} 12 | 13 | type remoteExecutionRunner struct { 14 | command []string 15 | args []string 16 | shell string 17 | directory string 18 | sudo bool 19 | serial bool 20 | term bool 21 | } 22 | 23 | func (runner *remoteExecutionRunner) run( 24 | cluster *distributedLock, 25 | setupCallback func(*remoteExecutionNode), 26 | ) (*remoteExecution, error) { 27 | commandline := joinCommand(runner.command) 28 | 29 | if runner.directory != "" { 30 | commandline = fmt.Sprintf("cd %s && { %s; }", 31 | escapeCommandArgumentStrict(runner.directory), 32 | commandline, 33 | ) 34 | } 35 | 36 | if len(runner.shell) != 0 { 37 | commandline = wrapCommandIntoShell( 38 | commandline, 39 | runner.shell, 40 | runner.args, 41 | ) 42 | } 43 | 44 | if runner.sudo { 45 | commandline = joinCommand(sudoCommand) + " " + commandline 46 | } 47 | 48 | command, err := shellwords.Parse(commandline) 49 | if err != nil { 50 | return nil, hierr.Errorf( 51 | err, "unparsable command line: %s", commandline, 52 | ) 53 | } 54 | 55 | return runRemoteExecution(cluster, command, setupCallback, runner.serial, runner.term) 56 | } 57 | 58 | func wrapCommandIntoShell(command, shell string, args []string) string { 59 | if shell == "" { 60 | return command 61 | } 62 | 63 | command = strings.Replace(shell, `{}`, command, -1) 64 | 65 | if len(args) == 0 { 66 | return command 67 | } 68 | 69 | escapedArgs := []string{} 70 | for _, arg := range args { 71 | escapedArgs = append(escapedArgs, escapeCommandArgumentStrict(arg)) 72 | } 73 | 74 | return command + " _ " + strings.Join(escapedArgs, " ") 75 | } 76 | 77 | func joinCommand(command []string) string { 78 | escapedParts := []string{} 79 | 80 | for _, part := range command { 81 | escapedParts = append(escapedParts, escapeCommandArgument(part)) 82 | } 83 | 84 | return strings.Join(escapedParts, ` `) 85 | } 86 | 87 | func escapeCommandArgument(argument string) string { 88 | argument = strings.Replace(argument, `'`, `'\''`, -1) 89 | 90 | return argument 91 | } 92 | 93 | func escapeCommandArgumentStrict(argument string) string { 94 | escaper := strings.NewReplacer( 95 | `\`, `\\`, 96 | "`", "\\`", 97 | `"`, `\"`, 98 | `'`, `'\''`, 99 | `$`, `\$`, 100 | ) 101 | 102 | escaper.Replace(argument) 103 | 104 | return `"` + argument + `"` 105 | } 106 | -------------------------------------------------------------------------------- /run_tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" 6 | 7 | source "vendor.bash/github.com/reconquest/import.bash/import.bash" 8 | 9 | import:use "github.com/reconquest/hastur.bash" 10 | import:use "github.com/reconquest/containers.bash" 11 | import:use "github.com/reconquest/progress.bash" 12 | import:use "github.com/reconquest/test-runner.bash" 13 | import:use "github.com/reconquest/tests.sh" 14 | import:use "github.com/reconquest/go-test.bash" 15 | import:use "github.com/reconquest/ssh-test.bash" 16 | 17 | import:include tests/build.sh 18 | import:include tests/orgalorg.sh 19 | import:include tests/deps.sh 20 | 21 | :deps:check 22 | 23 | test-runner:set-custom-opts \ 24 | --keep-containers \ 25 | --keep-images \ 26 | --containers-count: 27 | 28 | test-runner:handle-custom-opt() { 29 | case "$1" in 30 | --keep-containers) 31 | containers:keep-containers 32 | hastur:keep-images 33 | ;; 34 | 35 | --keep-images) 36 | hastur:keep-images 37 | ;; 38 | 39 | --containers-count) 40 | containers:set-count "$2" 41 | ;; 42 | esac 43 | } 44 | 45 | progress:spinner:new _progress_spinner 46 | 47 | test-runner:progress() { 48 | if [ "${1:-}" = "stop" ]; then 49 | printf " ok." 50 | else 51 | progress:spinner:spin "$_progress_spinner" > /dev/null 52 | fi 53 | } 54 | 55 | :init() { 56 | go-test:set-output-dir "$(readlink -f .)" 57 | go-test:build orgalorg 58 | 59 | hastur:init openssh,pam,util-linux,tar,iproute2,sudo,sed,procps-ng,systemd 60 | } 61 | 62 | :cleanup() { 63 | containers:wipe 64 | 65 | hastur:destroy-root 66 | 67 | progress:spinner:stop "$_progress_spinner" 68 | 69 | go-test:merge-coverage 70 | } 71 | 72 | :init 2> >(progress:spinner:spin "$_progress_spinner" > /dev/null) 73 | 74 | trap :cleanup EXIT 75 | 76 | test-runner:run "${@}" 77 | -------------------------------------------------------------------------------- /runner_factory.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "golang.org/x/crypto/ssh" 7 | "golang.org/x/crypto/ssh/agent" 8 | 9 | "github.com/reconquest/runcmd" 10 | ) 11 | 12 | type ( 13 | runnerFactory func(address address) (runcmd.Runner, error) 14 | ) 15 | 16 | func createRemoteRunnerFactoryWithAgent( 17 | keyring agent.Agent, 18 | timeout *runcmd.Timeout, 19 | ) runnerFactory { 20 | return func(address address) (runcmd.Runner, error) { 21 | return runcmd.NewRemoteRunner( 22 | address.user, 23 | fmt.Sprintf("%s:%d", address.domain, address.port), 24 | []ssh.AuthMethod{ 25 | ssh.PublicKeysCallback(keyring.Signers), 26 | }, 27 | *timeout, 28 | ) 29 | } 30 | } 31 | 32 | func createRemoteRunnerFactoryWithPassword( 33 | password string, 34 | timeout *runcmd.Timeout, 35 | ) runnerFactory { 36 | return func(address address) (runcmd.Runner, error) { 37 | return runcmd.NewRemotePasswordAuthRunner( 38 | address.user, 39 | fmt.Sprintf("%s:%d", address.domain, address.port), 40 | password, 41 | *timeout, 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /sshagent_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package main 4 | 5 | import ( 6 | "net" 7 | "os" 8 | 9 | "github.com/reconquest/hierr-go" 10 | "golang.org/x/crypto/ssh/agent" 11 | ) 12 | 13 | func getSshAgent() (agent.Agent, error) { 14 | debugf(`trying unix ssh-agent pipe`) 15 | if os.Getenv("SSH_AUTH_SOCK") == "" { 16 | return nil, hierr.Errorf( 17 | nil, 18 | "SSH_AUTH_SOCK is not set", 19 | ) 20 | } 21 | sock, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) 22 | if err != nil { 23 | return nil, hierr.Errorf( 24 | err, 25 | "unable to dial to ssh agent socket: %s", 26 | os.Getenv("SSH_AUTH_SOCK"), 27 | ) 28 | } 29 | 30 | return agent.NewClient(sock), nil 31 | } 32 | -------------------------------------------------------------------------------- /sshagent_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/davidmz/go-pageant" 7 | "github.com/reconquest/hierr-go" 8 | 9 | "github.com/Microsoft/go-winio" 10 | "golang.org/x/crypto/ssh/agent" 11 | ) 12 | 13 | const ( 14 | openSshAgentPipe = `\\.\pipe\openssh-ssh-agent` 15 | ) 16 | 17 | func getSshAgent() (agent.Agent, error) { 18 | debugf(`trying windows pageant`) 19 | if pageant.Available() { 20 | return pageant.New(), nil 21 | } else { 22 | debugf(" pageant is not found") 23 | } 24 | debugf(`trying windows openssh-agent`) 25 | sock, err := winio.DialPipe(openSshAgentPipe, nil) 26 | if err != nil { 27 | return nil, hierr.Errorf( 28 | err, 29 | "unable to dial openssh-agent socket: %s", 30 | openSshAgentPipe, 31 | ) 32 | } 33 | return agent.NewClient(sock), nil 34 | } 35 | -------------------------------------------------------------------------------- /status_bar_update_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "io" 4 | 5 | type statusBarUpdateWriter struct { 6 | writer io.WriteCloser 7 | } 8 | 9 | func (writer *statusBarUpdateWriter) Write(data []byte) (int, error) { 10 | clearStatus() 11 | 12 | written, err := writer.writer.Write(data) 13 | 14 | drawStatus() 15 | 16 | return written, err 17 | } 18 | 19 | func (writer *statusBarUpdateWriter) Close() error { 20 | return writer.writer.Close() 21 | } 22 | -------------------------------------------------------------------------------- /sync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/reconquest/hierr-go" 4 | 5 | func runSyncProtocol( 6 | cluster *distributedLock, 7 | runner *remoteExecutionRunner, 8 | ) error { 9 | protocol := newSyncProtocol() 10 | 11 | execution, err := runner.run( 12 | cluster, 13 | func(remoteNode *remoteExecutionNode) { 14 | remoteNode.stdout = newProtocolNodeWriter(remoteNode, protocol) 15 | }, 16 | ) 17 | if err != nil { 18 | return hierr.Errorf( 19 | err, 20 | `can't run sync tool command`, 21 | ) 22 | } 23 | 24 | tracef(`starting sync protocol with %d nodes`, len(execution.nodes)) 25 | 26 | err = protocol.Init(execution.stdin) 27 | if err != nil { 28 | return hierr.Errorf( 29 | err, 30 | `can't init protocol with sync tool`, 31 | ) 32 | } 33 | 34 | tracef(`sending information about %d nodes to each`, len(execution.nodes)) 35 | 36 | nodes := []*remoteExecutionNode{} 37 | for _, node := range execution.nodes { 38 | nodes = append(nodes, node) 39 | } 40 | 41 | for _, node := range execution.nodes { 42 | for _, neighbor := range nodes { 43 | err := protocol.SendNode(node, neighbor) 44 | if err != nil { 45 | return hierr.Errorf( 46 | err, 47 | `can't send node to sync tool: '%s'`, 48 | node.String(), 49 | ) 50 | } 51 | } 52 | } 53 | 54 | tracef(`sending start message to sync tools`) 55 | 56 | err = protocol.SendStart() 57 | if err != nil { 58 | return hierr.Errorf( 59 | err, 60 | `can't start sync tool`, 61 | ) 62 | } 63 | 64 | debugf(`waiting sync tool to finish`) 65 | 66 | err = execution.wait() 67 | if err != nil { 68 | return hierr.Errorf( 69 | err, 70 | `failed to finish sync tool command`, 71 | ) 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /sync_protocol.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/reconquest/prefixwriter-go" 10 | ) 11 | 12 | var ( 13 | syncProtocolPrefix = "ORGALORG" 14 | syncProtocolHello = "HELLO" 15 | syncProtocolNode = "NODE" 16 | syncProtocolNodeCurrent = "CURRENT" 17 | syncProtocolStart = "START" 18 | syncProtocolSync = "SYNC" 19 | ) 20 | 21 | // syncProtocol handles SYNC protocol described in the main.go. 22 | // 23 | // It will handle protocol over all connected nodes. 24 | type syncProtocol struct { 25 | // output represents writer, that should be connected to stdins of 26 | // all connected nodes. 27 | output io.WriteCloser 28 | 29 | // prefix is a unique string which prefixes every protocol message. 30 | prefix string 31 | } 32 | 33 | // newSyncProtocol returns syncProtocol instantiated with unique prefix. 34 | func newSyncProtocol() *syncProtocol { 35 | return &syncProtocol{ 36 | prefix: fmt.Sprintf( 37 | "%s:%d", 38 | syncProtocolPrefix, 39 | time.Now().UnixNano(), 40 | ), 41 | } 42 | } 43 | 44 | // Init starts protocol and sends HELLO message to the writer. Specified writer 45 | // will be used in all further communications. 46 | func (protocol *syncProtocol) Init(output io.WriteCloser) error { 47 | protocol.output = prefixwriter.New(output, protocol.prefix+" ") 48 | 49 | _, err := io.WriteString( 50 | protocol.output, 51 | syncProtocolHello+"\n", 52 | ) 53 | if err != nil { 54 | return protocolSuspendEOF(err) 55 | } 56 | 57 | return nil 58 | } 59 | 60 | // SendNode sends to the writer serialized representation of specified node as 61 | // NODE message. 62 | func (protocol *syncProtocol) SendNode( 63 | current *remoteExecutionNode, 64 | neighbor *remoteExecutionNode, 65 | ) error { 66 | var line = syncProtocolNode + " " + neighbor.String() 67 | 68 | if current == neighbor { 69 | line += " " + syncProtocolNodeCurrent 70 | } 71 | 72 | _, err := io.WriteString(current.stdin, line+"\n") 73 | if err != nil { 74 | return protocolSuspendEOF(err) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | // SendStart sends START message to the writer. 81 | func (protocol *syncProtocol) SendStart() error { 82 | _, err := io.WriteString( 83 | protocol.output, 84 | syncProtocolStart+"\n", 85 | ) 86 | if err != nil { 87 | return protocolSuspendEOF(err) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | // IsSyncCommand will return true, if specified line looks like incoming 94 | // SYNC message from the remote node. 95 | func (protocol *syncProtocol) IsSyncCommand(line string) bool { 96 | return strings.HasPrefix(line, protocol.prefix+" "+syncProtocolSync) 97 | } 98 | 99 | // SendSync sends SYNC message to the writer, tagging it as sent from node, 100 | // described by given source and adding optional description for the given 101 | // SYNC phase taken by extraction it from the original SYNC message, sent 102 | // by node. 103 | func (protocol *syncProtocol) SendSync( 104 | source fmt.Stringer, 105 | sync string, 106 | ) error { 107 | data := strings.TrimSpace( 108 | strings.TrimPrefix(sync, protocol.prefix+" "+syncProtocolSync), 109 | ) 110 | 111 | _, err := io.WriteString( 112 | protocol.output, 113 | syncProtocolSync+" "+source.String()+" "+data+"\n", 114 | ) 115 | 116 | if err != nil { 117 | return protocolSuspendEOF(err) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | // Suspend EOF for be compatible with simple commands, that are not support 124 | // protocol, and therefore can close exit earlier, than protocol is initiated. 125 | func protocolSuspendEOF(err error) error { 126 | if err == io.EOF { 127 | return nil 128 | } 129 | 130 | return err 131 | } 132 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /.last-testcase 2 | -------------------------------------------------------------------------------- /tests/build.sh: -------------------------------------------------------------------------------- 1 | :build:init() { 2 | printf "[build] building go binary... " 3 | 4 | if build_out=$(go build -o orgalorg -v 2>&1 | tee /dev/stderr); then 5 | printf "ok.\n" 6 | else 7 | printf "fail.\n\n%s\n" "$build_out" 8 | return 1 9 | fi 10 | } 11 | -------------------------------------------------------------------------------- /tests/deps.sh: -------------------------------------------------------------------------------- 1 | :deps:check() { 2 | if ! which brctl >/dev/null 2>&1; then 3 | echo "missing dependency: brctl (bridge-utils)" 4 | exit 1 5 | fi >&2 6 | 7 | if ! which expect >/dev/null 2>&1; then 8 | echo "missing dependency: expect" 9 | exit 1 10 | fi >&2 11 | 12 | if ! which hastur >/dev/null 2>&1; then 13 | echo "missing dependency: hastur (https://github.com/seletskiy/hastur)" 14 | exit 1 15 | fi >&2 16 | } 17 | -------------------------------------------------------------------------------- /tests/orgalorg.sh: -------------------------------------------------------------------------------- 1 | # requires setup.sh to be sourced first! 2 | 3 | orgalorg_user="orgalorg" 4 | 5 | :orgalorg:with-key() { 6 | :orgalorg \ 7 | -u $orgalorg_user ${ips[*]/#/-o} -k "$(ssh-test:print-key-path)" "${@}" 8 | } 9 | 10 | :orgalorg:with-password() { 11 | local password="$1" 12 | shift 13 | 14 | :expect() { 15 | expect -f <(cat) -- "${@}" /home/$(ssh-test:print-username)/.ssh/authorized_keys 33 | 34 | chown -R \\\\ 35 | $(ssh-test:print-username): /home/$(ssh-test:print-username)" 36 | } 37 | 38 | :start-ssh-daemon() { 39 | local container_name="$1" 40 | 41 | tests:debug "[$container_name] starting sshd..." 42 | 43 | tests:run-background "pid" ssh-test:remote:run-daemon 44 | 45 | until containers:is-active "$container_name"; do 46 | tests:debug "[$container_name] is offline" 47 | done 48 | 49 | while :; do 50 | containers:get-ip ip "$container_name" 51 | if [[ "$ip" ]]; then 52 | break 53 | fi 54 | 55 | tests:debug "[$container_name] is online, but no ip assigned" 56 | done 57 | 58 | tests:debug "[$container_name] is online and has ip" 59 | } 60 | 61 | :wait-for-ssh-active() { 62 | local container_name="$1" 63 | local container_ip="$2" 64 | 65 | until ssh-test:connect:by-key "$container_ip" "true"; do 66 | sleep 0.1 67 | tests:debug "[$container_name] sshd is offline" 68 | done 69 | 70 | tests:debug "[$container_name] sshd is online" 71 | } 72 | 73 | :install-sync-command-into-container() { 74 | local file_name="$1" 75 | local container_name="$2" 76 | 77 | containers:get-rootfs rootfs "$container_name" 78 | 79 | tests:ensure sudo mkdir -p "$rootfs/usr/lib/orgalorg/" 80 | tests:ensure sudo cp "$file_name" "$rootfs/usr/lib/orgalorg/" 81 | tests:ensure sudo chmod +x "$rootfs/usr/lib/orgalorg/sync" 82 | } 83 | 84 | tests:debug "!!! setup" 85 | 86 | tests:clone "orgalorg" "bin/" 87 | 88 | tests:debug "!!! spawning $(containers:count) containers" 89 | 90 | containers:spawn "/bin/true" 91 | 92 | tests:debug "!!! generating local key pair" 93 | 94 | tests:ensure ssh-test:local:keygen 95 | 96 | tests:debug "!!! bootstrapping containers" 97 | 98 | containers:foreach :bootstrap-container 99 | 100 | tests:debug "!!! starting sshd instances" 101 | 102 | containers:foreach :start-ssh-daemon 103 | 104 | tests:debug "!!! waiting for sshd" 105 | 106 | containers:do :wait-for-ssh-active 107 | 108 | containers:get-list containers 109 | containers:get-ip-list ips 110 | -------------------------------------------------------------------------------- /tests/ssh.sh: -------------------------------------------------------------------------------- 1 | :ssh:get-key() { 2 | printf "ssh-key" 3 | } 4 | 5 | :ssh:get-username() { 6 | printf "orgalorg" 7 | } 8 | 9 | :ssh:run-daemon() { 10 | local container_name=$1 11 | shift 12 | 13 | containers:run "$container_name" -- \ 14 | /usr/bin/sshd "${@:--Dd}" 15 | } 16 | 17 | :ssh:keygen-local() { 18 | local output_file=$1 19 | shift 20 | 21 | ssh-keygen -P '' -f "$output_file" 22 | } 23 | 24 | :ssh:keygen-remote() { 25 | local container_name=$1 26 | shift 27 | 28 | containers:run "$container_name" -- \ 29 | /usr/bin/ssh-keygen "${@:--A}" 30 | } 31 | 32 | :ssh:copy-id() { 33 | local container_name=$1 34 | local username=$2 35 | shift 36 | 37 | containers:run "$container_name" -- \ 38 | /usr/bin/tee -a /home/$username/.ssh/authorized_keys > /dev/null 39 | } 40 | 41 | :ssh:run-with-key() { 42 | local ip=$1 43 | local user=$2 44 | local identity=$3 45 | 46 | shift 3 47 | 48 | ssh \ 49 | -oStrictHostKeyChecking=no \ 50 | -oPasswordAuthentication=no \ 51 | -oControlPath=none \ 52 | -i "$identity" \ 53 | -l "$user" \ 54 | "$ip" "${@}" 55 | } 56 | 57 | :ssh() { 58 | local ip=$1 59 | shift 60 | 61 | :ssh:run-with-key "$ip" \ 62 | "$(:ssh:get-username)" "$(:ssh:get-key)" \ 63 | "${@}" 64 | } 65 | -------------------------------------------------------------------------------- /tests/teardown.sh: -------------------------------------------------------------------------------- 1 | containers:wipe 2 | -------------------------------------------------------------------------------- /tests/testcases/auth/can-authenticate-via-key-with-passphrase.test.sh: -------------------------------------------------------------------------------- 1 | passphrase="theone" 2 | 3 | tests:ensure ssh-test:local:keygen -f "$(ssh-test:print-key-path)-encrypted" \ 4 | -b 4096 -P "$passphrase" 5 | 6 | tests:ensure cat $(ssh-test:print-key-path) 7 | 8 | :copy-key() { 9 | local container_name="$1" 10 | local container_ip="$2" 11 | 12 | tests:ensure ssh-test:connect:by-key "$container_ip" \ 13 | 'cat > ~/.ssh/authorized_keys' \ 14 | < "$(ssh-test:print-key-path)-encrypted.pub" 15 | } 16 | 17 | containers:do :copy-key 18 | 19 | tests:ensure \ 20 | mv "$(ssh-test:print-key-path)-encrypted" \ 21 | "$(ssh-test:print-key-path)" 22 | 23 | tests:eval :orgalorg:with-key-passphrase "bla-$passphrase" -C -- \ 24 | whoami 25 | 26 | tests:assert-stdout "invalid passphrase for private key specified" 27 | 28 | tests:ensure :orgalorg:with-key-passphrase "$passphrase" -C -- \ 29 | whoami 30 | 31 | :check-output() { 32 | local container_name="$1" 33 | local container_ip="$2" 34 | 35 | tests:assert-stdout "$container_ip $orgalorg_user" 36 | } 37 | 38 | containers:do :check-output 39 | -------------------------------------------------------------------------------- /tests/testcases/auth/can-authenticate-via-password.test.sh: -------------------------------------------------------------------------------- 1 | password="123456" 2 | 3 | :set-ssh-password() { 4 | local container_ip="$2" 5 | 6 | ssh-test:connect:by-key "$container_ip" sudo -n chpasswd \ 7 | <<< "$orgalorg_user:$password" 8 | } 9 | 10 | containers:do :set-ssh-password 11 | 12 | tests:ensure :orgalorg:with-password "$password" -C -- whoami 13 | 14 | :check-output() { 15 | local container_name="$1" 16 | local container_ip="$2" 17 | 18 | tests:assert-stdout "$container_ip $orgalorg_user" 19 | } 20 | 21 | containers:do :check-output 22 | -------------------------------------------------------------------------------- /tests/testcases/can-skip-unreachable-servers-if-flag-is-given.test.sh: -------------------------------------------------------------------------------- 1 | tests:not tests:ensure :orgalorg:with-key -o example.com -C whoami 2 | 3 | tests:ensure :orgalorg:with-key -o example.com -w -C whoami 4 | 5 | tests:assert-stderr-re "WARN.*can't connect to address.*example.com" 6 | 7 | :check-node-output() { 8 | local container_ip="$2" 9 | 10 | tests:assert-stdout "$container_ip $orgalorg_user" 11 | } 12 | 13 | containers:do :check-node-output 14 | -------------------------------------------------------------------------------- /tests/testcases/commandline/can-handle-common-mistakes-in-arguments.test.sh: -------------------------------------------------------------------------------- 1 | tests:not tests:ensure :orgalorg -C echo blah 2 | tests:assert-stderr "Usage:" 3 | 4 | tests:not tests:ensure :orgalorg -o ./blah -C echo blah 5 | tests:assert-stderr-re "can't open.*blah" 6 | tests:assert-stderr-re "blah.*no such file or directory" 7 | 8 | tests:not tests:ensure :orgalorg -o blah --send-timeout=wazup -C echo dunno 9 | tests:assert-stderr-re "send timeout to number" 10 | 11 | tests:not tests:ensure :orgalorg -o blah --recv-timeout=wazup -C echo dunno 12 | tests:assert-stderr-re "receive timeout to number" 13 | 14 | tests:not tests:ensure :orgalorg -o blah --conn-timeout=wazup -C echo dunno 15 | tests:assert-stderr-re "connection timeout to number" 16 | 17 | tests:not tests:ensure :orgalorg -o blah --keep-alive=wazup -C echo dunno 18 | tests:assert-stderr-re "keep alive time to number" 19 | -------------------------------------------------------------------------------- /tests/testcases/commandline/will-read-hosts-from-file-if-argument-starts-with-slash-or-dot-slash.test.sh: -------------------------------------------------------------------------------- 1 | xargs -n1 <<< "${ips[@]}" | tests:put hosts 2 | 3 | tests:ensure :orgalorg:with-key -o ./hosts -C echo hello '|' wc -l 4 | 5 | tests:assert-stdout "$(containers:count)" 6 | -------------------------------------------------------------------------------- /tests/testcases/commands/can-aggregate-errors-on-the-end-of-execution.test.sh: -------------------------------------------------------------------------------- 1 | tests:not tests:ensure :orgalorg:with-key -e -C echo 1 '&&' exit 1 2 | tests:assert-stderr-re "exited with non-zero" 3 | tests:assert-stderr-re "all $(containers:count) nodes" 4 | tests:assert-stderr "code 1 ($(containers:count) nodes)" 5 | -------------------------------------------------------------------------------- /tests/testcases/commands/can-escape-space-in-the-remote-command.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -e -C echo '"two spaces"' 2 | 3 | tests:assert-stdout "two spaces" 4 | -------------------------------------------------------------------------------- /tests/testcases/commands/can-run-remote-command-and-pass-stdin-to-it.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -C -- wc -l 2 | 3 | containers:do tests:assert-stdout "0" 4 | 5 | tests:ensure :orgalorg:with-key -C -i <(echo 1) -- wc -l 6 | 7 | containers:do tests:assert-stdout "1" 8 | -------------------------------------------------------------------------------- /tests/testcases/commands/can-run-remote-command.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -e -C pwd 2 | 3 | tests:assert-stdout "/home/orgalorg" 4 | -------------------------------------------------------------------------------- /tests/testcases/commands/can-run-shell-command.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -C -- echo -n 1 '&&' echo 2 2 | 3 | tests:assert-stdout-re "${ips[0]} 12" 4 | -------------------------------------------------------------------------------- /tests/testcases/commands/should-properly-escape-shell-arguments.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure echo "'1'" 2 | tests:ensure :orgalorg:with-key -e -C echo "'1'" 3 | 4 | tests:assert-stdout-re "1$" 5 | 6 | tests:ensure :orgalorg:with-key -e -C echo "\\'" 7 | 8 | tests:assert-stdout-re "'$" 9 | -------------------------------------------------------------------------------- /tests/testcases/commands/should-return-non-zero-exit-code-if-command-fails.test.sh: -------------------------------------------------------------------------------- 1 | tests:not tests:ensure :orgalorg:with-key -e -C exit 17 2 | 3 | tests:assert-stderr "remote execution failed" 4 | tests:assert-stderr "code 17 ($(containers:count) nodes)" 5 | -------------------------------------------------------------------------------- /tests/testcases/commands/should-use-default-user-directory-unless-specified.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -C -- pwd 2 | 3 | tests:assert-stdout-re "${ips[0]} /home/$orgalorg_user" 4 | -------------------------------------------------------------------------------- /tests/testcases/commands/should-use-specified-working-directory.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -r /tmp -C -- pwd 2 | 3 | tests:assert-stdout-re "${ips[0]} /tmp" 4 | -------------------------------------------------------------------------------- /tests/testcases/commands/will-flush-lines-so-they-do-not-interleave.test.sh: -------------------------------------------------------------------------------- 1 | # checking, that lines from different remote sources do not interleave 2 | # each other 3 | tests:ensure :orgalorg:with-key -C \ 4 | seq 1 10000 '|' awk '{print $1}' '|' sort '|' uniq '|' wc -l 5 | 6 | tests:assert-no-diff "$(containers:count)" stdout 7 | -------------------------------------------------------------------------------- /tests/testcases/locking/can-acquire-global-lock.test.sh: -------------------------------------------------------------------------------- 1 | tests:involve tests/testcases/locking/lock.sh 2 | 3 | :orgalorg:lock orgalorg_output orgalorg_pid 4 | 5 | tests:not tests:ensure :orgalorg:with-key --lock 6 | tests:assert-stderr "lock already" 7 | 8 | pkill -INT -P "$orgalorg_pid" 9 | 10 | _exited_with_ctrl_c=130 11 | 12 | wait "$orgalorg_pid" || tests:assert-equals "$_exited_with_ctrl_c" "$?" 13 | -------------------------------------------------------------------------------- /tests/testcases/locking/can-detect-if-lock-process-is-aborted-by-remote-host-after-acquire.test.sh: -------------------------------------------------------------------------------- 1 | tests:involve tests/testcases/locking/lock.sh 2 | 3 | :orgalorg:lock orgalorg_output orgalorg_pid --send-timeout=2000 4 | 5 | tests:wait-file-changes "$orgalorg_output" 0.1 10 \ 6 | ssh-test:connect:by-key "${ips[0]}" pkill -f flock 7 | 8 | tests:ensure grep -q "ERROR.*${ips[0]}.*heartbeat" "$orgalorg_output" 9 | -------------------------------------------------------------------------------- /tests/testcases/locking/lock.sh: -------------------------------------------------------------------------------- 1 | :orgalorg:lock() { 2 | local _output_var="$1" 3 | local _pid_var="$2" 4 | shift 2 5 | 6 | local _orgalorg_output="$(tests:get-tmp-dir)/oralorg.stdout" 7 | local _orgalorg="" 8 | 9 | tests:run-background _orgalorg \ 10 | tests:silence tests:pipe \ 11 | :orgalorg:with-key --lock "${@}" '2>&1' \ 12 | '|' tee "$_orgalorg_output" 13 | 14 | until grep -qF "waiting for interrupt" "$_orgalorg_output" 2>/dev/null 15 | do 16 | tests:debug "[orgalorg] waiting for global lock..." 17 | sleep 0.1 18 | done 19 | 20 | tests:debug "[orgalorg] global lock has been acquired" 21 | 22 | eval $_output_var=\$_orgalorg_output 23 | eval $_pid_var=\$\(tests:get-background-pid \$_orgalorg\) 24 | } 25 | -------------------------------------------------------------------------------- /tests/testcases/locking/will-continue-execution-when-lock-failed-if-flag-is-specified.test.sh: -------------------------------------------------------------------------------- 1 | tests:involve tests/testcases/locking/lock.sh 2 | 3 | :orgalorg:lock orgalorg_output orgalorg_pid 4 | 5 | tests:ensure :orgalorg:with-key --no-lock-fail -C -- echo 1 6 | tests:assert-stdout "1" 7 | 8 | pkill -INT -P "$orgalorg_pid" 9 | 10 | _exited_with_ctrl_c=130 11 | 12 | wait "$orgalorg_pid" || tests:assert-equals "$_exited_with_ctrl_c" "$?" 13 | -------------------------------------------------------------------------------- /tests/testcases/newlines/do-append-newlines-in-non-quite-mode-when-necessary.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -C -- echo -n hello '|' wc -l 2 | 3 | tests:assert-stdout "$(containers:count)" 4 | -------------------------------------------------------------------------------- /tests/testcases/newlines/do-not-append-newlines-if-they-are-not-present-in-remote-output-at-quiet-mode.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -q -C -- echo -n hello 2 | 3 | tests:debug $(containers:count) 4 | 5 | tests:assert-stdout "$(printf "%0.shello" $(seq ${#containers[@]}))" 6 | -------------------------------------------------------------------------------- /tests/testcases/sudo/can-run-command-under-sudo.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -C 'whoami' 2 | 3 | containers:do tests:assert-stdout "$orgalorg_user" 4 | 5 | tests:ensure :orgalorg:with-key -x -C 'whoami' 6 | 7 | containers:do tests:assert-stdout "root" 8 | -------------------------------------------------------------------------------- /tests/testcases/sudo/can-upload-files-under-sudo.test.sh: -------------------------------------------------------------------------------- 1 | tests:put test-file < output 25 | 26 | tests:ensure awk '$1 != $4 { exit 1 }' '<' output 27 | } 28 | 29 | containers:do :check-all-nodes-present-in-list 30 | :check-all-nodes-has-current-flag-set-correctly 31 | -------------------------------------------------------------------------------- /tests/testcases/sync/will-send-stderr-from-sync-tool-back-to-master.test.sh: -------------------------------------------------------------------------------- 1 | tests:put test-file <&2 8 | EOF 9 | 10 | containers:do :install-sync-command-into-container "sync" 11 | 12 | tests:ensure :orgalorg:with-key -e -r /home/orgalorg/ -S test-file 13 | 14 | :check-stderr-returned-from-all-nodes() { 15 | local container_name="$1" 16 | local container_ip="$2" 17 | 18 | tests:assert-stderr-re "$container_ip XXX" 19 | } 20 | 21 | containers:do :check-stderr-returned-from-all-nodes 22 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/can-log-stderr-and-stdout-from-remote-at-verbose-level-to-stderr.test.sh: -------------------------------------------------------------------------------- 1 | # note! tests:ensure will eat '>&2' if it's passed without prefix 2 | tests:ensure :orgalorg:with-key -v -C -- echo 1 \; echo err\>\&2 3 | 4 | tests:assert-stderr-re ' \[.*\] 1' 5 | tests:assert-stderr-re ' \[.*\] err' 6 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/can-output-ip-in-response-from-remote-server-when-running-command.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -C pwd 2 | 3 | containers:do tests:assert-stdout-re "${ips[0]} /home/orgalorg" 4 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/can-output-node-stdout-and-stderr-in-json-format.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key --json -C pwd 2 | 3 | tests:assert-stdout-re '"stream":"stdout"' 4 | tests:assert-stdout-re "\"node\":\".*${ips[0]}.*\"" 5 | tests:assert-stdout-re "\"body\":\"/home/$orgalorg_user" 6 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/can-output-verbose-debug-info-in-json-format.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key --json -vv -C pwd 2 | 3 | tests:assert-stderr-re '"stream":"stderr"' 4 | tests:assert-stderr-re '"body":".*DEBUG.*connection established' 5 | tests:assert-stderr-re '"body":".*TRACE.*running lock command' 6 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/can-print-debug-output.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -vv -C pwd 2 | 3 | tests:assert-stderr-re "DEBUG.*stdout.*/home/orgalorg" 4 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/can-report-internal-errors-in-json-format.test.sh: -------------------------------------------------------------------------------- 1 | tests:not tests:ensure :orgalorg:with-key --json -o example.com -C pwd 2 | 3 | tests:assert-stderr-re '"stream":"stderr"' 4 | tests:assert-stderr-re '"body":".*FATAL.*connect to address' 5 | tests:not tests:assert-stderr '└─' 6 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/do-not-output-ip-in-quiet-mode.test.sh: -------------------------------------------------------------------------------- 1 | tests:ensure :orgalorg:with-key -q -C pwd 2 | 3 | tests:assert-stdout-re "^/home/orgalorg$" 4 | -------------------------------------------------------------------------------- /tests/testcases/verbosity/will-output-hierarchical-errors-by-default.test.sh: -------------------------------------------------------------------------------- 1 | tests:not tests:ensure :orgalorg:with-key -o example.com -C pwd 2 | 3 | tests:assert-stderr "└─ can't connect to address" 4 | -------------------------------------------------------------------------------- /themes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kovetskiy/lorg" 7 | "github.com/reconquest/colorgful" 8 | "github.com/reconquest/loreley" 9 | ) 10 | 11 | const ( 12 | themeDefault = `default` 13 | themeDark = `dark` 14 | themeLight = `light` 15 | ) 16 | 17 | var ( 18 | statusBarThemeTemplate = `{bg %d}{fg %d}` + 19 | `{bold}` + 20 | `{if eq .Phase "lock"}{bg %d} LOCK{end}` + 21 | `{if eq .Phase "connect"}{bg %[3]d} CONNECT{end}` + 22 | `{if eq .Phase "exec"}{bg %d} EXEC{end}` + 23 | `{if eq .Phase "wait"}{bg %d} WAIT{end}` + 24 | `{if eq .Phase "upload"}{bg %d} UPLOAD{end}` + 25 | `{nobold} ` + 26 | `{from "" %d} ` + 27 | `{fg %d}{bold}{printf "%%4d" .Success}{nobold}{fg %d}` + 28 | `/{printf "%%4d" .Total} ` + 29 | `{if .Fails}{fg %d}✗ {.Fails}{end} ` + 30 | `{from "" %d}` + 31 | `{if eq .Phase "upload"}{fg %d} ` + 32 | `{printf "%%9s/%%s" .Written .Bytes} ` + 33 | `{end}` 34 | 35 | statusBarThemes = map[string]string{ 36 | themeDark: fmt.Sprintf( 37 | statusBarThemeTemplate, 38 | 99, 7, 22, 1, 1, 25, 237, 46, 15, 214, -1, 140, 39 | ), 40 | 41 | themeLight: fmt.Sprintf( 42 | statusBarThemeTemplate, 43 | 99, 7, 22, 1, 1, 64, 254, 106, 16, 9, -1, 140, 44 | ), 45 | 46 | themeDefault: fmt.Sprintf( 47 | statusBarThemeTemplate, 48 | 234, 255, 22, 1, 1, 19, 245, 85, 255, 160, -1, 140, 49 | ), 50 | } 51 | 52 | logFormat = `${time} ${level:[%s]:right:true} %s` 53 | ) 54 | 55 | func getLoggerTheme(theme string) (lorg.Formatter, error) { 56 | switch theme { 57 | case "default": 58 | return colorgful.ApplyDefaultTheme( 59 | logFormat, 60 | colorgful.Default, 61 | ) 62 | case "dark": 63 | return colorgful.ApplyDefaultTheme( 64 | logFormat, 65 | colorgful.Dark, 66 | ) 67 | case "light": 68 | return colorgful.ApplyDefaultTheme( 69 | logFormat, 70 | colorgful.Light, 71 | ) 72 | default: 73 | return colorgful.Format(theme) 74 | } 75 | } 76 | 77 | func getStatusBarTheme(theme string) (*loreley.Style, error) { 78 | if format, ok := statusBarThemes[theme]; ok { 79 | theme = format 80 | } 81 | 82 | style, err := loreley.CompileWithReset(theme, nil) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return style, nil 88 | } 89 | 90 | func parseTheme(target string, args map[string]interface{}) string { 91 | var ( 92 | theme = args["--"+target+"-format"].(string) 93 | light = args["--colors-light"].(bool) 94 | dark = args["--colors-dark"].(bool) 95 | ) 96 | 97 | switch { 98 | case light: 99 | return themeLight 100 | 101 | case dark: 102 | return themeDark 103 | 104 | default: 105 | return theme 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /thread_pool.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/reconquest/hierr-go" 7 | ) 8 | 9 | type threadPool struct { 10 | available chan struct{} 11 | 12 | size int 13 | } 14 | 15 | func newThreadPool(size int) *threadPool { 16 | available := make(chan struct{}, size) 17 | for i := 0; i < size; i++ { 18 | available <- struct{}{} 19 | } 20 | 21 | return &threadPool{ 22 | available, 23 | size, 24 | } 25 | } 26 | 27 | func (pool *threadPool) run(task func()) { 28 | <-pool.available 29 | defer func() { 30 | pool.available <- struct{}{} 31 | }() 32 | 33 | task() 34 | } 35 | 36 | func parseThreadPoolSize(args map[string]interface{}) (int, error) { 37 | var ( 38 | poolSizeRaw = args["--threads"].(string) 39 | ) 40 | 41 | poolSize, err := strconv.Atoi(poolSizeRaw) 42 | if err != nil { 43 | return 0, hierr.Errorf( 44 | err, 45 | `can't parse threads count`, 46 | ) 47 | } 48 | 49 | return poolSize, nil 50 | } 51 | -------------------------------------------------------------------------------- /timeouts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/reconquest/hierr-go" 8 | "github.com/reconquest/runcmd" 9 | ) 10 | 11 | func makeTimeouts(args map[string]interface{}) (*runcmd.Timeout, error) { 12 | var ( 13 | connectionTimeoutRaw = args["--conn-timeout"].(string) 14 | sendTimeoutRaw = args["--send-timeout"].(string) 15 | receiveTimeoutRaw = args["--recv-timeout"].(string) 16 | keepAliveRaw = args["--keep-alive"].(string) 17 | ) 18 | 19 | connectionTimeout, err := strconv.Atoi(connectionTimeoutRaw) 20 | if err != nil { 21 | return nil, hierr.Errorf( 22 | err, 23 | `can't convert specified connection timeout to number: '%s'`, 24 | connectionTimeoutRaw, 25 | ) 26 | } 27 | 28 | sendTimeout, err := strconv.Atoi(sendTimeoutRaw) 29 | if err != nil { 30 | return nil, hierr.Errorf( 31 | err, 32 | `can't convert specified send timeout to number: '%s'`, 33 | sendTimeoutRaw, 34 | ) 35 | } 36 | 37 | receiveTimeout, err := strconv.Atoi(receiveTimeoutRaw) 38 | if err != nil { 39 | return nil, hierr.Errorf( 40 | err, 41 | `can't convert specified receive timeout to number: '%s'`, 42 | receiveTimeoutRaw, 43 | ) 44 | } 45 | 46 | keepAlive, err := strconv.Atoi(keepAliveRaw) 47 | if err != nil { 48 | return nil, hierr.Errorf( 49 | err, 50 | `can't convert specified keep alive time to number: '%s'`, 51 | keepAliveRaw, 52 | ) 53 | } 54 | 55 | return &runcmd.Timeout{ 56 | Connection: time.Millisecond * time.Duration(connectionTimeout), 57 | Send: time.Millisecond * time.Duration(sendTimeout), 58 | Receive: time.Millisecond * time.Duration(receiveTimeout), 59 | KeepAlive: time.Millisecond * time.Duration(keepAlive), 60 | }, nil 61 | } 62 | -------------------------------------------------------------------------------- /vendor.bash/.gitignore: -------------------------------------------------------------------------------- 1 | /github.com/reconquest/containers.bash 2 | /github.com/reconquest/coproc.bash 3 | /github.com/reconquest/go-test.bash 4 | /github.com/reconquest/hastur.bash 5 | /github.com/reconquest/opts.bash 6 | /github.com/reconquest/progress.bash 7 | /github.com/reconquest/ssh-test.bash 8 | /github.com/reconquest/sudo.bash 9 | /github.com/reconquest/test-runner.bash 10 | /github.com/reconquest/tests.sh 11 | /github.com/reconquest/types.bash 12 | -------------------------------------------------------------------------------- /vendor.bash/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: .gitignore 2 | 3 | .gitignore: 4 | find . -type d -name .git -prune -printf '/%P\n' \ 5 | | sed 's#/\.git$$##' \ 6 | | sort \ 7 | | tee .gitignore 8 | -------------------------------------------------------------------------------- /verbosity.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type ( 4 | verbosity int 5 | ) 6 | 7 | const ( 8 | verbosityQuiet verbosity = iota 9 | verbosityNormal 10 | verbosityDebug 11 | verbosityTrace 12 | ) 13 | 14 | func parseVerbosity(args map[string]interface{}) verbosity { 15 | var ( 16 | quiet = args["--quiet"].(bool) 17 | level = args["--verbose"].(int) 18 | ) 19 | 20 | if quiet { 21 | return verbosityQuiet 22 | } 23 | 24 | if level == 1 { 25 | return verbosityDebug 26 | } 27 | 28 | if level > 1 { 29 | return verbosityTrace 30 | } 31 | 32 | return verbosityNormal 33 | } 34 | --------------------------------------------------------------------------------