├── .github └── workflows │ ├── demo.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── example └── main.tf ├── go.mod ├── go.sum ├── img └── demo.gif ├── internal ├── clipboard │ ├── clipboard.go │ ├── clipboard_cgo.go │ └── clipboard_other.go ├── csv │ └── csv.go ├── log │ └── log.go ├── plainui │ └── ui.go ├── reader │ ├── reader.go │ └── reader_test.go ├── state │ ├── output_info.go │ ├── plan_info.go │ └── resource_operation_info.go ├── terraform │ └── views │ │ ├── json │ │ ├── change.go │ │ ├── change_summary.go │ │ ├── diagnostic.go │ │ ├── doc.go │ │ ├── function.go │ │ ├── hook.go │ │ ├── importing.go │ │ ├── message_types.go │ │ ├── output.go │ │ └── resource_addr.go │ │ ├── json_view.go │ │ ├── json_view_test.go │ │ └── test.go └── ui │ ├── diags.go │ ├── keymap.go │ ├── message.go │ ├── size.go │ ├── style.go │ ├── tick.go │ ├── ui.go │ └── view_state.go ├── main.go └── tool └── streamgen └── main.go /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: Pipeform Demo 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | jobs: 6 | Demo: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Setup Go 1.23 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: '1.23' 14 | - name: Install pipeform 15 | run: CGO_ENABLED=0 go install 16 | - uses: hashicorp/setup-terraform@v3 17 | with: 18 | terraform_version: "1.10.3" 19 | - run: | 20 | cd example 21 | terraform init 22 | terraform apply -json -auto-approve | $HOME/go/bin/pipeform --plain-ui 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: stable 23 | - name: Import GPG key 24 | id: import_gpg 25 | uses: crazy-max/ghaction-import-gpg@v6 26 | with: 27 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 28 | passphrase: ${{ secrets.PASSPHRASE }} 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v6 31 | with: 32 | distribution: goreleaser 33 | version: "~> v2" 34 | args: release --clean 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .terraform* 2 | terraform.tfstate* 3 | dist/ 4 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | 16 | builds: 17 | - env: 18 | - CGO_ENABLED=0 19 | mod_timestamp: '{{ .CommitTimestamp }}' 20 | goos: 21 | - freebsd 22 | - linux 23 | - windows 24 | - darwin 25 | goarch: 26 | - amd64 27 | - '386' 28 | - arm 29 | - arm64 30 | ignore: 31 | - goos: darwin 32 | goarch: '386' 33 | binary: '{{ .ProjectName }}' 34 | 35 | archives: 36 | - format: zip 37 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 38 | 39 | checksum: 40 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 41 | algorithm: sha256 42 | 43 | signs: 44 | - artifacts: checksum 45 | cmd: gpg2 46 | args: 47 | - "--batch" 48 | - "-u" 49 | - "{{ .Env.GPG_FINGERPRINT }}" 50 | - "--output" 51 | - "${signature}" 52 | - "--detach-sign" 53 | - "${artifact}" 54 | 55 | changelog: 56 | disable: true 57 | 58 | release: 59 | draft: true 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pipeform 2 | 3 | ## Introduction 4 | 5 | `pipeform` is a TUI for Terraform runtime progress. 6 | 7 | ## Usage 8 | 9 | `pipeform` as its name indicates, shall be preceded by `terraform` run, through a pipe (`|`). Only the following `terraform` commands are supported: 10 | 11 | - `terraform refresh -json` 12 | - `terraform plan -json` 13 | - `terraform apply -auto-approve -json` 14 | 15 | Note that all the commands must have the `-json` flag specified, as the tool is built on top of the [Terraform machine-readable UI](https://developer.hashicorp.com/terraform/internals/machine-readable-ui). 16 | 17 | Example: 18 | 19 | ![demo](./img/demo.gif) 20 | 21 | ## Install 22 | 23 | ```shell 24 | go install github.com/magodo/pipeform@main 25 | ``` 26 | 27 | ## Timing CSV File 28 | 29 | The tool will generate a CSV file for further analysis/visualization by specifying the `--time-csv=` option. 30 | 31 | A taste of the output: 32 | 33 | ```csv 34 | Start Timestamp,End Timestamp,Stage,Action,Module,Resource Type,Resource Name,Resource Key,Status,Duration (sec) 35 | 1735018449,1735018453,apply,create,,null_resource,cluster,13,complete,4 36 | 1735018449,1735018453,apply,create,,null_resource,cluster,3,complete,4 37 | 1735018449,1735018451,apply,create,,null_resource,cluster,1,complete,2 38 | 1735018450,1735018451,apply,create,,null_resource,cluster,25,complete,1 39 | 1735018450,1735018452,apply,create,,null_resource,cluster,26,complete,2 40 | ``` 41 | 42 | ## FAQ 43 | 44 | ### How to use in CI? 45 | 46 | By default, `pipeform` requires a [`tty`](https://man7.org/linux/man-pages/man7/pty.7.html) to make the UI work, which is unavailable in most CI systems (e.g. Github Action [has no tty](https://github.com/actions/runner/issues/241)). To support this environment, the tool comes with an option `--plain-ui`, which will print out logs similar to the `terraform` default logs, except for the operations, it will prefix with a progress indicator. 47 | 48 | [Example](https://github.com/magodo/pipeform/actions/runs/12745773365/job/35520444647). 49 | 50 | ### How to copy output variables? 51 | 52 | In a successful run, the tool will end up at the `SUMMARY` stage, that displays a table of output variables defined. Users can select any of the output variables and press c to copy the value to the system clipboard. 53 | 54 | Note that the clipboard functionality is only enabled when the tool is built properly (CGO might be required) on a supported platform. [Details](https://github.com/golang-design/clipboard?tab=readme-ov-file#platform-specific-details). 55 | 56 | ### What happens if terraform encounters any warning or error? 57 | 58 | When the tool ends in either successful or failed state, the user is supposed to quit. Then the tool will print the Terraform JSON warning/error diagnostics to `stderr`. 59 | 60 | Especially, if Terraform encounters an error, the tool will display an error indicator ❌ in the "state" section on the top left and stay in the terminated state. 61 | 62 | ### How to exit during operation? 63 | 64 | There are two ways to exit during operation: 65 | - Terminate `pipeform` 66 | - Terminate `terraform` 67 | 68 | Though, it is highly recommended **NOT** to terminate in the middle of the run. 69 | 70 | #### Terminate `pipeform` 71 | 72 | There is a key bind for terminating `pipeform`. When the user hit the key to quit, `pipeform` will quit immediately, which causes the pipe to close. Since `terraform` is still running and piping out logs, it will then hit a `SIGPIPE` signal, which `terraform` has no special handling and defaults to terminate `terraform` immediately. 73 | 74 | #### Terminate `terraform` 75 | 76 | Terraform can be terminated by interruption (ctrl-c). It even has *some* graceful handling for the interruption signal. 77 | 78 | Whilst when using `pipeform`, since the terminal is turned into *raw* mode, pressing ctrl-c won't send the signal at all. Instead, you'll have to send the signal manually. 79 | 80 | Under Linux you can do something as below: 81 | 82 | ``` 83 | $ # Find out the ppid of the `pipeform` 84 | $ ps -ef | grep pipeform 85 | magodo 88375 8823 1 11:05 pts/7 00:00:00 pipeform 86 | magodo 89764 49424 0 11:05 pts/6 00:00:00 grep --color pipeform 87 | $ # 8823 is the ppid of `pipeform` 88 | $ # Use pstree to find the pid of the preceded `terraform` 89 | $ pstree -lpT 8823 90 | zsh(8823)─┬─pipeform(88375) 91 | └─terraform(88374)───terraform-provi(88695) 92 | $ # 88374 is the pid of `terraform` 93 | $ # Send the signal manully 94 | $ kill -SIGINT 88374 95 | ``` 96 | 97 | After `terraform` being interrupted in the middle, `pipeform` won't just quit. Instead, it will respond to the diagnostics sent from `terraform` (once `terraform` finishes its *graceful* handling) and display the error indicators to users. 98 | 99 | ### Windows Powershell Doesn't Work? 100 | 101 | Windows Powershell (at up to 5.1.22621.4391) does not pipe byte-streams like UNIX shells or the DOS Command interpreter. The Powershell also faces the same [issue](https://github.com/PowerShell/PowerShell/issues/1908), until v7.4.0-preview.4 (with this [PR](https://github.com/PowerShell/PowerShell/pull/17857#issuecomment-1613864139) merged). 102 | 103 | For users want to use `pipeform` under PowerShell, please ensure you have installed PowerShell newer than v7.4.0. 104 | 105 | For users want to use `pipeform` under Windows Powershell, please invoke the DOS Command interpreter instead: `cmd "/c terraform.exe -json | pipeform.exe"` (or just use the DOS Command interpreter instead). 106 | -------------------------------------------------------------------------------- /example/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | source = "hashicorp/null" 5 | version = "3.2.3" 6 | } 7 | } 8 | } 9 | 10 | resource "null_resource" "cluster" { 11 | count = 30 12 | triggers = { 13 | foo = "bar" 14 | } 15 | 16 | provisioner "local-exec" { 17 | command = "sleep ${count.index % 5 + 1}" 18 | } 19 | } 20 | 21 | output "output_string" { 22 | value = null_resource.cluster[0].id 23 | } 24 | 25 | output "output_bool" { 26 | value = true 27 | } 28 | 29 | output "output_num" { 30 | value = 123 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/magodo/pipeform 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.20.0 7 | github.com/charmbracelet/bubbletea v1.2.5-0.20241217141949-1bf18861d91b 8 | github.com/charmbracelet/lipgloss v1.0.0 9 | github.com/charmbracelet/x/term v0.2.1 10 | github.com/hashicorp/go-hclog v1.6.3 11 | github.com/hashicorp/hc-install v0.9.0 12 | github.com/muesli/reflow v0.3.0 13 | github.com/stretchr/testify v1.10.0 14 | github.com/urfave/cli/v3 v3.0.0-beta1 15 | github.com/zclconf/go-cty v1.14.4 16 | golang.design/x/clipboard v0.7.0 17 | ) 18 | 19 | require ( 20 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/charmbracelet/harmonica v0.2.0 // indirect 23 | github.com/charmbracelet/x/ansi v0.6.0 // indirect 24 | github.com/davecgh/go-spew v1.1.1 // indirect 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 26 | github.com/fatih/color v1.16.0 // indirect 27 | github.com/hashicorp/errwrap v1.0.0 // indirect 28 | github.com/hashicorp/go-multierror v1.1.1 // indirect 29 | github.com/hashicorp/go-version v1.7.0 // indirect 30 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 31 | github.com/mattn/go-colorable v0.1.13 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mattn/go-localereader v0.0.1 // indirect 34 | github.com/mattn/go-runewidth v0.0.16 // indirect 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 36 | github.com/muesli/cancelreader v0.2.2 // indirect 37 | github.com/muesli/termenv v0.15.2 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/rivo/uniseg v0.4.7 // indirect 40 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect 41 | golang.org/x/image v0.6.0 // indirect 42 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect 43 | golang.org/x/mod v0.21.0 // indirect 44 | golang.org/x/sync v0.10.0 // indirect 45 | golang.org/x/sys v0.28.0 // indirect 46 | golang.org/x/text v0.14.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 4 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 5 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 6 | github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= 7 | github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 9 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 12 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 13 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 14 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 15 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 16 | github.com/charmbracelet/bubbletea v1.2.5-0.20241217141949-1bf18861d91b h1:edTS5rnzKWbYAdPerGMs2wO+bARyqJSZUJbB/irQIU0= 17 | github.com/charmbracelet/bubbletea v1.2.5-0.20241217141949-1bf18861d91b/go.mod h1:jw9DGtEW9dJ9JxilFwWyS9uZ2gpVJ06PpG03Y3joQcI= 18 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 19 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 20 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 21 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 22 | github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= 23 | github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= 24 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= 25 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 26 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 27 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 28 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 29 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 30 | github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 31 | github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 32 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 36 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 37 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 38 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 39 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 40 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 41 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 42 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 43 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 44 | github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= 45 | github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= 46 | github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= 47 | github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= 48 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 49 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 50 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 51 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 52 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 53 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 54 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 55 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 56 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 57 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 58 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 59 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 60 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 61 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 62 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 63 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 64 | github.com/hashicorp/hc-install v0.9.0 h1:2dIk8LcvANwtv3QZLckxcjyF5w8KVtiMxu6G6eLhghE= 65 | github.com/hashicorp/hc-install v0.9.0/go.mod h1:+6vOP+mf3tuGgMApVYtmsnDoKWMDcFXeTxCACYZ8SFg= 66 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 67 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 68 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 69 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 70 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 71 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 72 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 73 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 74 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 75 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 76 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 77 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 78 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 79 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 80 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 81 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 82 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 83 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 84 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 85 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 86 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 87 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 88 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 89 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 90 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 91 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 92 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 93 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 94 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 95 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 96 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 97 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 98 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 99 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 100 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 101 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 102 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 103 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 104 | github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= 105 | github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 106 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 107 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 108 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 109 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 110 | github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg= 111 | github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= 112 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 113 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 114 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 115 | github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= 116 | github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 117 | golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= 118 | golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= 119 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 120 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 121 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 122 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 123 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 124 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= 125 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= 126 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 127 | golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= 128 | golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= 129 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 130 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4= 131 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY= 132 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 133 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 134 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 135 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 136 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 137 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 138 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 139 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 140 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 141 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 142 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 143 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 144 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 145 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 146 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 148 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 149 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 150 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 151 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 156 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 159 | golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 160 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 161 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 163 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 164 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 166 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 167 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 168 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 169 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 170 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 171 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 172 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 173 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 174 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 175 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 176 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 177 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 178 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 179 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 180 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 181 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 182 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 183 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 184 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 185 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 186 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 187 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 188 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 189 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 190 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 191 | -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magodo/pipeform/7ee8e3aebd30919e66cb0b36e9d218972ec217c3/img/demo.gif -------------------------------------------------------------------------------- /internal/clipboard/clipboard.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | type Clipboard interface { 4 | Enabled() bool 5 | Write([]byte) 6 | } 7 | -------------------------------------------------------------------------------- /internal/clipboard/clipboard_cgo.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | 3 | package clipboard 4 | 5 | import cb "golang.design/x/clipboard" 6 | 7 | type t struct{} 8 | 9 | func (c *t) Enabled() bool { 10 | return cb.Init() == nil 11 | } 12 | 13 | func (c *t) Write(b []byte) { 14 | cb.Write(cb.FmtText, b) 15 | } 16 | 17 | func NewClipboard() Clipboard { 18 | return &t{} 19 | } 20 | -------------------------------------------------------------------------------- /internal/clipboard/clipboard_other.go: -------------------------------------------------------------------------------- 1 | //go:build !cgo 2 | 3 | package clipboard 4 | 5 | type t struct{} 6 | 7 | func (c *t) Enabled() bool { 8 | return false 9 | } 10 | 11 | func (c *t) Write(b []byte) { 12 | return 13 | } 14 | 15 | func NewClipboard() Clipboard { 16 | return &t{} 17 | } 18 | -------------------------------------------------------------------------------- /internal/csv/csv.go: -------------------------------------------------------------------------------- 1 | package csv 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/magodo/pipeform/internal/state" 7 | ) 8 | 9 | type Input struct { 10 | RefreshInfos state.ResourceOperationInfos 11 | ApplyInfos state.ResourceOperationInfos 12 | } 13 | 14 | func ToCsv(input Input) []byte { 15 | out := []string{ 16 | strings.Join([]string{ 17 | "Start Timestamp", 18 | "End Timestamp", 19 | "Stage", 20 | "Action", 21 | "Module", 22 | "Resource Type", 23 | "Resource Name", 24 | "Resource Key", 25 | "Status", 26 | "Duration (sec)", 27 | }, ","), 28 | } 29 | out = append(out, input.RefreshInfos.ToCsv("refresh")...) 30 | out = append(out, input.ApplyInfos.ToCsv("apply")...) 31 | return []byte(strings.Join(out, "\n")) 32 | } 33 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/hashicorp/go-hclog" 7 | ) 8 | 9 | type Level string 10 | 11 | const ( 12 | LevelTrace Level = "trace" 13 | LevelDebug Level = "debug" 14 | LevelInfo Level = "info" 15 | LevelWarn Level = "warn" 16 | LevelError Level = "error" 17 | ) 18 | 19 | func PossibleLevels() []Level { 20 | return []Level{LevelTrace, LevelDebug, LevelInfo, LevelWarn, LevelError} 21 | } 22 | 23 | type Logger struct { 24 | hclog.Logger 25 | f *os.File 26 | } 27 | 28 | func NewLogger(level Level, path string) (*Logger, error) { 29 | if level == "" || path == "" { 30 | return &Logger{Logger: hclog.NewNullLogger()}, nil 31 | } 32 | 33 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | hclogger := hclog.New(&hclog.LoggerOptions{ 39 | Name: "pipeform", 40 | Level: hclog.LevelFromString(string(level)), 41 | Output: f, 42 | }) 43 | 44 | return &Logger{ 45 | Logger: hclogger, 46 | f: f, 47 | }, nil 48 | } 49 | 50 | func (l *Logger) Close() error { 51 | if l.f == nil { 52 | return nil 53 | } 54 | if err := l.f.Close(); err != nil { 55 | return err 56 | } 57 | l.f = nil 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/plainui/ui.go: -------------------------------------------------------------------------------- 1 | package plainui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/magodo/pipeform/internal/csv" 10 | "github.com/magodo/pipeform/internal/log" 11 | "github.com/magodo/pipeform/internal/reader" 12 | "github.com/magodo/pipeform/internal/state" 13 | "github.com/magodo/pipeform/internal/terraform/views" 14 | "github.com/magodo/pipeform/internal/terraform/views/json" 15 | ) 16 | 17 | type UIModel struct { 18 | startTime time.Time 19 | logger *log.Logger 20 | reader reader.Reader 21 | writer io.Writer 22 | 23 | refreshInfos state.ResourceOperationInfos 24 | applyInfos state.ResourceOperationInfos 25 | 26 | totalCnt int 27 | doneCnt int 28 | 29 | isEOF bool 30 | } 31 | 32 | func NewRuntimeModel(logger *log.Logger, reader reader.Reader, writer io.Writer, startTime time.Time) UIModel { 33 | model := UIModel{ 34 | startTime: startTime, 35 | logger: logger, 36 | reader: reader, 37 | writer: writer, 38 | } 39 | 40 | return model 41 | } 42 | 43 | func (m *UIModel) Run() error { 44 | for { 45 | msg, err := m.reader.Next() 46 | if err != nil { 47 | if err == io.EOF { 48 | m.isEOF = true 49 | return nil 50 | } 51 | return err 52 | } 53 | 54 | var msgstr string 55 | switch msg := msg.(type) { 56 | case views.VersionMsg: 57 | msgstr = msg.Message 58 | case views.LogMsg: 59 | kvs := []string{} 60 | for k, v := range msg.KVs { 61 | kvs = append(kvs, fmt.Sprintf("%s=%v", k, v)) 62 | } 63 | msgstr = fmt.Sprintf("%s. %s", msg.Message, strings.Join(kvs, " ")) 64 | case views.DiagnosticsMsg: 65 | msgstr = fmt.Sprintf("Summary: %s.", msg.Diagnostic.Summary) 66 | if msg.Diagnostic.Detail != "" { 67 | msgstr += fmt.Sprintf(" Detail: %s", msg.Diagnostic.Detail) 68 | } 69 | if msg.Level != "info" { 70 | msgstr = fmt.Sprintf("[%s] %s", strings.ToUpper(msg.Level), msgstr) 71 | } 72 | case views.ResourceDriftMsg: 73 | msgstr = msg.Message 74 | case views.PlannedChangeMsg: 75 | // Normally, we don't need to handle the PlannedChangeMsg here, as the ChangeSummaryMsg has all these information. 76 | // The exception is that when apply with a plan file, there is no ChangeSummaryMsg sent from Terraform at this moment. 77 | // (see: https://github.com/magodo/pipeform/issues/1) 78 | // The counting here is a fallback logic to cover the case above. Otherwise, it will just be overwritten by ChangeSummaryMsg. 79 | // 80 | // TODO: Once https://github.com/hashicorp/terraform/pull/36245 merged, remove this part. 81 | // 82 | // Referencing the logic of terraform: internal/command/views/operation.go 83 | // But we also count the "import" 84 | switch msg.Change.Action { 85 | case json.ActionCreate: 86 | m.totalCnt++ 87 | case json.ActionDelete: 88 | m.totalCnt++ 89 | case json.ActionUpdate: 90 | m.totalCnt++ 91 | case json.ActionReplace: 92 | m.totalCnt += 2 93 | case json.ActionImport: 94 | m.totalCnt++ 95 | } 96 | msgstr = msg.Message 97 | 98 | case views.ChangeSummaryMsg: 99 | changes := msg.Changes 100 | m.totalCnt = changes.Add + changes.Change + changes.Import + changes.Remove 101 | msgstr = msg.Message 102 | 103 | case views.OutputMsg: 104 | outputs := []string{} 105 | for name, o := range msg.Outputs { 106 | if o.Action != "" { 107 | continue 108 | } 109 | output := fmt.Sprintf("%s=%s", name, string(o.Value)) 110 | if o.Sensitive { 111 | output += " (sensitive)" 112 | } 113 | outputs = append(outputs, output) 114 | } 115 | msgstr = fmt.Sprintf("%s. %s", msg.Message, strings.Join(outputs, " ")) 116 | 117 | case views.HookMsg: 118 | switch hook := msg.Hook.(type) { 119 | case json.RefreshStart: 120 | res := &state.ResourceOperationInfo{ 121 | Idx: len(m.refreshInfos) + 1, 122 | RawResourceAddr: hook.Resource, 123 | Loc: state.ResourceOperationInfoLocator{ 124 | Module: hook.Resource.Module, 125 | ResourceAddr: hook.Resource.Addr, 126 | Action: "refresh", 127 | }, 128 | Status: state.ResourceOperationStatusStart, 129 | StartTime: msg.TimeStamp, 130 | } 131 | m.refreshInfos = append(m.refreshInfos, res) 132 | msgstr = msg.Message 133 | 134 | case json.RefreshComplete: 135 | loc := state.ResourceOperationInfoLocator{ 136 | Module: hook.Resource.Module, 137 | ResourceAddr: hook.Resource.Addr, 138 | Action: "refresh", 139 | } 140 | status := state.ResourceOperationStatusComplete 141 | update := state.ResourceOperationInfoUpdate{ 142 | Status: &status, 143 | Endtime: &msg.TimeStamp, 144 | } 145 | if m.refreshInfos.Update(loc, update) == nil { 146 | m.logger.Error("RefreshComplete hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", "refresh") 147 | break 148 | } 149 | msgstr = msg.Message 150 | 151 | case json.OperationStart: 152 | info := &state.ResourceOperationInfo{ 153 | Idx: len(m.applyInfos) + 1, 154 | RawResourceAddr: hook.Resource, 155 | Loc: state.ResourceOperationInfoLocator{ 156 | Module: hook.Resource.Module, 157 | ResourceAddr: hook.Resource.Addr, 158 | Action: string(hook.Action), 159 | }, 160 | Status: state.ResourceOperationStatusStart, 161 | StartTime: msg.TimeStamp, 162 | } 163 | m.applyInfos = append(m.applyInfos, info) 164 | 165 | w := width(m.totalCnt) 166 | msgstr = fmt.Sprintf("[%*d/%*d] %s", w, info.Idx, w, m.totalCnt, msg.Message) 167 | 168 | case json.OperationProgress: 169 | loc := state.ResourceOperationInfoLocator{ 170 | Module: hook.Resource.Module, 171 | ResourceAddr: hook.Resource.Addr, 172 | Action: string(hook.Action), 173 | } 174 | info := m.applyInfos.Find(loc) 175 | if info == nil { 176 | m.logger.Error("OperationProgress hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", hook.Action) 177 | break 178 | } 179 | 180 | w := width(m.totalCnt) 181 | msgstr = fmt.Sprintf("[%*d/%*d] %s", w, info.Idx, w, m.totalCnt, msg.Message) 182 | 183 | case json.OperationComplete: 184 | loc := state.ResourceOperationInfoLocator{ 185 | Module: hook.Resource.Module, 186 | ResourceAddr: hook.Resource.Addr, 187 | Action: string(hook.Action), 188 | } 189 | status := state.ResourceOperationStatusComplete 190 | update := state.ResourceOperationInfoUpdate{ 191 | Status: &status, 192 | Endtime: &msg.TimeStamp, 193 | } 194 | info := m.applyInfos.Update(loc, update) 195 | if info == nil { 196 | m.logger.Error("OperationComplete hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", hook.Action) 197 | break 198 | } 199 | 200 | w := width(m.totalCnt) 201 | msgstr = fmt.Sprintf("[%*d/%*d] %s", w, info.Idx, w, m.totalCnt, msg.Message) 202 | 203 | case json.OperationErrored: 204 | loc := state.ResourceOperationInfoLocator{ 205 | Module: hook.Resource.Module, 206 | ResourceAddr: hook.Resource.Addr, 207 | Action: string(hook.Action), 208 | } 209 | status := state.ResourceOperationStatusErrored 210 | update := state.ResourceOperationInfoUpdate{ 211 | Status: &status, 212 | Endtime: &msg.TimeStamp, 213 | } 214 | info := m.applyInfos.Update(loc, update) 215 | if info == nil { 216 | m.logger.Error("OperationErrored hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", hook.Action) 217 | break 218 | } 219 | 220 | w := width(m.totalCnt) 221 | msgstr = fmt.Sprintf("[%*d/%*d] %s", w, info.Idx, w, m.totalCnt, msg.Message) 222 | 223 | case json.ProvisionStart: 224 | msgstr = msg.Message 225 | case json.ProvisionProgress: 226 | msgstr = msg.Message 227 | case json.ProvisionComplete: 228 | msgstr = msg.Message 229 | case json.ProvisionErrored: 230 | msgstr = msg.Message 231 | default: 232 | msgstr = msg.Message 233 | } 234 | } 235 | 236 | m.writer.Write([]byte(msgstr + "\n")) 237 | } 238 | } 239 | 240 | func (m UIModel) IsEOF() bool { 241 | return m.isEOF 242 | } 243 | 244 | func (m UIModel) ToCsv() []byte { 245 | return csv.ToCsv(csv.Input{ 246 | RefreshInfos: m.refreshInfos, 247 | ApplyInfos: m.applyInfos, 248 | }) 249 | } 250 | 251 | func decorateMsg(level, msg string) string { 252 | return msg 253 | } 254 | 255 | func width(n int) int { 256 | var w int 257 | for n != 0 { 258 | n /= 10 259 | w += 1 260 | } 261 | return w 262 | } 263 | -------------------------------------------------------------------------------- /internal/reader/reader.go: -------------------------------------------------------------------------------- 1 | package reader 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | 7 | "github.com/magodo/pipeform/internal/terraform/views" 8 | ) 9 | 10 | type Reader struct { 11 | scanner *bufio.Scanner 12 | teeWriter io.Writer 13 | } 14 | 15 | func NewReader(r io.Reader, teeWriter io.Writer) Reader { 16 | return Reader{ 17 | scanner: bufio.NewScanner(r), 18 | teeWriter: teeWriter, 19 | } 20 | } 21 | 22 | // Next returns the message. 23 | // Otherwise, it returns either the io.EOF error, or others. 24 | func (r *Reader) Next() (views.Message, error) { 25 | if r.scanner.Scan() { 26 | line := r.scanner.Text() 27 | io.WriteString(r.teeWriter, line+"\n") 28 | return views.UnmarshalMessage([]byte(line)) 29 | } 30 | if err := r.scanner.Err(); err != nil { 31 | return nil, err 32 | } 33 | return nil, io.EOF 34 | } 35 | -------------------------------------------------------------------------------- /internal/reader/reader_test.go: -------------------------------------------------------------------------------- 1 | package reader_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "testing" 8 | "time" 9 | 10 | "github.com/magodo/pipeform/internal/reader" 11 | "github.com/magodo/pipeform/internal/terraform/views" 12 | vjson "github.com/magodo/pipeform/internal/terraform/views/json" 13 | "github.com/stretchr/testify/require" 14 | "github.com/zclconf/go-cty/cty" 15 | ctyjson "github.com/zclconf/go-cty/cty/json" 16 | ) 17 | 18 | func mustUnmarshalTime(t *testing.T, v string) time.Time { 19 | var timE time.Time 20 | require.NoError(t, json.Unmarshal([]byte(v), &timE)) 21 | return timE 22 | } 23 | 24 | func TestReader(t *testing.T) { 25 | inputs := []string{ 26 | `{"@level":"info","@message":"Terraform 0.15.4","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.275359-04:00","terraform":"0.15.4","type":"version","ui":"0.1.0"}`, 27 | `{"@level":"info","@message":"random_pet.animal: Plan to create","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.705503-04:00","change":{"resource":{"addr":"random_pet.animal","module":"","resource":"random_pet.animal","implied_provider":"random","resource_type":"random_pet","resource_name":"animal","resource_key":null},"action":"create"},"type":"planned_change"}`, 28 | `{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.705638-04:00","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}`, 29 | `{"@level":"info","@message":"random_pet.animal: Creating...","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.825308-04:00","hook":{"resource":{"addr":"random_pet.animal","module":"","resource":"random_pet.animal","implied_provider":"random","resource_type":"random_pet","resource_name":"animal","resource_key":null},"action":"create"},"type":"apply_start"}`, 30 | `{"@level":"info","@message":"random_pet.animal: Creation complete after 0s [id=smart-lizard]","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.826179-04:00","hook":{"resource":{"addr":"random_pet.animal","module":"","resource":"random_pet.animal","implied_provider":"random","resource_type":"random_pet","resource_name":"animal","resource_key":null},"action":"create","id_key":"id","id_value":"smart-lizard","elapsed_seconds":0},"type":"apply_complete"}`, 31 | `{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.869168-04:00","changes":{"add":1,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"}`, 32 | `{"@level":"info","@message":"Outputs: 1","@module":"terraform.ui","@timestamp":"2021-05-25T13:32:41.869280-04:00","outputs":{"pets":{"sensitive":false,"type":"string","value":"smart-lizard"}},"type":"outputs"}`, 33 | } 34 | 35 | expects := []views.Message{ 36 | views.VersionMsg{ 37 | BaseMsg: views.BaseMsg{ 38 | Level: "info", 39 | Message: "Terraform 0.15.4", 40 | Type: "version", 41 | Module: "terraform.ui", 42 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.275359-04:00"`), 43 | }, 44 | UI: "0.1.0", 45 | Terraform: "0.15.4", 46 | }, 47 | views.PlannedChangeMsg{ 48 | BaseMsg: views.BaseMsg{ 49 | Level: "info", 50 | Message: "random_pet.animal: Plan to create", 51 | Type: "planned_change", 52 | Module: "terraform.ui", 53 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.705503-04:00"`), 54 | }, 55 | Change: &vjson.ResourceInstanceChange{ 56 | Resource: vjson.ResourceAddr{ 57 | Addr: "random_pet.animal", 58 | Module: "", 59 | Resource: "random_pet.animal", 60 | ImpliedProvider: "random", 61 | ResourceType: "random_pet", 62 | ResourceName: "animal", 63 | ResourceKey: ctyjson.SimpleJSONValue{Value: cty.NullVal(cty.DynamicPseudoType)}, 64 | }, 65 | Action: "create", 66 | }, 67 | }, 68 | views.ChangeSummaryMsg{ 69 | BaseMsg: views.BaseMsg{ 70 | Level: "info", 71 | Message: "Plan: 1 to add, 0 to change, 0 to destroy.", 72 | Type: "change_summary", 73 | Module: "terraform.ui", 74 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.705638-04:00"`), 75 | }, 76 | Changes: &vjson.ChangeSummary{ 77 | Add: 1, 78 | Operation: "plan", 79 | }, 80 | }, 81 | views.HookMsg{ 82 | BaseMsg: views.BaseMsg{ 83 | Level: "info", 84 | Message: "random_pet.animal: Creating...", 85 | Type: "apply_start", 86 | Module: "terraform.ui", 87 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.825308-04:00"`), 88 | }, 89 | Hook: vjson.OperationStart{ 90 | Resource: vjson.ResourceAddr{ 91 | Addr: "random_pet.animal", 92 | Module: "", 93 | Resource: "random_pet.animal", 94 | ImpliedProvider: "random", 95 | ResourceType: "random_pet", 96 | ResourceName: "animal", 97 | ResourceKey: ctyjson.SimpleJSONValue{Value: cty.NullVal(cty.DynamicPseudoType)}, 98 | }, 99 | Action: "create", 100 | }, 101 | }, 102 | views.HookMsg{ 103 | BaseMsg: views.BaseMsg{ 104 | Level: "info", 105 | Message: "random_pet.animal: Creation complete after 0s [id=smart-lizard]", 106 | Type: "apply_complete", 107 | Module: "terraform.ui", 108 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.826179-04:00"`), 109 | }, 110 | Hook: vjson.OperationComplete{ 111 | Resource: vjson.ResourceAddr{ 112 | Addr: "random_pet.animal", 113 | Module: "", 114 | Resource: "random_pet.animal", 115 | ImpliedProvider: "random", 116 | ResourceType: "random_pet", 117 | ResourceName: "animal", 118 | ResourceKey: ctyjson.SimpleJSONValue{Value: cty.NullVal(cty.DynamicPseudoType)}, 119 | }, 120 | Action: "create", 121 | IDKey: "id", 122 | IDValue: "smart-lizard", 123 | Elapsed: 0, 124 | }, 125 | }, 126 | views.ChangeSummaryMsg{ 127 | BaseMsg: views.BaseMsg{ 128 | Level: "info", 129 | Message: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", 130 | Type: "change_summary", 131 | Module: "terraform.ui", 132 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.869168-04:00"`), 133 | }, 134 | Changes: &vjson.ChangeSummary{ 135 | Add: 1, 136 | Operation: vjson.OperationApplied, 137 | }, 138 | }, 139 | views.OutputMsg{ 140 | BaseMsg: views.BaseMsg{ 141 | Level: "info", 142 | Message: "Outputs: 1", 143 | Type: "outputs", 144 | Module: "terraform.ui", 145 | TimeStamp: mustUnmarshalTime(t, `"2021-05-25T13:32:41.869280-04:00"`), 146 | }, 147 | Outputs: vjson.Outputs{ 148 | "pets": { 149 | Sensitive: false, 150 | Type: "string", 151 | Value: json.RawMessage([]byte(`"smart-lizard"`)), 152 | }, 153 | }, 154 | }, 155 | } 156 | 157 | buf := bytes.NewBuffer([]byte{}) 158 | for _, input := range inputs { 159 | _, err := buf.WriteString(input) 160 | buf.WriteString("\n") 161 | require.NoError(t, err) 162 | } 163 | reader := reader.NewReader(buf, io.Discard) 164 | 165 | for i := 0; i < len(inputs); i++ { 166 | msg, err := reader.Next() 167 | require.NoError(t, err) 168 | require.Equal(t, expects[i], msg) 169 | } 170 | 171 | _, err := reader.Next() 172 | require.Equal(t, io.EOF, err) 173 | 174 | _, err = reader.Next() 175 | require.Equal(t, io.EOF, err) 176 | } 177 | -------------------------------------------------------------------------------- /internal/state/output_info.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | gojson "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/charmbracelet/bubbles/table" 9 | "github.com/magodo/pipeform/internal/terraform/views/json" 10 | ) 11 | 12 | type OutputInfo struct { 13 | Name string 14 | 15 | Sensitive bool 16 | Type string 17 | ValueStr gojson.RawMessage 18 | Action json.ChangeAction 19 | } 20 | 21 | type OutputInfos []*OutputInfo 22 | 23 | func (infos OutputInfos) ToRows() []table.Row { 24 | var rows []table.Row 25 | for i, info := range infos { 26 | row := []string{ 27 | strconv.Itoa(i + 1), 28 | info.Name, 29 | info.Type, 30 | fmt.Sprintf("%t", info.Sensitive), 31 | string(info.ValueStr), 32 | } 33 | rows = append(rows, row) 34 | } 35 | return rows 36 | } 37 | 38 | func (infos OutputInfos) ToColumns(width int) []table.Column { 39 | const indexWidth = 6 40 | const typeWidth = 8 41 | const sensitiveWidth = 10 42 | 43 | dynamicWidth := width - indexWidth - typeWidth - sensitiveWidth 44 | 45 | nameWidth := dynamicWidth / 2 46 | valueWidth := dynamicWidth / 2 47 | 48 | return []table.Column{ 49 | {Title: "Index", Width: indexWidth}, 50 | {Title: "Name", Width: nameWidth}, 51 | {Title: "Type", Width: typeWidth}, 52 | {Title: "Sensitive", Width: sensitiveWidth}, 53 | {Title: "Value", Width: valueWidth}, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/state/plan_info.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/charmbracelet/bubbles/table" 8 | "github.com/magodo/pipeform/internal/terraform/views/json" 9 | ) 10 | 11 | type PlanInfo struct { 12 | Resource json.ResourceAddr 13 | Action json.ChangeAction 14 | 15 | PrevResource *json.ResourceAddr 16 | Reason json.ChangeReason 17 | } 18 | 19 | type PlanInfos []*PlanInfo 20 | 21 | func (infos PlanInfos) ToRows() []table.Row { 22 | var rows []table.Row 23 | for i, info := range infos { 24 | var comment string 25 | switch info.Action { 26 | case json.ActionDelete, json.ActionReplace: 27 | comment = string(info.Reason) 28 | case json.ActionMove: 29 | if info.PrevResource != nil { 30 | source := info.PrevResource.Addr 31 | if info.PrevResource.Module != "" { 32 | source = fmt.Sprintf("%s (%s)", source, info.PrevResource.Module) 33 | } 34 | comment = fmt.Sprintf("Moved from %s", source) 35 | } 36 | } 37 | row := []string{ 38 | strconv.Itoa(i + 1), 39 | info.Resource.Module, 40 | info.Resource.Addr, 41 | string(info.Action), 42 | comment, 43 | } 44 | rows = append(rows, row) 45 | } 46 | return rows 47 | } 48 | 49 | func (infos PlanInfos) ToColumns(width int) []table.Column { 50 | const indexWidth = 6 51 | const actionWidth = 8 52 | 53 | dynamicWidth := width - indexWidth - actionWidth 54 | 55 | commentWidth := dynamicWidth / 3 56 | moduleWidth := dynamicWidth / 3 57 | resourceWidth := dynamicWidth / 3 58 | 59 | return []table.Column{ 60 | {Title: "Index", Width: indexWidth}, 61 | {Title: "Module", Width: moduleWidth}, 62 | {Title: "Resource", Width: resourceWidth}, 63 | {Title: "Action", Width: actionWidth}, 64 | // Comment is a combination of "reason" (for delete/replace) and a modified version of "previous_resource" (for move) 65 | {Title: "Comment", Width: commentWidth}, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/state/resource_operation_info.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/charmbracelet/bubbles/table" 10 | "github.com/magodo/pipeform/internal/terraform/views/json" 11 | ) 12 | 13 | type ResourceOperationStatus string 14 | 15 | const ( 16 | // Once received one OperationStart hook message 17 | ResourceOperationStatusStart ResourceOperationStatus = "start" 18 | // Once received one OperationComplete hook message 19 | ResourceOperationStatusComplete ResourceOperationStatus = "complete" 20 | // Once received one OperationErrored hook message 21 | ResourceOperationStatusErrored ResourceOperationStatus = "error" 22 | 23 | // TODO: Support refresh? (refresh is an independent lifecycle than the resource apply lifecycle) 24 | // TODO: Support provision? (provision is a intermidiate stage in the resource apply lifecycle) 25 | ) 26 | 27 | func resourceOperationStatusEmoji(status ResourceOperationStatus) string { 28 | switch status { 29 | case ResourceOperationStatusStart: 30 | return "🕛" 31 | case ResourceOperationStatusComplete: 32 | return "✅" 33 | case ResourceOperationStatusErrored: 34 | return "❌" 35 | default: 36 | return "❓" 37 | } 38 | } 39 | 40 | type ResourceOperationInfoLocator struct { 41 | Module string 42 | ResourceAddr string 43 | Action string 44 | } 45 | 46 | type ResourceOperationInfo struct { 47 | Idx int 48 | RawResourceAddr json.ResourceAddr 49 | Loc ResourceOperationInfoLocator 50 | Status ResourceOperationStatus 51 | StartTime time.Time 52 | EndTime time.Time 53 | } 54 | 55 | type ResourceOperationInfoUpdate struct { 56 | Status *ResourceOperationStatus 57 | Endtime *time.Time 58 | } 59 | 60 | // ResourceOperationInfos records the operation information for each resource's action. 61 | type ResourceOperationInfos []*ResourceOperationInfo 62 | 63 | func (infos ResourceOperationInfos) Find(loc ResourceOperationInfoLocator) *ResourceOperationInfo { 64 | for _, info := range infos { 65 | if info.Loc == loc { 66 | return info 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | func (infos ResourceOperationInfos) Update(loc ResourceOperationInfoLocator, update ResourceOperationInfoUpdate) *ResourceOperationInfo { 73 | info := infos.Find(loc) 74 | if info == nil { 75 | return nil 76 | } 77 | if update.Status != nil { 78 | info.Status = *update.Status 79 | } 80 | if update.Endtime != nil { 81 | info.EndTime = *update.Endtime 82 | } 83 | return info 84 | } 85 | 86 | // ToRows turns the ResourceInfos into table rows. 87 | // The total is used to decorate the index as a fraction, if total > 0. 88 | func (infos ResourceOperationInfos) ToRows(total int) []table.Row { 89 | now := time.Now() 90 | var rows []table.Row 91 | for _, info := range infos { 92 | idx := strconv.Itoa(info.Idx) 93 | if total > 0 { 94 | idx = fmt.Sprintf("%d/%d", info.Idx, total) 95 | } 96 | 97 | dur := info.Duration(now) 98 | 99 | module := "-" 100 | if info.Loc.Module != "" { 101 | module = info.Loc.Module 102 | } 103 | 104 | row := []string{ 105 | idx, 106 | resourceOperationStatusEmoji(info.Status), 107 | string(info.Loc.Action), 108 | module, 109 | info.Loc.ResourceAddr, 110 | dur.String(), 111 | } 112 | rows = append(rows, row) 113 | } 114 | return rows 115 | } 116 | 117 | func (infos ResourceOperationInfos) ToColumns(width int) []table.Column { 118 | const statusWidth = 6 119 | const actionWidth = 8 120 | const timeWidth = 24 121 | 122 | dynamicWidth := width - statusWidth - actionWidth - timeWidth 123 | 124 | indexWidth := dynamicWidth / 5 125 | moduleWidth := dynamicWidth / 5 * 2 126 | resourceWidth := dynamicWidth / 5 * 2 127 | 128 | return []table.Column{ 129 | {Title: "Index", Width: indexWidth}, 130 | {Title: "Status", Width: statusWidth}, 131 | {Title: "Action", Width: actionWidth}, 132 | {Title: "Module", Width: moduleWidth}, 133 | {Title: "Resource", Width: resourceWidth}, 134 | {Title: "Time", Width: timeWidth}, 135 | } 136 | } 137 | 138 | func (infos ResourceOperationInfos) ToCsv(stage string) []string { 139 | var out []string 140 | now := time.Now() 141 | for _, info := range infos { 142 | key, _ := info.RawResourceAddr.ResourceKey.MarshalJSON() 143 | line := []string{ 144 | strconv.FormatInt(info.StartTime.Unix(), 10), 145 | strconv.FormatInt(info.EndTime.Unix(), 10), 146 | stage, 147 | info.Loc.Action, 148 | info.Loc.Module, 149 | info.RawResourceAddr.ResourceType, 150 | info.RawResourceAddr.ResourceName, 151 | string(key), 152 | string(info.Status), 153 | strconv.FormatInt(int64(info.Duration(now).Seconds()), 10), 154 | } 155 | out = append(out, strings.Join(line, ",")) 156 | } 157 | return out 158 | } 159 | 160 | func (info ResourceOperationInfo) Duration(now time.Time) time.Duration { 161 | var dur time.Duration 162 | if info.EndTime.Equal(time.Time{}) { 163 | dur = now.Sub(info.StartTime).Truncate(time.Second) 164 | } else { 165 | dur = info.EndTime.Sub(info.StartTime).Truncate(time.Second) 166 | } 167 | return dur 168 | } 169 | -------------------------------------------------------------------------------- /internal/terraform/views/json/change.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | type ResourceInstanceChange struct { 4 | Resource ResourceAddr `json:"resource"` 5 | PreviousResource *ResourceAddr `json:"previous_resource,omitempty"` 6 | Action ChangeAction `json:"action"` 7 | Reason ChangeReason `json:"reason,omitempty"` 8 | Importing *Importing `json:"importing,omitempty"` 9 | GeneratedConfig string `json:"generated_config,omitempty"` 10 | } 11 | 12 | type ChangeAction string 13 | 14 | const ( 15 | ActionNoOp ChangeAction = "noop" 16 | ActionMove ChangeAction = "move" 17 | ActionForget ChangeAction = "remove" 18 | ActionCreate ChangeAction = "create" 19 | ActionRead ChangeAction = "read" 20 | ActionUpdate ChangeAction = "update" 21 | ActionReplace ChangeAction = "replace" 22 | ActionDelete ChangeAction = "delete" 23 | ActionImport ChangeAction = "import" 24 | 25 | // While ephemeral resources do not represent a change 26 | // or participate in the plan in the same way as the above 27 | // we declare them here for convenience in helper functions. 28 | ActionOpen ChangeAction = "open" 29 | ActionRenew ChangeAction = "renew" 30 | ActionClose ChangeAction = "close" 31 | ) 32 | 33 | type ChangeReason string 34 | 35 | const ( 36 | ReasonNone ChangeReason = "" 37 | ReasonTainted ChangeReason = "tainted" 38 | ReasonRequested ChangeReason = "requested" 39 | ReasonReplaceTriggeredBy ChangeReason = "replace_triggered_by" 40 | ReasonCannotUpdate ChangeReason = "cannot_update" 41 | ReasonUnknown ChangeReason = "unknown" 42 | 43 | ReasonDeleteBecauseNoResourceConfig ChangeReason = "delete_because_no_resource_config" 44 | ReasonDeleteBecauseWrongRepetition ChangeReason = "delete_because_wrong_repetition" 45 | ReasonDeleteBecauseCountIndex ChangeReason = "delete_because_count_index" 46 | ReasonDeleteBecauseEachKey ChangeReason = "delete_because_each_key" 47 | ReasonDeleteBecauseNoModule ChangeReason = "delete_because_no_module" 48 | ReasonDeleteBecauseNoMoveTarget ChangeReason = "delete_because_no_move_target" 49 | ReasonReadBecauseConfigUnknown ChangeReason = "read_because_config_unknown" 50 | ReasonReadBecauseDependencyPending ChangeReason = "read_because_dependency_pending" 51 | ReasonReadBecauseCheckNested ChangeReason = "read_because_check_nested" 52 | ) 53 | -------------------------------------------------------------------------------- /internal/terraform/views/json/change_summary.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | type Operation string 4 | 5 | const ( 6 | OperationApplied Operation = "apply" 7 | OperationDestroyed Operation = "destroy" 8 | OperationPlanned Operation = "plan" 9 | ) 10 | 11 | type ChangeSummary struct { 12 | Add int `json:"add"` 13 | Change int `json:"change"` 14 | Import int `json:"import"` 15 | Remove int `json:"remove"` 16 | Operation Operation `json:"operation"` 17 | } 18 | -------------------------------------------------------------------------------- /internal/terraform/views/json/diagnostic.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | const ( 4 | DiagnosticSeverityUnknown = "unknown" 5 | DiagnosticSeverityError = "error" 6 | DiagnosticSeverityWarning = "warning" 7 | ) 8 | 9 | type Diagnostic struct { 10 | Severity string `json:"severity"` 11 | Summary string `json:"summary"` 12 | Detail string `json:"detail"` 13 | Address string `json:"address,omitempty"` 14 | Range *DiagnosticRange `json:"range,omitempty"` 15 | Snippet *DiagnosticSnippet `json:"snippet,omitempty"` 16 | } 17 | 18 | type Pos struct { 19 | Line int `json:"line"` 20 | Column int `json:"column"` 21 | Byte int `json:"byte"` 22 | } 23 | 24 | type DiagnosticRange struct { 25 | Filename string `json:"filename"` 26 | Start Pos `json:"start"` 27 | End Pos `json:"end"` 28 | } 29 | 30 | type DiagnosticSnippet struct { 31 | Context *string `json:"context"` 32 | Code string `json:"code"` 33 | StartLine int `json:"start_line"` 34 | HighlightStartOffset int `json:"highlight_start_offset"` 35 | HighlightEndOffset int `json:"highlight_end_offset"` 36 | Values []DiagnosticExpressionValue `json:"values"` 37 | FunctionCall *DiagnosticFunctionCall `json:"function_call,omitempty"` 38 | } 39 | 40 | type DiagnosticExpressionValue struct { 41 | Traversal string `json:"traversal"` 42 | Statement string `json:"statement"` 43 | } 44 | 45 | type DiagnosticFunctionCall struct { 46 | CalledAs string `json:"called_as"` 47 | Signature *Function `json:"signature,omitempty"` 48 | } 49 | -------------------------------------------------------------------------------- /internal/terraform/views/json/doc.go: -------------------------------------------------------------------------------- 1 | // This is derived from terraform b4a634ced88673d4d208ddee41f39666f51cd133 2 | package json 3 | -------------------------------------------------------------------------------- /internal/terraform/views/json/function.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import "encoding/json" 4 | 5 | type Function struct { 6 | Name string `json:"name"` 7 | Params []FunctionParam `json:"params"` 8 | VariadicParam *FunctionParam `json:"variadic_param,omitempty"` 9 | ReturnType json.RawMessage `json:"return_type"` 10 | Description string `json:"description,omitempty"` 11 | DescriptionKind string `json:"description_kind,omitempty"` 12 | } 13 | 14 | type FunctionParam struct { 15 | Name string `json:"name"` 16 | Type json.RawMessage `json:"type"` 17 | Description string `json:"description,omitempty"` 18 | DescriptionKind string `json:"description_kind,omitempty"` 19 | } 20 | -------------------------------------------------------------------------------- /internal/terraform/views/json/hook.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | type Hook interface { 4 | isHook() 5 | } 6 | 7 | type hook struct{} 8 | 9 | func (hook) isHook() {} 10 | 11 | // OperationStart: triggered by Pre{Apply,EphemeralOp} hook 12 | // msgType can be: 13 | // - MessageApplyStart 14 | // - MessageEphemeralOpStart 15 | type OperationStart struct { 16 | hook 17 | 18 | Resource ResourceAddr `json:"resource"` 19 | Action ChangeAction `json:"action"` 20 | IDKey string `json:"id_key,omitempty"` 21 | IDValue string `json:"id_value,omitempty"` 22 | } 23 | 24 | // OperationProgress: currently triggered by a timer started on Pre{Apply,EphemeralOp}. In 25 | // future, this might also be triggered by provider progress reporting. 26 | // msgType can be: 27 | // - MessageApplyProgress 28 | // - MessageEphemeralOpProgress 29 | type OperationProgress struct { 30 | hook 31 | 32 | Resource ResourceAddr `json:"resource"` 33 | Action ChangeAction `json:"action"` 34 | Elapsed float64 `json:"elapsed_seconds"` 35 | } 36 | 37 | // OperationComplete: triggered by PostApply hook 38 | // msgType can be: 39 | // - MessageApplyComplete 40 | // - MessageEphemeralOpComplete 41 | type OperationComplete struct { 42 | hook 43 | 44 | Resource ResourceAddr `json:"resource"` 45 | Action ChangeAction `json:"action"` 46 | IDKey string `json:"id_key,omitempty"` 47 | IDValue string `json:"id_value,omitempty"` 48 | Elapsed float64 `json:"elapsed_seconds"` 49 | } 50 | 51 | // OperationErrored: triggered by PostApply hook on failure. This will be followed 52 | // by diagnostics when the apply finishes. 53 | // msgType can be: 54 | // - MessageApplyErrored 55 | // - MessageEphemeralOpErrored 56 | type OperationErrored struct { 57 | hook 58 | 59 | Resource ResourceAddr `json:"resource"` 60 | Action ChangeAction `json:"action"` 61 | Elapsed float64 `json:"elapsed_seconds"` 62 | } 63 | 64 | // ProvisionStart: triggered by PreProvisionInstanceStep hook 65 | // msgType can be: 66 | // - MessageProvisionStart 67 | type ProvisionStart struct { 68 | hook 69 | 70 | Resource ResourceAddr `json:"resource"` 71 | Provisioner string `json:"provisioner"` 72 | } 73 | 74 | // ProvisionProgress: triggered by ProvisionOutput hook 75 | // msgType can be: 76 | // - MessageProvisionProgress 77 | type ProvisionProgress struct { 78 | hook 79 | 80 | Resource ResourceAddr `json:"resource"` 81 | Provisioner string `json:"provisioner"` 82 | Output string `json:"output"` 83 | } 84 | 85 | // ProvisionComplete: triggered by PostProvisionInstanceStep hook 86 | // msgType can be: 87 | // - MessageProvisionComplete 88 | type ProvisionComplete struct { 89 | hook 90 | 91 | Resource ResourceAddr `json:"resource"` 92 | Provisioner string `json:"provisioner"` 93 | } 94 | 95 | // ProvisionErrored: triggered by PostProvisionInstanceStep hook on failure. 96 | // This will be followed by diagnostics when the apply finishes. 97 | // msgType can be: 98 | // - MessageProvisionErrored 99 | type ProvisionErrored struct { 100 | hook 101 | 102 | Resource ResourceAddr `json:"resource"` 103 | Provisioner string `json:"provisioner"` 104 | } 105 | 106 | // RefreshStart: triggered by PreRefresh hook 107 | // msgType can be: 108 | // - MessageRefreshStart 109 | type RefreshStart struct { 110 | hook 111 | 112 | Resource ResourceAddr `json:"resource"` 113 | IDKey string `json:"id_key,omitempty"` 114 | IDValue string `json:"id_value,omitempty"` 115 | } 116 | 117 | // RefreshComplete: triggered by PostRefresh hook 118 | // msgType can be: 119 | // - MessageRefreshComplete 120 | type RefreshComplete struct { 121 | hook 122 | 123 | Resource ResourceAddr `json:"resource"` 124 | IDKey string `json:"id_key,omitempty"` 125 | IDValue string `json:"id_value,omitempty"` 126 | } 127 | -------------------------------------------------------------------------------- /internal/terraform/views/json/importing.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | type Importing struct { 4 | ID string `json:"id,omitempty"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/terraform/views/json/message_types.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | type MessageType string 4 | 5 | const ( 6 | // Generic messages 7 | MessageVersion MessageType = "version" 8 | MessageLog MessageType = "log" 9 | MessageDiagnostic MessageType = "diagnostic" 10 | 11 | // Operation results 12 | MessageResourceDrift MessageType = "resource_drift" 13 | MessagePlannedChange MessageType = "planned_change" 14 | MessageChangeSummary MessageType = "change_summary" 15 | MessageOutputs MessageType = "outputs" 16 | 17 | // Hook-driven messages 18 | MessageApplyStart MessageType = "apply_start" 19 | MessageApplyProgress MessageType = "apply_progress" 20 | MessageApplyComplete MessageType = "apply_complete" 21 | MessageApplyErrored MessageType = "apply_errored" 22 | MessageProvisionStart MessageType = "provision_start" 23 | MessageProvisionProgress MessageType = "provision_progress" 24 | MessageProvisionComplete MessageType = "provision_complete" 25 | MessageProvisionErrored MessageType = "provision_errored" 26 | MessageRefreshStart MessageType = "refresh_start" 27 | MessageRefreshComplete MessageType = "refresh_complete" 28 | // Ephemeral operation messages 29 | MessageEphemeralOpStart MessageType = "ephemeral_op_start" 30 | MessageEphemeralOpProgress MessageType = "ephemeral_op_progress" 31 | MessageEphemeralOpComplete MessageType = "ephemeral_op_complete" 32 | MessageEphemeralOpErrored MessageType = "ephemeral_op_errored" 33 | 34 | // Test messages 35 | MessageTestAbstract MessageType = "test_abstract" 36 | MessageTestFile MessageType = "test_file" 37 | MessageTestRun MessageType = "test_run" 38 | MessageTestPlan MessageType = "test_plan" 39 | MessageTestState MessageType = "test_state" 40 | MessageTestSummary MessageType = "test_summary" 41 | MessageTestCleanup MessageType = "test_cleanup" 42 | MessageTestInterrupt MessageType = "test_interrupt" 43 | MessageTestStatus MessageType = "test_status" 44 | MessageTestRetry MessageType = "test_retry" 45 | ) 46 | -------------------------------------------------------------------------------- /internal/terraform/views/json/output.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import "encoding/json" 4 | 5 | type Output struct { 6 | Sensitive bool `json:"sensitive"` 7 | Type string `json:"type,omitempty"` 8 | Value json.RawMessage `json:"value,omitempty"` 9 | Action ChangeAction `json:"action,omitempty"` 10 | } 11 | 12 | type Outputs map[string]Output 13 | -------------------------------------------------------------------------------- /internal/terraform/views/json/resource_addr.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | ctyjson "github.com/zclconf/go-cty/cty/json" 5 | ) 6 | 7 | type ResourceAddr struct { 8 | Addr string `json:"addr"` 9 | Module string `json:"module"` 10 | Resource string `json:"resource"` 11 | ImpliedProvider string `json:"implied_provider"` 12 | ResourceType string `json:"resource_type"` 13 | ResourceName string `json:"resource_name"` 14 | ResourceKey ctyjson.SimpleJSONValue `json:"resource_key"` 15 | } 16 | -------------------------------------------------------------------------------- /internal/terraform/views/json_view.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | gojson "encoding/json" 8 | 9 | "github.com/magodo/pipeform/internal/terraform/views/json" 10 | ) 11 | 12 | type Message interface { 13 | BaseMessage() BaseMsg 14 | } 15 | 16 | // This file define structures corresponding to the different logs defined in: 17 | // terraform/internal/command/views/json_view.go 18 | 19 | type BaseMsg struct { 20 | Level string `json:"@level"` 21 | Message string `json:"@message"` 22 | Module string `json:"@module"` 23 | TimeStamp time.Time `json:"@timestamp"` 24 | Type json.MessageType `json:"type"` 25 | } 26 | 27 | func (m BaseMsg) BaseMessage() BaseMsg { 28 | return m 29 | } 30 | 31 | type VersionMsg struct { 32 | BaseMsg 33 | Terraform string `json:"terraform"` 34 | Tofu string `json:"tofu"` 35 | UI string `json:"ui"` 36 | } 37 | 38 | func (m VersionMsg) BaseMessage() BaseMsg { 39 | return m.BaseMsg 40 | } 41 | 42 | type LogMsg struct { 43 | BaseMsg 44 | KVs map[string]interface{} 45 | } 46 | 47 | func (m LogMsg) BaseMessage() BaseMsg { 48 | return m.BaseMsg 49 | } 50 | 51 | func (v LogMsg) MarshalJSON() ([]byte, error) { 52 | b, err := gojson.Marshal(v.BaseMsg) 53 | if err != nil { 54 | return nil, err 55 | } 56 | var m map[string]interface{} 57 | if err := gojson.Unmarshal(b, &m); err != nil { 58 | return nil, err 59 | } 60 | 61 | for k, v := range v.KVs { 62 | if _, ok := m[k]; !ok { 63 | m[k] = v 64 | } 65 | } 66 | 67 | return gojson.Marshal(m) 68 | } 69 | 70 | type DiagnosticsMsg struct { 71 | BaseMsg 72 | Diagnostic *json.Diagnostic `json:"diagnostic"` 73 | } 74 | 75 | func (m DiagnosticsMsg) BaseMessage() BaseMsg { 76 | return m.BaseMsg 77 | } 78 | 79 | type ResourceDriftMsg struct { 80 | BaseMsg 81 | Change *json.ResourceInstanceChange `json:"change"` 82 | } 83 | 84 | func (m ResourceDriftMsg) BaseMessage() BaseMsg { 85 | return m.BaseMsg 86 | } 87 | 88 | type PlannedChangeMsg struct { 89 | BaseMsg 90 | Change *json.ResourceInstanceChange `json:"change"` 91 | } 92 | 93 | func (m PlannedChangeMsg) BaseMessage() BaseMsg { 94 | return m.BaseMsg 95 | } 96 | 97 | type ChangeSummaryMsg struct { 98 | BaseMsg 99 | Changes *json.ChangeSummary `json:"changes"` 100 | } 101 | 102 | func (m ChangeSummaryMsg) BaseMessage() BaseMsg { 103 | return m.BaseMsg 104 | } 105 | 106 | type OutputMsg struct { 107 | BaseMsg 108 | Outputs json.Outputs `json:"outputs"` 109 | } 110 | 111 | func (m OutputMsg) BaseMessage() BaseMsg { 112 | return m.BaseMsg 113 | } 114 | 115 | type HookMsg struct { 116 | BaseMsg 117 | json.Hook `json:"hook"` 118 | } 119 | 120 | func (m HookMsg) BaseMessage() BaseMsg { 121 | return m.BaseMsg 122 | } 123 | 124 | func UnmarshalMessage(b []byte) (Message, error) { 125 | var baseMsg BaseMsg 126 | if err := gojson.Unmarshal(b, &baseMsg); err != nil { 127 | return nil, err 128 | } 129 | 130 | switch baseMsg.Type { 131 | case json.MessageVersion: 132 | var msg VersionMsg 133 | if err := gojson.Unmarshal(b, &msg); err != nil { 134 | return nil, err 135 | } 136 | return msg, nil 137 | 138 | case json.MessageLog: 139 | var m map[string]interface{} 140 | if err := gojson.Unmarshal(b, &m); err != nil { 141 | return nil, err 142 | } 143 | var msg LogMsg 144 | if err := gojson.Unmarshal(b, &msg); err != nil { 145 | return nil, err 146 | } 147 | b, err := gojson.Marshal(msg) 148 | if err != nil { 149 | return nil, err 150 | } 151 | var m2 map[string]interface{} 152 | if err := gojson.Unmarshal(b, &m2); err != nil { 153 | return nil, err 154 | } 155 | 156 | msg.KVs = map[string]interface{}{} 157 | for k, v := range m { 158 | if _, ok := m2[k]; !ok { 159 | msg.KVs[k] = v 160 | } 161 | } 162 | 163 | return msg, nil 164 | 165 | case json.MessageDiagnostic: 166 | var msg DiagnosticsMsg 167 | if err := gojson.Unmarshal(b, &msg); err != nil { 168 | return nil, err 169 | } 170 | return msg, nil 171 | 172 | case json.MessageResourceDrift: 173 | var msg ResourceDriftMsg 174 | if err := gojson.Unmarshal(b, &msg); err != nil { 175 | return nil, err 176 | } 177 | return msg, nil 178 | 179 | case json.MessagePlannedChange: 180 | var msg PlannedChangeMsg 181 | if err := gojson.Unmarshal(b, &msg); err != nil { 182 | return nil, err 183 | } 184 | return msg, nil 185 | 186 | case json.MessageChangeSummary: 187 | var msg ChangeSummaryMsg 188 | if err := gojson.Unmarshal(b, &msg); err != nil { 189 | return nil, err 190 | } 191 | return msg, nil 192 | 193 | case json.MessageOutputs: 194 | var msg OutputMsg 195 | if err := gojson.Unmarshal(b, &msg); err != nil { 196 | return nil, err 197 | } 198 | return msg, nil 199 | 200 | case json.MessageApplyStart, json.MessageEphemeralOpStart: 201 | temp := struct { 202 | BaseMsg 203 | json.OperationStart `json:"hook"` 204 | }{} 205 | if err := gojson.Unmarshal(b, &temp); err != nil { 206 | return nil, err 207 | } 208 | 209 | return HookMsg{ 210 | BaseMsg: temp.BaseMsg, 211 | Hook: temp.OperationStart, 212 | }, nil 213 | 214 | case json.MessageApplyProgress, json.MessageEphemeralOpProgress: 215 | temp := struct { 216 | BaseMsg 217 | json.OperationProgress `json:"hook"` 218 | }{} 219 | if err := gojson.Unmarshal(b, &temp); err != nil { 220 | return nil, err 221 | } 222 | 223 | return HookMsg{ 224 | BaseMsg: temp.BaseMsg, 225 | Hook: temp.OperationProgress, 226 | }, nil 227 | 228 | case json.MessageApplyComplete, json.MessageEphemeralOpComplete: 229 | temp := struct { 230 | BaseMsg 231 | json.OperationComplete `json:"hook"` 232 | }{} 233 | if err := gojson.Unmarshal(b, &temp); err != nil { 234 | return nil, err 235 | } 236 | 237 | return HookMsg{ 238 | BaseMsg: temp.BaseMsg, 239 | Hook: temp.OperationComplete, 240 | }, nil 241 | 242 | case json.MessageApplyErrored, json.MessageEphemeralOpErrored: 243 | temp := struct { 244 | BaseMsg 245 | json.OperationErrored `json:"hook"` 246 | }{} 247 | if err := gojson.Unmarshal(b, &temp); err != nil { 248 | return nil, err 249 | } 250 | 251 | return HookMsg{ 252 | BaseMsg: temp.BaseMsg, 253 | Hook: temp.OperationErrored, 254 | }, nil 255 | 256 | case json.MessageProvisionStart: 257 | temp := struct { 258 | BaseMsg 259 | json.ProvisionStart `json:"hook"` 260 | }{} 261 | if err := gojson.Unmarshal(b, &temp); err != nil { 262 | return nil, err 263 | } 264 | 265 | return HookMsg{ 266 | BaseMsg: temp.BaseMsg, 267 | Hook: temp.ProvisionStart, 268 | }, nil 269 | 270 | case json.MessageProvisionProgress: 271 | temp := struct { 272 | BaseMsg 273 | json.ProvisionProgress `json:"hook"` 274 | }{} 275 | if err := gojson.Unmarshal(b, &temp); err != nil { 276 | return nil, err 277 | } 278 | 279 | return HookMsg{ 280 | BaseMsg: temp.BaseMsg, 281 | Hook: temp.ProvisionProgress, 282 | }, nil 283 | 284 | case json.MessageProvisionComplete: 285 | temp := struct { 286 | BaseMsg 287 | json.ProvisionComplete `json:"hook"` 288 | }{} 289 | if err := gojson.Unmarshal(b, &temp); err != nil { 290 | return nil, err 291 | } 292 | 293 | return HookMsg{ 294 | BaseMsg: temp.BaseMsg, 295 | Hook: temp.ProvisionComplete, 296 | }, nil 297 | 298 | case json.MessageProvisionErrored: 299 | temp := struct { 300 | BaseMsg 301 | json.ProvisionErrored `json:"hook"` 302 | }{} 303 | if err := gojson.Unmarshal(b, &temp); err != nil { 304 | return nil, err 305 | } 306 | 307 | return HookMsg{ 308 | BaseMsg: temp.BaseMsg, 309 | Hook: temp.ProvisionErrored, 310 | }, nil 311 | 312 | case json.MessageRefreshStart: 313 | temp := struct { 314 | BaseMsg 315 | json.RefreshStart `json:"hook"` 316 | }{} 317 | if err := gojson.Unmarshal(b, &temp); err != nil { 318 | return nil, err 319 | } 320 | 321 | return HookMsg{ 322 | BaseMsg: temp.BaseMsg, 323 | Hook: temp.RefreshStart, 324 | }, nil 325 | 326 | case json.MessageRefreshComplete: 327 | temp := struct { 328 | BaseMsg 329 | json.RefreshComplete `json:"hook"` 330 | }{} 331 | if err := gojson.Unmarshal(b, &temp); err != nil { 332 | return nil, err 333 | } 334 | 335 | return HookMsg{ 336 | BaseMsg: temp.BaseMsg, 337 | Hook: temp.RefreshComplete, 338 | }, nil 339 | 340 | default: 341 | return nil, fmt.Errorf("unhandled message type: %s", baseMsg.Type) 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /internal/terraform/views/json_view_test.go: -------------------------------------------------------------------------------- 1 | package views_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | gojson "encoding/json" 8 | 9 | "github.com/magodo/pipeform/internal/terraform/views" 10 | "github.com/magodo/pipeform/internal/terraform/views/json" 11 | "github.com/stretchr/testify/require" 12 | "github.com/zclconf/go-cty/cty" 13 | ctyjson "github.com/zclconf/go-cty/cty/json" 14 | ) 15 | 16 | var timE, _ = time.Parse(time.RFC3339, "2024-12-09T10:25:00Z") 17 | 18 | var resourceAddr = json.ResourceAddr{ 19 | Addr: "random_pet.animal", 20 | Module: "", 21 | Resource: "random_pet.animal", 22 | ImpliedProvider: "random", 23 | ResourceType: "random_pet", 24 | ResourceName: "animal", 25 | ResourceKey: ctyjson.SimpleJSONValue{Value: cty.NullVal(cty.DynamicPseudoType)}, 26 | } 27 | 28 | var change = json.ResourceInstanceChange{ 29 | Resource: resourceAddr, 30 | PreviousResource: nil, 31 | Action: json.ActionCreate, 32 | Reason: json.ReasonRequested, 33 | Importing: nil, 34 | GeneratedConfig: "", 35 | } 36 | 37 | var changeSummary = json.ChangeSummary{ 38 | Add: 1, 39 | Change: 2, 40 | Import: 3, 41 | Remove: 4, 42 | Operation: json.OperationApplied, 43 | } 44 | 45 | var outputs = json.Outputs{ 46 | "foo": { 47 | Sensitive: true, 48 | Type: "string", 49 | Value: []byte(`"foo"`), 50 | Action: json.ActionCreate, 51 | }, 52 | } 53 | 54 | func newBaseMsg(typ json.MessageType) views.BaseMsg { 55 | return views.BaseMsg{ 56 | Level: "info", 57 | Message: "base message", 58 | Module: "terraform.ui", 59 | TimeStamp: timE, 60 | Type: typ, 61 | } 62 | } 63 | 64 | func TestMarshal(t *testing.T) { 65 | cases := []struct { 66 | name string 67 | msg views.Message 68 | expect string 69 | }{ 70 | { 71 | name: "Version Message", 72 | msg: views.VersionMsg{ 73 | BaseMsg: newBaseMsg(json.MessageVersion), 74 | Terraform: "1.10.0", 75 | UI: "0.1.0", 76 | }, 77 | expect: ` 78 | { 79 | "@level": "info", 80 | "@message": "base message", 81 | "@module": "terraform.ui", 82 | "@timestamp": "2024-12-09T10:25:00Z", 83 | "type": "version", 84 | "terraform": "1.10.0", 85 | "tofu": "", 86 | "ui": "0.1.0" 87 | } 88 | `, 89 | }, 90 | { 91 | name: "Log Message", 92 | msg: views.LogMsg{ 93 | BaseMsg: newBaseMsg(json.MessageLog), 94 | KVs: map[string]interface{}{"k1": "v1"}, 95 | }, 96 | expect: ` 97 | { 98 | "@level": "info", 99 | "@message": "base message", 100 | "@module": "terraform.ui", 101 | "@timestamp": "2024-12-09T10:25:00Z", 102 | "type": "log", 103 | "k1": "v1" 104 | } 105 | `, 106 | }, 107 | { 108 | name: "Diagnostic Message", 109 | msg: views.DiagnosticsMsg{ 110 | BaseMsg: newBaseMsg(json.MessageDiagnostic), 111 | Diagnostic: &json.Diagnostic{ 112 | Severity: "sev1", 113 | Summary: "summary1", 114 | Detail: "detail1", 115 | Address: "foo.bar", 116 | Range: &json.DiagnosticRange{ 117 | Filename: "file.tf", 118 | Start: json.Pos{ 119 | Line: 1, 120 | Column: 1, 121 | Byte: 1, 122 | }, 123 | End: json.Pos{ 124 | Line: 1, 125 | Column: 1, 126 | Byte: 1, 127 | }, 128 | }, 129 | }, 130 | }, 131 | expect: ` 132 | { 133 | "@level": "info", 134 | "@message": "base message", 135 | "@module": "terraform.ui", 136 | "@timestamp": "2024-12-09T10:25:00Z", 137 | "type": "diagnostic", 138 | "diagnostic": { 139 | "address": "foo.bar", 140 | "detail": "detail1", 141 | "range": { 142 | "filename": "file.tf", 143 | "start": { 144 | "line": 1, 145 | "column": 1, 146 | "byte": 1 147 | }, 148 | "end": { 149 | "line": 1, 150 | "column": 1, 151 | "byte": 1 152 | } 153 | }, 154 | "severity": "sev1", 155 | "summary": "summary1" 156 | } 157 | } 158 | `, 159 | }, 160 | { 161 | name: "Planned Change Message", 162 | msg: views.PlannedChangeMsg{ 163 | BaseMsg: newBaseMsg(json.MessagePlannedChange), 164 | Change: &change, 165 | }, 166 | expect: ` 167 | { 168 | "@level": "info", 169 | "@message": "base message", 170 | "@module": "terraform.ui", 171 | "@timestamp": "2024-12-09T10:25:00Z", 172 | "type": "planned_change", 173 | "change": { 174 | "resource": { 175 | "addr": "random_pet.animal", 176 | "implied_provider": "random", 177 | "module": "", 178 | "resource": "random_pet.animal", 179 | "resource_key": null, 180 | "resource_type": "random_pet", 181 | "resource_name": "animal" 182 | }, 183 | "action": "create", 184 | "reason": "requested" 185 | } 186 | } 187 | `, 188 | }, 189 | { 190 | name: "Resource Drift Message", 191 | msg: views.ResourceDriftMsg{ 192 | BaseMsg: newBaseMsg(json.MessageResourceDrift), 193 | Change: &change, 194 | }, 195 | expect: ` 196 | { 197 | "@level": "info", 198 | "@message": "base message", 199 | "@module": "terraform.ui", 200 | "@timestamp": "2024-12-09T10:25:00Z", 201 | "type": "resource_drift", 202 | "change": { 203 | "resource": { 204 | "addr": "random_pet.animal", 205 | "implied_provider": "random", 206 | "module": "", 207 | "resource": "random_pet.animal", 208 | "resource_key": null, 209 | "resource_type": "random_pet", 210 | "resource_name": "animal" 211 | }, 212 | "action": "create", 213 | "reason": "requested" 214 | } 215 | } 216 | `, 217 | }, 218 | { 219 | name: "Change Summary Message", 220 | msg: views.ChangeSummaryMsg{ 221 | BaseMsg: newBaseMsg(json.MessageChangeSummary), 222 | Changes: &changeSummary, 223 | }, 224 | expect: ` 225 | { 226 | "@level": "info", 227 | "@message": "base message", 228 | "@module": "terraform.ui", 229 | "@timestamp": "2024-12-09T10:25:00Z", 230 | "type": "change_summary", 231 | "changes": { 232 | "add": 1, 233 | "change": 2, 234 | "import": 3, 235 | "remove": 4, 236 | "operation": "apply" 237 | } 238 | } 239 | `, 240 | }, 241 | { 242 | name: "Output Message", 243 | msg: views.OutputMsg{ 244 | BaseMsg: newBaseMsg(json.MessageOutputs), 245 | Outputs: outputs, 246 | }, 247 | expect: ` 248 | { 249 | "@level": "info", 250 | "@message": "base message", 251 | "@module": "terraform.ui", 252 | "@timestamp": "2024-12-09T10:25:00Z", 253 | "type": "outputs", 254 | "outputs": { 255 | "foo": { 256 | "action": "create", 257 | "sensitive": true, 258 | "type": "string", 259 | "value": "foo" 260 | } 261 | } 262 | } 263 | `, 264 | }, 265 | { 266 | name: "Hook Message (Operation Start)", 267 | msg: views.HookMsg{ 268 | BaseMsg: newBaseMsg(json.MessageApplyStart), 269 | Hook: json.OperationStart{ 270 | Resource: resourceAddr, 271 | Action: json.ActionCreate, 272 | IDKey: "id", 273 | IDValue: "/foo/bar", 274 | }, 275 | }, 276 | expect: ` 277 | { 278 | "@level": "info", 279 | "@message": "base message", 280 | "@module": "terraform.ui", 281 | "@timestamp": "2024-12-09T10:25:00Z", 282 | "type": "apply_start", 283 | "hook": { 284 | "resource": { 285 | "addr": "random_pet.animal", 286 | "implied_provider": "random", 287 | "module": "", 288 | "resource": "random_pet.animal", 289 | "resource_key": null, 290 | "resource_type": "random_pet", 291 | "resource_name": "animal" 292 | }, 293 | "action": "create", 294 | "id_key": "id", 295 | "id_value": "/foo/bar" 296 | } 297 | } 298 | `, 299 | }, 300 | } 301 | 302 | for _, tt := range cases { 303 | t.Run(tt.name, func(t *testing.T) { 304 | b, err := gojson.Marshal(tt.msg) 305 | require.NoError(t, err) 306 | require.JSONEq(t, tt.expect, string(b)) 307 | }) 308 | } 309 | } 310 | 311 | func TestUnmarshal(t *testing.T) { 312 | cases := []struct { 313 | name string 314 | input string 315 | msg views.Message 316 | }{ 317 | { 318 | name: "Version Message", 319 | input: ` 320 | { 321 | "@level": "info", 322 | "@message": "base message", 323 | "@module": "terraform.ui", 324 | "@timestamp": "2024-12-09T10:25:00Z", 325 | "type": "version", 326 | "terraform": "1.10.0", 327 | "ui": "0.1.0" 328 | } 329 | `, 330 | msg: views.VersionMsg{ 331 | BaseMsg: newBaseMsg(json.MessageVersion), 332 | Terraform: "1.10.0", 333 | UI: "0.1.0", 334 | }, 335 | }, 336 | 337 | { 338 | name: "Log Message", 339 | input: ` 340 | { 341 | "@level": "info", 342 | "@message": "base message", 343 | "@module": "terraform.ui", 344 | "@timestamp": "2024-12-09T10:25:00Z", 345 | "type": "log", 346 | "k1": "v1" 347 | } 348 | `, 349 | msg: views.LogMsg{ 350 | BaseMsg: newBaseMsg(json.MessageLog), 351 | KVs: map[string]interface{}{"k1": "v1"}, 352 | }, 353 | }, 354 | { 355 | name: "Diagnostic Message", 356 | input: ` 357 | { 358 | "@level": "info", 359 | "@message": "base message", 360 | "@module": "terraform.ui", 361 | "@timestamp": "2024-12-09T10:25:00Z", 362 | "type": "diagnostic", 363 | "diagnostic": { 364 | "address": "foo.bar", 365 | "detail": "detail1", 366 | "range": { 367 | "filename": "file.tf", 368 | "start": { 369 | "line": 1, 370 | "column": 1, 371 | "byte": 1 372 | }, 373 | "end": { 374 | "line": 1, 375 | "column": 1, 376 | "byte": 1 377 | } 378 | }, 379 | "severity": "sev1", 380 | "summary": "summary1" 381 | } 382 | } 383 | `, 384 | msg: views.DiagnosticsMsg{ 385 | BaseMsg: newBaseMsg(json.MessageDiagnostic), 386 | Diagnostic: &json.Diagnostic{ 387 | Severity: "sev1", 388 | Summary: "summary1", 389 | Detail: "detail1", 390 | Address: "foo.bar", 391 | Range: &json.DiagnosticRange{ 392 | Filename: "file.tf", 393 | Start: json.Pos{ 394 | Line: 1, 395 | Column: 1, 396 | Byte: 1, 397 | }, 398 | End: json.Pos{ 399 | Line: 1, 400 | Column: 1, 401 | Byte: 1, 402 | }, 403 | }, 404 | }, 405 | }, 406 | }, 407 | { 408 | name: "Planned Change Message", 409 | input: ` 410 | { 411 | "@level": "info", 412 | "@message": "base message", 413 | "@module": "terraform.ui", 414 | "@timestamp": "2024-12-09T10:25:00Z", 415 | "type": "planned_change", 416 | "change": { 417 | "resource": { 418 | "addr": "random_pet.animal", 419 | "implied_provider": "random", 420 | "module": "", 421 | "resource": "random_pet.animal", 422 | "resource_key": null, 423 | "resource_type": "random_pet", 424 | "resource_name": "animal" 425 | }, 426 | "action": "create", 427 | "reason": "requested" 428 | } 429 | } 430 | `, 431 | msg: views.PlannedChangeMsg{ 432 | BaseMsg: newBaseMsg(json.MessagePlannedChange), 433 | Change: &change, 434 | }, 435 | }, 436 | { 437 | name: "Resource Drift Message", 438 | input: ` 439 | { 440 | "@level": "info", 441 | "@message": "base message", 442 | "@module": "terraform.ui", 443 | "@timestamp": "2024-12-09T10:25:00Z", 444 | "type": "resource_drift", 445 | "change": { 446 | "resource": { 447 | "addr": "random_pet.animal", 448 | "implied_provider": "random", 449 | "module": "", 450 | "resource": "random_pet.animal", 451 | "resource_key": null, 452 | "resource_type": "random_pet", 453 | "resource_name": "animal" 454 | }, 455 | "action": "create", 456 | "reason": "requested" 457 | } 458 | } 459 | `, 460 | msg: views.ResourceDriftMsg{ 461 | BaseMsg: newBaseMsg(json.MessageResourceDrift), 462 | Change: &change, 463 | }, 464 | }, 465 | { 466 | name: "Change Summary Message", 467 | input: ` 468 | { 469 | "@level": "info", 470 | "@message": "base message", 471 | "@module": "terraform.ui", 472 | "@timestamp": "2024-12-09T10:25:00Z", 473 | "type": "change_summary", 474 | "changes": { 475 | "add": 1, 476 | "change": 2, 477 | "import": 3, 478 | "remove": 4, 479 | "operation": "apply" 480 | } 481 | } 482 | `, 483 | msg: views.ChangeSummaryMsg{ 484 | BaseMsg: newBaseMsg(json.MessageChangeSummary), 485 | Changes: &changeSummary, 486 | }, 487 | }, 488 | { 489 | name: "Output Message", 490 | input: ` 491 | { 492 | "@level": "info", 493 | "@message": "base message", 494 | "@module": "terraform.ui", 495 | "@timestamp": "2024-12-09T10:25:00Z", 496 | "type": "outputs", 497 | "outputs": { 498 | "foo": { 499 | "action": "create", 500 | "sensitive": true, 501 | "type": "string", 502 | "value": "foo" 503 | } 504 | } 505 | } 506 | `, 507 | msg: views.OutputMsg{ 508 | BaseMsg: newBaseMsg(json.MessageOutputs), 509 | Outputs: outputs, 510 | }, 511 | }, 512 | { 513 | name: "Hook Message (Operation Start)", 514 | input: ` 515 | { 516 | "@level": "info", 517 | "@message": "base message", 518 | "@module": "terraform.ui", 519 | "@timestamp": "2024-12-09T10:25:00Z", 520 | "type": "apply_start", 521 | "hook": { 522 | "resource": { 523 | "addr": "random_pet.animal", 524 | "implied_provider": "random", 525 | "module": "", 526 | "resource": "random_pet.animal", 527 | "resource_key": null, 528 | "resource_type": "random_pet", 529 | "resource_name": "animal" 530 | }, 531 | "action": "create", 532 | "id_key": "id", 533 | "id_value": "/foo/bar" 534 | } 535 | } 536 | `, 537 | msg: views.HookMsg{ 538 | BaseMsg: newBaseMsg(json.MessageApplyStart), 539 | Hook: json.OperationStart{ 540 | Resource: resourceAddr, 541 | Action: json.ActionCreate, 542 | IDKey: "id", 543 | IDValue: "/foo/bar", 544 | }, 545 | }, 546 | }, 547 | } 548 | 549 | for _, tt := range cases { 550 | t.Run(tt.name, func(t *testing.T) { 551 | msg, err := views.UnmarshalMessage([]byte(tt.input)) 552 | require.NoError(t, err) 553 | require.Equal(t, tt.msg, msg) 554 | }) 555 | } 556 | } 557 | -------------------------------------------------------------------------------- /internal/terraform/views/test.go: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | // This file define structures corresponding to the different logs defined in: 4 | // terraform/internal/command/views/test.go (TestJSON) 5 | -------------------------------------------------------------------------------- /internal/ui/diags.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/magodo/pipeform/internal/terraform/views/json" 7 | ) 8 | 9 | type Diags []json.Diagnostic 10 | 11 | func (diags Diags) HasError() bool { 12 | for _, diag := range diags { 13 | if strings.EqualFold(diag.Severity, "error") { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /internal/ui/keymap.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/paginator" 6 | "github.com/charmbracelet/bubbles/table" 7 | ) 8 | 9 | type KeyMap struct { 10 | TableKeyMap table.KeyMap 11 | PaginatorMap paginator.KeyMap 12 | 13 | Follow key.Binding 14 | Quit key.Binding 15 | Copy key.Binding 16 | 17 | Help key.Binding 18 | } 19 | 20 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 21 | // of the key.Map interface. 22 | func (k KeyMap) ShortHelp() []key.Binding { 23 | tableHelp := k.TableKeyMap.ShortHelp() 24 | return append([]key.Binding{k.Follow, k.Quit, k.Copy, k.PaginatorMap.PrevPage, k.PaginatorMap.NextPage, k.Help}, tableHelp...) 25 | } 26 | 27 | // FullHelp returns keybindings for the expanded help view. It's part of the 28 | // key.Map interface. 29 | func (k KeyMap) FullHelp() [][]key.Binding { 30 | tableHelp := k.TableKeyMap.FullHelp() 31 | return append([][]key.Binding{{k.Follow, k.Quit, k.Copy, k.Help, k.PaginatorMap.PrevPage, k.PaginatorMap.NextPage}}, tableHelp...) 32 | } 33 | 34 | func NewKeyMap(clipboardEnabled bool) KeyMap { 35 | keymap := KeyMap{ 36 | TableKeyMap: table.DefaultKeyMap(), 37 | PaginatorMap: paginator.KeyMap{ 38 | PrevPage: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("← / h", "left page"), key.WithDisabled()), 39 | NextPage: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→ / l", "right page"), key.WithDisabled()), 40 | }, 41 | Follow: key.NewBinding( 42 | key.WithKeys("f"), 43 | key.WithHelp("f", "follow"), 44 | ), 45 | Quit: key.NewBinding( 46 | key.WithKeys("ctrl+c"), 47 | key.WithHelp("ctrl+c", "quit"), 48 | ), 49 | Copy: key.NewBinding( 50 | key.WithKeys("c"), 51 | key.WithHelp("c", "copy"), 52 | ), 53 | Help: key.NewBinding( 54 | key.WithKeys("?"), 55 | key.WithHelp("?", "toggle help"), 56 | ), 57 | } 58 | 59 | if !clipboardEnabled { 60 | keymap.Copy.SetEnabled(false) 61 | } 62 | 63 | return keymap 64 | } 65 | 66 | func (km *KeyMap) EnablePaginator() { 67 | km.PaginatorMap.PrevPage.SetEnabled(true) 68 | km.PaginatorMap.NextPage.SetEnabled(true) 69 | } 70 | -------------------------------------------------------------------------------- /internal/ui/message.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/magodo/pipeform/internal/terraform/views" 4 | 5 | type receiverMsg struct { 6 | msg views.Message 7 | } 8 | 9 | type receiverEOFMsg struct{} 10 | 11 | type receiverErrorMsg struct { 12 | err error 13 | } 14 | 15 | func (m receiverErrorMsg) Error() string { 16 | return m.err.Error() 17 | } 18 | -------------------------------------------------------------------------------- /internal/ui/size.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | type Size struct { 4 | Height int 5 | Width int 6 | } 7 | -------------------------------------------------------------------------------- /internal/ui/style.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/table" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | // Colors for dark and light backgrounds. 9 | var ( 10 | ColorIndigo = lipgloss.AdaptiveColor{Dark: "#7571F9", Light: "#5A56E0"} 11 | ColorSubtleIndigo = lipgloss.AdaptiveColor{Dark: "#514DC1", Light: "#7D79F6"} 12 | ColorCream = lipgloss.AdaptiveColor{Dark: "#FFFDF5", Light: "#FFFDF5"} 13 | ColorYellowGreen = lipgloss.AdaptiveColor{Dark: "#ECFD65", Light: "#04B575"} 14 | ColorFuschia = lipgloss.AdaptiveColor{Dark: "#EE6FF8", Light: "#EE6FF8"} 15 | ColorGreen = lipgloss.AdaptiveColor{Dark: "#04B575", Light: "#04B575"} 16 | ColorRed = lipgloss.AdaptiveColor{Dark: "#ED567A", Light: "#FF4672"} 17 | ColorFaintRed = lipgloss.AdaptiveColor{Dark: "#C74665", Light: "#FF6F91"} 18 | ColorGrey = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} 19 | ColorNoColor = lipgloss.AdaptiveColor{Dark: "", Light: ""} 20 | ) 21 | 22 | var ( 23 | StyleTitle = lipgloss.NewStyle().Foreground(ColorCream).Background(ColorIndigo) 24 | StyleSubtitle = lipgloss.NewStyle().Foreground(ColorCream).Background(ColorSubtleIndigo) 25 | StyleComment = lipgloss.NewStyle().Foreground(ColorGrey) 26 | 27 | StyleTableFunc = func() table.Styles { 28 | s := table.DefaultStyles() 29 | s.Header = s.Header. 30 | BorderStyle(lipgloss.NormalBorder()). 31 | BorderForeground(lipgloss.Color("240")). 32 | BorderBottom(true). 33 | Bold(false) 34 | s.Selected = s.Selected. 35 | Foreground(lipgloss.Color("229")). 36 | Background(lipgloss.Color("57")). 37 | Bold(false) 38 | return s 39 | } 40 | StyleTableBase = lipgloss.NewStyle(). 41 | BorderStyle(lipgloss.NormalBorder()). 42 | BorderForeground(lipgloss.Color("240")) 43 | 44 | StyleActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•") 45 | StyleInactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•") 46 | 47 | StyleQuitMsg = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}) 48 | StyleErrorMsg = lipgloss.NewStyle().Foreground(ColorRed) 49 | ) 50 | -------------------------------------------------------------------------------- /internal/ui/tick.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | type tickMsg time.Time 10 | 11 | func tickCmd() tea.Cmd { 12 | return tea.Tick(time.Second*1, func(t time.Time) tea.Msg { 13 | return tickMsg(t) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/magodo/pipeform/internal/clipboard" 10 | "github.com/magodo/pipeform/internal/csv" 11 | "github.com/magodo/pipeform/internal/log" 12 | "github.com/magodo/pipeform/internal/state" 13 | "github.com/muesli/reflow/indent" 14 | 15 | "github.com/charmbracelet/bubbles/help" 16 | "github.com/charmbracelet/bubbles/key" 17 | "github.com/charmbracelet/bubbles/paginator" 18 | "github.com/charmbracelet/bubbles/progress" 19 | "github.com/charmbracelet/bubbles/spinner" 20 | "github.com/charmbracelet/bubbles/table" 21 | tea "github.com/charmbracelet/bubbletea" 22 | "github.com/magodo/pipeform/internal/reader" 23 | "github.com/magodo/pipeform/internal/terraform/views" 24 | "github.com/magodo/pipeform/internal/terraform/views/json" 25 | ) 26 | 27 | const ( 28 | padding = 2 29 | indentLevel = 2 30 | ) 31 | 32 | type UIModel struct { 33 | startTime time.Time 34 | logger *log.Logger 35 | reader reader.Reader 36 | 37 | // state is the actual state of the process 38 | state ViewState 39 | visitedStates []ViewState 40 | // viewState is the state of the current view. It is nil until EOF received. 41 | // After which, users can select different view. 42 | viewState *ViewState 43 | 44 | lastLog string 45 | userOperationInfo string 46 | 47 | isEOF bool 48 | 49 | diags Diags 50 | 51 | refreshInfos state.ResourceOperationInfos 52 | planInfos state.PlanInfos 53 | applyInfos state.ResourceOperationInfos 54 | outputInfos state.OutputInfos 55 | 56 | versionMsg *string 57 | 58 | // These are read from the ChangeSummaryMsg 59 | operation json.Operation 60 | totalCnt int 61 | 62 | doneCnt int 63 | 64 | keymap KeyMap 65 | 66 | help help.Model 67 | spinner spinner.Model 68 | table table.Model 69 | progress progress.Model 70 | paginator paginator.Model 71 | 72 | tableSize Size 73 | 74 | cp clipboard.Clipboard 75 | 76 | followed bool 77 | } 78 | 79 | func NewRuntimeModel(logger *log.Logger, reader reader.Reader, startTime time.Time) UIModel { 80 | t := table.New(table.WithFocused(true)) 81 | t.SetStyles(StyleTableFunc()) 82 | 83 | cp := clipboard.NewClipboard() 84 | 85 | keymap := NewKeyMap(cp.Enabled()) 86 | 87 | p := paginator.New() 88 | p.KeyMap = keymap.PaginatorMap 89 | p.Type = paginator.Dots 90 | p.ActiveDot = StyleActiveDot 91 | p.InactiveDot = StyleInactiveDot 92 | 93 | model := UIModel{ 94 | startTime: startTime, 95 | logger: logger, 96 | reader: reader, 97 | state: ViewStateIdle, 98 | visitedStates: []ViewState{ViewStateIdle}, 99 | keymap: keymap, 100 | help: help.New(), 101 | spinner: spinner.New(), 102 | table: t, 103 | progress: progress.New(), 104 | paginator: p, 105 | cp: cp, 106 | } 107 | 108 | return model 109 | } 110 | 111 | func (m UIModel) Diags() Diags { 112 | return m.diags 113 | } 114 | 115 | func (m UIModel) IsEOF() bool { 116 | return m.isEOF 117 | } 118 | 119 | func (m UIModel) nextMessage() tea.Msg { 120 | msg, err := m.reader.Next() 121 | if err != nil { 122 | if err == io.EOF { 123 | return receiverEOFMsg{} 124 | } 125 | return receiverErrorMsg{err: err} 126 | } 127 | return receiverMsg{msg: msg} 128 | } 129 | 130 | func (m UIModel) Init() tea.Cmd { 131 | return tea.Batch(m.nextMessage, m.spinner.Tick, tickCmd()) 132 | } 133 | 134 | func (m UIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 135 | m.logger.Trace("Message received", "type", fmt.Sprintf("%T", msg)) 136 | switch msg := msg.(type) { 137 | case tea.KeyMsg: 138 | m.userOperationInfo = "" 139 | switch { 140 | case key.Matches(msg, m.keymap.Quit): 141 | m.logger.Warn("Interrupt key received, quit the program") 142 | return m, tea.Quit 143 | case key.Matches(msg, m.keymap.Help): 144 | m.help.ShowAll = !m.help.ShowAll 145 | return m, nil 146 | case key.Matches(msg, m.keymap.Follow): 147 | m.followed = !m.followed 148 | return m, nil 149 | case key.Matches(msg, m.keymap.Copy): 150 | m.copyTableRow() 151 | return m, nil 152 | case key.Matches(msg, m.keymap.PaginatorMap.PrevPage): 153 | if m.viewState == nil { 154 | return m, nil 155 | } 156 | m.paginator.PrevPage() 157 | idx, _ := m.paginator.GetSliceBounds(len(m.visitedStates)) 158 | m.viewState = &m.visitedStates[idx] 159 | m.resetTableNonEmpty() 160 | return m, nil 161 | case key.Matches(msg, m.keymap.PaginatorMap.NextPage): 162 | if m.viewState == nil { 163 | return m, nil 164 | } 165 | m.paginator.NextPage() 166 | idx, _ := m.paginator.GetSliceBounds(len(m.visitedStates)) 167 | m.viewState = &m.visitedStates[idx] 168 | m.resetTableNonEmpty() 169 | return m, nil 170 | default: 171 | table, cmd := m.table.Update(msg) 172 | m.table = table 173 | return m, cmd 174 | } 175 | case tea.WindowSizeMsg: 176 | progressWidth := msg.Width - padding*2 177 | m.progress.Width = progressWidth 178 | 179 | m.tableSize = Size{ 180 | Width: msg.Width - padding*2 - 10, 181 | Height: msg.Height - padding*2 - 10, 182 | } 183 | m.setTableOutlook() 184 | m.setTableRows() 185 | 186 | return m, nil 187 | 188 | // FrameMsg is sent when the progress bar wants to animate itself 189 | case progress.FrameMsg: 190 | progressModel, cmd := m.progress.Update(msg) 191 | m.progress = progressModel.(progress.Model) 192 | return m, cmd 193 | 194 | case spinner.TickMsg: 195 | var cmd tea.Cmd 196 | m.spinner, cmd = m.spinner.Update(msg) 197 | 198 | return m, cmd 199 | 200 | case tickMsg: 201 | m.setTableRows() 202 | return m, tickCmd() 203 | 204 | // Log the receiver error message 205 | case receiverErrorMsg: 206 | m.logger.Error("Receiver error", "error", msg.Error()) 207 | return m, m.nextMessage 208 | 209 | case receiverEOFMsg: 210 | m.logger.Info("Receiver reaches EOF") 211 | m.isEOF = true 212 | m.lastLog = fmt.Sprintf("Time spent: %s", time.Now().Sub(m.startTime).Truncate(time.Second)) 213 | 214 | // Enable paginator 215 | m.paginator.SetTotalPages(len(m.visitedStates)) 216 | for i := 0; i < len(m.visitedStates); i++ { 217 | m.paginator.NextPage() 218 | } 219 | m.viewState = &m.state 220 | m.keymap.EnablePaginator() 221 | 222 | return m, nil 223 | 224 | case receiverMsg: 225 | m.logger.Debug("Message receiverMsg received", "type", fmt.Sprintf("%T", msg.msg)) 226 | 227 | cmds := []tea.Cmd{m.nextMessage} 228 | 229 | m.lastLog = msg.msg.BaseMessage().Message 230 | 231 | switch msg := msg.msg.(type) { 232 | case views.VersionMsg: 233 | m.versionMsg = &msg.BaseMsg.Message 234 | 235 | case views.LogMsg: 236 | // There's no much useful information for now. 237 | case views.DiagnosticsMsg: 238 | switch strings.ToLower(msg.Level) { 239 | case "warn", "error": 240 | m.diags = append(m.diags, *msg.Diagnostic) 241 | } 242 | 243 | case views.ResourceDriftMsg: 244 | // There's no much useful information for now. 245 | 246 | case views.PlannedChangeMsg: 247 | m.planInfos = append(m.planInfos, &state.PlanInfo{ 248 | Resource: msg.Change.Resource, 249 | Action: msg.Change.Action, 250 | PrevResource: msg.Change.PreviousResource, 251 | Reason: msg.Change.Reason, 252 | }) 253 | 254 | // Normally, we don't need to handle the PlannedChangeMsg here, as the ChangeSummaryMsg has all these information. 255 | // The exception is that when apply with a plan file, there is no ChangeSummaryMsg sent from Terraform at this moment. 256 | // (see: https://github.com/magodo/pipeform/issues/1) 257 | // The counting here is a fallback logic to cover the case above. Otherwise, it will just be overwritten by ChangeSummaryMsg. 258 | // 259 | // TODO: Once https://github.com/hashicorp/terraform/pull/36245 merged, remove this part. 260 | // 261 | // Referencing the logic of terraform: internal/command/views/operation.go 262 | // But we also count the "import" 263 | switch msg.Change.Action { 264 | case json.ActionCreate: 265 | m.totalCnt++ 266 | case json.ActionDelete: 267 | m.totalCnt++ 268 | case json.ActionUpdate: 269 | m.totalCnt++ 270 | case json.ActionReplace: 271 | m.totalCnt += 2 272 | case json.ActionImport: 273 | m.totalCnt++ 274 | } 275 | 276 | case views.ChangeSummaryMsg: 277 | changes := msg.Changes 278 | m.logger.Debug("Change summary", "add", changes.Add, "change", changes.Change, "import", changes.Import, "remove", changes.Remove) 279 | m.totalCnt = changes.Add + changes.Change + changes.Import + changes.Remove 280 | m.operation = changes.Operation 281 | 282 | // Specifically, if the total count is 0, we update the progress bar directly as it is 100% anyway. 283 | if m.totalCnt == 0 { 284 | cmds = append(cmds, m.progress.SetPercent(1)) 285 | } 286 | 287 | case views.OutputMsg: 288 | for name, o := range msg.Outputs { 289 | if o.Action != "" { 290 | continue 291 | } 292 | m.outputInfos = append(m.outputInfos, &state.OutputInfo{ 293 | Name: name, 294 | Sensitive: o.Sensitive, 295 | Type: o.Type, 296 | ValueStr: o.Value, 297 | Action: o.Action, 298 | }) 299 | } 300 | 301 | case views.HookMsg: 302 | m.logger.Debug("Hook message", "type", fmt.Sprintf("%T", msg.Hook)) 303 | switch hook := msg.Hook.(type) { 304 | case json.RefreshStart: 305 | res := &state.ResourceOperationInfo{ 306 | Idx: len(m.refreshInfos) + 1, 307 | RawResourceAddr: hook.Resource, 308 | Loc: state.ResourceOperationInfoLocator{ 309 | Module: hook.Resource.Module, 310 | ResourceAddr: hook.Resource.Addr, 311 | Action: "refresh", 312 | }, 313 | Status: state.ResourceOperationStatusStart, 314 | StartTime: msg.TimeStamp, 315 | } 316 | m.refreshInfos = append(m.refreshInfos, res) 317 | 318 | case json.RefreshComplete: 319 | loc := state.ResourceOperationInfoLocator{ 320 | Module: hook.Resource.Module, 321 | ResourceAddr: hook.Resource.Addr, 322 | Action: "refresh", 323 | } 324 | status := state.ResourceOperationStatusComplete 325 | update := state.ResourceOperationInfoUpdate{ 326 | Status: &status, 327 | Endtime: &msg.TimeStamp, 328 | } 329 | if m.refreshInfos.Update(loc, update) == nil { 330 | m.logger.Error("RefreshComplete hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", "refresh") 331 | break 332 | } 333 | 334 | case json.OperationStart: 335 | res := &state.ResourceOperationInfo{ 336 | Idx: len(m.applyInfos) + 1, 337 | RawResourceAddr: hook.Resource, 338 | Loc: state.ResourceOperationInfoLocator{ 339 | Module: hook.Resource.Module, 340 | ResourceAddr: hook.Resource.Addr, 341 | Action: string(hook.Action), 342 | }, 343 | Status: state.ResourceOperationStatusStart, 344 | StartTime: msg.TimeStamp, 345 | } 346 | m.applyInfos = append(m.applyInfos, res) 347 | 348 | case json.OperationProgress: 349 | // Ignore 350 | 351 | case json.OperationComplete: 352 | loc := state.ResourceOperationInfoLocator{ 353 | Module: hook.Resource.Module, 354 | ResourceAddr: hook.Resource.Addr, 355 | Action: string(hook.Action), 356 | } 357 | status := state.ResourceOperationStatusComplete 358 | update := state.ResourceOperationInfoUpdate{ 359 | Status: &status, 360 | Endtime: &msg.TimeStamp, 361 | } 362 | if m.applyInfos.Update(loc, update) == nil { 363 | m.logger.Error("OperationComplete hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", hook.Action) 364 | break 365 | } 366 | 367 | m.doneCnt += 1 368 | percentage := float64(m.doneCnt) / float64(m.totalCnt) 369 | cmds = append(cmds, m.progress.SetPercent(percentage)) 370 | 371 | case json.OperationErrored: 372 | loc := state.ResourceOperationInfoLocator{ 373 | Module: hook.Resource.Module, 374 | ResourceAddr: hook.Resource.Addr, 375 | Action: string(hook.Action), 376 | } 377 | status := state.ResourceOperationStatusErrored 378 | update := state.ResourceOperationInfoUpdate{ 379 | Status: &status, 380 | Endtime: &msg.TimeStamp, 381 | } 382 | if m.applyInfos.Update(loc, update) == nil { 383 | m.logger.Error("OperationErrored hook can't find the resource info", "module", hook.Resource.Module, "addr", hook.Resource.Addr, "action", hook.Action) 384 | break 385 | } 386 | 387 | m.doneCnt += 1 388 | percentage := float64(m.doneCnt) / float64(m.totalCnt) 389 | cmds = append(cmds, m.progress.SetPercent(percentage)) 390 | 391 | case json.ProvisionStart: 392 | case json.ProvisionProgress: 393 | case json.ProvisionComplete: 394 | case json.ProvisionErrored: 395 | default: 396 | } 397 | default: 398 | panic(fmt.Sprintf("unknown message type: %T", msg)) 399 | } 400 | 401 | // Update viewState 402 | var change bool 403 | oldState := m.state 404 | m.state, change = m.state.NextState(msg.msg) 405 | if change { 406 | m.logger.Info("View State change", "old", oldState.String(), "new", m.state.String()) 407 | m.visitedStates = append(m.visitedStates, m.state) 408 | m.resetTableEmpty() 409 | } else { 410 | m.setTableRows() 411 | } 412 | 413 | return m, tea.Batch(cmds...) 414 | 415 | default: 416 | return m, nil 417 | } 418 | } 419 | 420 | func (m *UIModel) resetTableEmpty() { 421 | // Clean up the rows before changing table columns, mainly to avoid 422 | // existing rows have more columns than the new columns, i.e. from 423 | // "apply" (6) to "summary" (5). 424 | m.table.SetRows(nil) 425 | m.table.SetCursor(0) 426 | m.setTableOutlook() 427 | } 428 | 429 | func (m *UIModel) resetTableNonEmpty() { 430 | m.resetTableEmpty() 431 | m.setTableRows() 432 | } 433 | 434 | func (m *UIModel) setTableOutlook() { 435 | m.table.SetWidth(m.tableSize.Width) 436 | m.table.SetHeight(m.tableSize.Height) 437 | 438 | switch m.getViewState() { 439 | case ViewStateRefresh: 440 | m.table.SetColumns(m.refreshInfos.ToColumns(m.tableSize.Width)) 441 | case ViewStatePlan: 442 | m.table.SetColumns(m.planInfos.ToColumns(m.tableSize.Width)) 443 | case ViewStateApply: 444 | m.table.SetColumns(m.applyInfos.ToColumns(m.tableSize.Width)) 445 | case ViewStateSummary: 446 | m.table.SetColumns(m.outputInfos.ToColumns(m.tableSize.Width)) 447 | } 448 | } 449 | 450 | // setTableRows on a one second pace. 451 | func (m *UIModel) setTableRows() { 452 | switch m.getViewState() { 453 | case ViewStateRefresh: 454 | m.table.SetRows(m.refreshInfos.ToRows(0)) 455 | case ViewStatePlan: 456 | m.table.SetRows(m.planInfos.ToRows()) 457 | case ViewStateApply: 458 | m.table.SetRows(m.applyInfos.ToRows(m.totalCnt)) 459 | case ViewStateSummary: 460 | m.table.SetRows(m.outputInfos.ToRows()) 461 | } 462 | 463 | if m.followed { 464 | m.table.GotoBottom() 465 | } 466 | } 467 | 468 | func (m *UIModel) copyTableRow() { 469 | if !m.cp.Enabled() { 470 | return 471 | } 472 | 473 | switch m.getViewState() { 474 | case ViewStateRefresh: 475 | if row := m.table.SelectedRow(); len(row) > 4 { 476 | m.cp.Write([]byte(row[4])) 477 | } 478 | case ViewStateApply: 479 | if row := m.table.SelectedRow(); len(row) > 4 { 480 | m.cp.Write([]byte(row[4])) 481 | } 482 | case ViewStateSummary: 483 | if row := m.table.SelectedRow(); len(row) > 4 { 484 | m.cp.Write([]byte(row[4])) 485 | } 486 | } 487 | 488 | m.userOperationInfo = "Copied!" 489 | } 490 | 491 | func (m UIModel) ToCsv() []byte { 492 | return csv.ToCsv(csv.Input{ 493 | RefreshInfos: m.refreshInfos, 494 | ApplyInfos: m.applyInfos, 495 | }) 496 | } 497 | 498 | func (m *UIModel) getViewState() ViewState { 499 | if m.viewState != nil { 500 | return *m.viewState 501 | } 502 | return m.state 503 | } 504 | 505 | func (m UIModel) logoView() string { 506 | msg := "pipeform" 507 | if m.versionMsg != nil { 508 | msg += fmt.Sprintf(" (%s)", *m.versionMsg) 509 | } 510 | return StyleTitle.Render(" " + msg + " ") 511 | } 512 | 513 | func (m UIModel) stateView() string { 514 | prefix := m.spinner.View() 515 | if m.isEOF { 516 | if m.diags.HasError() { 517 | prefix = "❌" 518 | } else { 519 | prefix = "✅" 520 | } 521 | } 522 | 523 | s := prefix + " " + StyleSubtitle.Render(m.getViewState().String()) 524 | 525 | if m.followed { 526 | s += " [following]" 527 | } 528 | 529 | if m.lastLog != "" { 530 | s += " " + StyleComment.Render(m.lastLog) 531 | } 532 | 533 | return s 534 | } 535 | 536 | func (m UIModel) View() string { 537 | s := "\n" + m.logoView() 538 | 539 | s += "\n\n" + m.stateView() 540 | 541 | if m.getViewState() != ViewStateIdle { 542 | s += "\n\n" + StyleTableBase.Render(m.table.View()) 543 | } 544 | 545 | var progressBar string 546 | if m.getViewState() == ViewStateApply { 547 | progressBar = m.progress.View() 548 | } 549 | s += "\n\n" + progressBar 550 | 551 | if m.viewState != nil { 552 | s += "\n" + m.paginator.View() 553 | } 554 | 555 | var bottomLine string 556 | if m.userOperationInfo != "" { 557 | bottomLine = StyleComment.Render(m.userOperationInfo) 558 | } else { 559 | bottomLine = m.help.View(m.keymap) 560 | } 561 | s += "\n\n" + bottomLine 562 | 563 | return indent.String(s, indentLevel) 564 | } 565 | -------------------------------------------------------------------------------- /internal/ui/view_state.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/magodo/pipeform/internal/terraform/views" 5 | "github.com/magodo/pipeform/internal/terraform/views/json" 6 | ) 7 | 8 | type ViewState int 9 | 10 | const ( 11 | ViewStateUnknown ViewState = iota 12 | ViewStateIdle 13 | ViewStateRefresh 14 | ViewStatePlan 15 | ViewStateApply 16 | ViewStateSummary 17 | ) 18 | 19 | func (s ViewState) String() string { 20 | switch s { 21 | case ViewStateIdle: 22 | return "IDLE" 23 | case ViewStateRefresh: 24 | return "REFRESH" 25 | case ViewStatePlan: 26 | return "PLAN" 27 | case ViewStateApply: 28 | return "APPLY" 29 | case ViewStateSummary: 30 | return "SUMMARY" 31 | default: 32 | return "UNKNOWN" 33 | } 34 | } 35 | 36 | func (s ViewState) NextState(msg views.Message) (ViewState, bool) { 37 | switch s { 38 | case ViewStateIdle: 39 | switch msg.BaseMessage().Type { 40 | case json.MessageRefreshStart: 41 | return ViewStateRefresh, true 42 | case json.MessagePlannedChange: 43 | return ViewStatePlan, true 44 | case json.MessageApplyStart: 45 | return ViewStateApply, true 46 | case json.MessageChangeSummary: 47 | // There are two change summary messages, one after plan, one after apply. 48 | // We only handle the one after apply, as the one after plan is less interesting to show. 49 | if msg.(views.ChangeSummaryMsg).Changes.Operation == json.OperationApplied { 50 | return ViewStateSummary, true 51 | } 52 | } 53 | 54 | case ViewStateRefresh: 55 | switch msg.BaseMessage().Type { 56 | case json.MessagePlannedChange: 57 | return ViewStatePlan, true 58 | case json.MessageChangeSummary: 59 | if msg.(views.ChangeSummaryMsg).Changes.Operation == json.OperationApplied { 60 | return ViewStateSummary, true 61 | } 62 | } 63 | 64 | case ViewStatePlan: 65 | switch msg.BaseMessage().Type { 66 | case json.MessageApplyStart: 67 | return ViewStateApply, true 68 | case json.MessageChangeSummary: 69 | if msg.(views.ChangeSummaryMsg).Changes.Operation == json.OperationApplied { 70 | return ViewStateSummary, true 71 | } 72 | } 73 | 74 | case ViewStateApply: 75 | switch msg.BaseMessage().Type { 76 | case json.MessageChangeSummary: 77 | return ViewStateSummary, true 78 | } 79 | } 80 | 81 | return s, false 82 | } 83 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "slices" 11 | "strings" 12 | "time" 13 | 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/x/term" 16 | "github.com/magodo/pipeform/internal/log" 17 | "github.com/magodo/pipeform/internal/plainui" 18 | "github.com/magodo/pipeform/internal/reader" 19 | "github.com/magodo/pipeform/internal/ui" 20 | "github.com/urfave/cli/v3" 21 | ) 22 | 23 | type FlagSet struct { 24 | LogLevel string 25 | LogPath string 26 | TeePath string 27 | TimeCsv string 28 | PlainUI bool 29 | } 30 | 31 | var fset FlagSet 32 | 33 | func main() { 34 | cmd := &cli.Command{ 35 | Name: "pipeform", 36 | Usage: "Terraform UI by running like: `terraform ... -json | pipeform`", 37 | Flags: []cli.Flag{ 38 | &cli.StringFlag{ 39 | Name: "log-level", 40 | Usage: "The log level", 41 | Sources: cli.EnvVars("PF_LOG"), 42 | Value: string(log.LevelDebug), 43 | Destination: &fset.LogLevel, 44 | Validator: func(input string) error { 45 | if !slices.Contains(log.PossibleLevels(), log.Level(strings.ToLower(input))) { 46 | return fmt.Errorf("invalid log level: %s", input) 47 | } 48 | return nil 49 | }, 50 | }, 51 | &cli.StringFlag{ 52 | Name: "log-path", 53 | Usage: "The log path", 54 | Sources: cli.EnvVars("PF_LOG_PATH"), 55 | Destination: &fset.LogPath, 56 | }, 57 | &cli.StringFlag{ 58 | Name: "tee", 59 | Usage: `Equivalent to "terraform ... -json | tee | pipeform"`, 60 | Sources: cli.EnvVars("PF_TEE"), 61 | Destination: &fset.TeePath, 62 | }, 63 | &cli.StringFlag{ 64 | Name: "time-csv", 65 | Usage: "The csv file that records the timing of each operation of each resource", 66 | Sources: cli.EnvVars("PF_TIME_CSV"), 67 | Destination: &fset.TimeCsv, 68 | }, 69 | &cli.BoolFlag{ 70 | Name: "plain-ui", 71 | Usage: "Simply print each log line by line, that expect to use in systems only support plain output", 72 | Sources: cli.EnvVars("PF_PLAIN_UI"), 73 | Destination: &fset.PlainUI, 74 | }, 75 | }, 76 | Before: func(ctx context.Context, c *cli.Command) (context.Context, error) { 77 | // If this program starts in standalone, its stdin is the same as the terminal. 78 | // bubbletea will change the terminal into raw mode and read ansi events from it, 79 | // which conflicts with the stdin reading for terraform JSON streams. 80 | // In this case, user's input (e.g. ctrl-c keypress) will most likely be accidently read by 81 | // the stream reader, instead of the ansi read loop (by bubbletea), causing a lost of event. 82 | if term.IsTerminal(os.Stdin.Fd()) { 83 | return ctx, errors.New("Must be followed by a pipe") 84 | } 85 | return ctx, nil 86 | }, 87 | Action: func(context.Context, *cli.Command) error { 88 | startTime := time.Now() 89 | 90 | logger, err := log.NewLogger(log.Level(fset.LogLevel), fset.LogPath) 91 | if err != nil { 92 | fmt.Println(err) 93 | os.Exit(1) 94 | } 95 | defer logger.Close() 96 | 97 | teeWriter := io.Discard 98 | if path := fset.TeePath; path != "" { 99 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) 100 | if err != nil { 101 | return fmt.Errorf("open for tee: %v", err) 102 | } 103 | teeWriter = f 104 | defer f.Close() 105 | } 106 | 107 | reader := reader.NewReader(os.Stdin, teeWriter) 108 | 109 | type Model interface { 110 | ToCsv() []byte 111 | IsEOF() bool 112 | } 113 | 114 | var model Model 115 | 116 | if fset.PlainUI { 117 | m := plainui.NewRuntimeModel(logger, reader, os.Stdout, startTime) 118 | if err := m.Run(); err != nil { 119 | return fmt.Errorf("Error running program: %v\n", err) 120 | } 121 | 122 | model = m 123 | } else { 124 | m := ui.NewRuntimeModel(logger, reader, startTime) 125 | tm, err := tea.NewProgram(m, tea.WithInputTTY(), tea.WithAltScreen()).Run() 126 | if err != nil { 127 | return fmt.Errorf("Error running program: %v\n", err) 128 | } 129 | 130 | m = tm.(ui.UIModel) 131 | 132 | // Print diags 133 | for _, diag := range m.Diags() { 134 | if b, err := json.MarshalIndent(diag, "", " "); err == nil { 135 | fmt.Fprintln(os.Stderr, string(b)) 136 | } 137 | } 138 | 139 | model = m 140 | } 141 | 142 | if path := fset.TimeCsv; path != "" { 143 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 144 | if err != nil { 145 | return fmt.Errorf("open time csv file: %v", err) 146 | } 147 | defer f.Close() 148 | 149 | if _, err := f.Write(model.ToCsv()); err != nil { 150 | fmt.Fprintf(os.Stderr, "writing time csv file: %v", err) 151 | } 152 | } 153 | 154 | if !model.IsEOF() { 155 | fmt.Fprintln(os.Stderr, "Interrupted!") 156 | os.Exit(1) 157 | } 158 | 159 | return nil 160 | }, 161 | } 162 | 163 | if err := cmd.Run(context.Background(), os.Args); err != nil { 164 | fmt.Fprintln(os.Stderr, err) 165 | os.Exit(1) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tool/streamgen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "time" 9 | ) 10 | 11 | func main() { 12 | c := make(chan os.Signal, 1) 13 | signal.Notify(c, os.Interrupt) 14 | 15 | go func() { 16 | s := <-c 17 | log.Fatalf("streamgen: captured signal %s\n", s) 18 | }() 19 | 20 | inputs := []string{ 21 | `{"@level":"info","@message":"Terraform 0.15.4","@module":"terraform.ui","@timestamp":"%s","terraform":"0.15.4","type":"version","ui":"0.1.0"}`, 22 | `{"@level":"info","@message":"random_pet.dog: Plan to create","@module":"terraform.ui","@timestamp":"%s","change":{"resource":{"addr":"random_pet.dog","module":"","resource":"random_pet.dog","implied_provider":"random","resource_type":"random_pet","resource_name":"dog","resource_key":null},"action":"create"},"type":"planned_change"}`, 23 | `{"@level":"info","@message":"random_pet.cat: Plan to create","@module":"terraform.ui","@timestamp":"%s","change":{"resource":{"addr":"random_pet.cat","module":"","resource":"random_pet.cat","implied_provider":"random","resource_type":"random_pet","resource_name":"cat","resource_key":null},"action":"create"},"type":"planned_change"}`, 24 | `{"@level":"info","@message":"random_pet.mouse: Plan to create","@module":"terraform.ui","@timestamp":"%s","change":{"resource":{"addr":"random_pet.mouse","module":"","resource":"random_pet.mouse","implied_provider":"random","resource_type":"random_pet","resource_name":"mouse","resource_key":null},"action":"create"},"type":"planned_change"}`, 25 | `{"@level":"info","@message":"Plan: 3 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"%s","changes":{"add":3,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}`, 26 | `{"@level":"info","@message":"random_pet.dog: Creating...","@module":"terraform.ui","@timestamp":"%s","hook":{"resource":{"addr":"random_pet.dog","module":"","resource":"random_pet.dog","implied_provider":"random","resource_type":"random_pet","resource_name":"dog","resource_key":null},"action":"create"},"type":"apply_start"}`, 27 | `{"@level":"info","@message":"random_pet.cat: Creating...","@module":"terraform.ui","@timestamp":"%s","hook":{"resource":{"addr":"random_pet.cat","module":"","resource":"random_pet.cat","implied_provider":"random","resource_type":"random_pet","resource_name":"cat","resource_key":null},"action":"create"},"type":"apply_start"}`, 28 | `{"@level":"info","@message":"random_pet.mouse: Creating...","@module":"terraform.ui","@timestamp":"%s","hook":{"resource":{"addr":"random_pet.mouse","module":"","resource":"random_pet.mouse","implied_provider":"random","resource_type":"random_pet","resource_name":"mouse","resource_key":null},"action":"create"},"type":"apply_start"}`, 29 | `{"@level":"info","@message":"random_pet.dog: Creation complete after 0s [id=smart-lizard]","@module":"terraform.ui","@timestamp":"%s","hook":{"resource":{"addr":"random_pet.dog","module":"","resource":"random_pet.dog","implied_provider":"random","resource_type":"random_pet","resource_name":"dog","resource_key":null},"action":"create","id_key":"id","id_value":"smart-lizard","elapsed_seconds":0},"type":"apply_complete"}`, 30 | `{"@level":"info","@message":"random_pet.cat: Creation complete after 0s [id=smart-lizard]","@module":"terraform.ui","@timestamp":"%s","hook":{"resource":{"addr":"random_pet.cat","module":"","resource":"random_pet.cat","implied_provider":"random","resource_type":"random_pet","resource_name":"cat","resource_key":null},"action":"create","id_key":"id","id_value":"smart-lizard","elapsed_seconds":0},"type":"apply_complete"}`, 31 | `{"@level":"info","@message":"random_pet.mouse: Creation complete after 0s [id=smart-lizard]","@module":"terraform.ui","@timestamp":"%s","hook":{"resource":{"addr":"random_pet.mouse","module":"","resource":"random_pet.mouse","implied_provider":"random","resource_type":"random_pet","resource_name":"mouse","resource_key":null},"action":"create","id_key":"id","id_value":"smart-lizard","elapsed_seconds":0},"type":"apply_complete"}`, 32 | `{"@level":"info","@message":"Apply complete! Resources: 3 added, 0 changed, 0 destroyed.","@module":"terraform.ui","@timestamp":"%s","changes":{"add":3,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"}`, 33 | `{"@level":"info","@message":"Outputs: 1","@module":"terraform.ui","@timestamp":"%s","outputs":{"pets":{"sensitive":false,"type":"string","value":"smart-lizard"}},"type":"outputs"}`, 34 | } 35 | 36 | layout := "2006-01-02T15:04:05.999999-07:00" 37 | for i, input := range inputs { 38 | input = fmt.Sprintf(input, time.Now().Format(layout)) 39 | fmt.Println(input) 40 | if i < 4 { 41 | time.Sleep(time.Millisecond * 20) 42 | } else { 43 | time.Sleep(time.Second * 1) 44 | } 45 | } 46 | } 47 | --------------------------------------------------------------------------------