├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── automerge.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── __tests__ ├── app.test.ts ├── machine.test.ts ├── network.test.ts ├── organization.test.ts ├── regions.test.ts ├── secret.test.ts └── volume.test.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── client.ts ├── lib │ ├── app.ts │ ├── machine.ts │ ├── network.ts │ ├── organization.ts │ ├── regions.ts │ ├── secret.ts │ ├── types.ts │ └── volume.ts └── main.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "i18n-text/no-en": "off", 12 | "eslint-comments/no-use": "off", 13 | "import/no-namespace": "off", 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": "error", 16 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 17 | "@typescript-eslint/no-require-imports": "error", 18 | "@typescript-eslint/array-type": "error", 19 | "@typescript-eslint/await-thenable": "error", 20 | "@typescript-eslint/ban-ts-comment": "error", 21 | "camelcase": "off", 22 | "@typescript-eslint/consistent-type-assertions": "error", 23 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 24 | "@typescript-eslint/func-call-spacing": ["error", "never"], 25 | "@typescript-eslint/no-array-constructor": "error", 26 | "@typescript-eslint/no-empty-interface": "error", 27 | "@typescript-eslint/no-explicit-any": "warn", 28 | "@typescript-eslint/no-extraneous-class": "error", 29 | "@typescript-eslint/no-for-in-array": "error", 30 | "@typescript-eslint/no-inferrable-types": "error", 31 | "@typescript-eslint/no-misused-new": "error", 32 | "@typescript-eslint/no-namespace": "error", 33 | "@typescript-eslint/no-non-null-assertion": "warn", 34 | "@typescript-eslint/no-unnecessary-qualifier": "error", 35 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 36 | "@typescript-eslint/no-useless-constructor": "error", 37 | "@typescript-eslint/no-var-requires": "error", 38 | "no-shadow": "off", 39 | "@typescript-eslint/no-shadow": "error", 40 | "@typescript-eslint/prefer-for-of": "warn", 41 | "@typescript-eslint/prefer-function-type": "warn", 42 | "@typescript-eslint/prefer-includes": "error", 43 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 44 | "@typescript-eslint/promise-function-async": "error", 45 | "@typescript-eslint/require-array-sort-compare": "error", 46 | "@typescript-eslint/restrict-plus-operands": "error", 47 | "semi": "off", 48 | "@typescript-eslint/semi": ["error", "never"], 49 | "@typescript-eslint/type-annotation-spacing": "error", 50 | "@typescript-eslint/unbound-method": "error" 51 | }, 52 | "env": { 53 | "node": true, 54 | "es6": true, 55 | "jest/globals": true 56 | } 57 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: ${{ github.actor == 'dependabot[bot]' }} 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v1 17 | with: 18 | github-token: '${{ secrets.GITHUB_TOKEN }}' 19 | 20 | - name: Approve a PR 21 | if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' && !startswith(steps.metadata.outputs.new_version, '0.') }} 22 | run: gh pr review --approve "$PR_URL" 23 | env: 24 | PR_URL: ${{ github.event.pull_request.html_url }} 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Enable auto-merge for Dependabot PRs 28 | if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' && !startswith(steps.metadata.outputs.new_version, '0.') }} 29 | run: gh pr merge --auto --squash "$PR_URL" 30 | env: 31 | PR_URL: ${{ github.event.pull_request.html_url }} 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '31 7 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'TypeScript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | source-root: src 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v2 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | semantic-release: 11 | name: Release 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 16 20 | cache: 'npm' 21 | - run: npm ci 22 | - run: npm run build 23 | - uses: cycjimmy/semantic-release-action@v3 24 | with: 25 | branch: main 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: # rebuild any PRs and main branch changes 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: # make sure build/ci work properly 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm run all 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | dist/**/* 101 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 Supabase, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `fly-admin` 2 | 3 | A Typescript client for managing Fly infrastructure. 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm i --save fly-admin 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```ts 14 | import { createClient } from 'fly-admin' 15 | 16 | const fly = createClient('FLY_API_TOKEN') 17 | 18 | async function deployApp() { 19 | const machine = await fly.Machine.createMachine({ 20 | app_name: 'myAppId', 21 | image: 'supabase/postgres', 22 | }) 23 | } 24 | ``` 25 | 26 | ## API 27 | 28 | **Apps** 29 | 30 | - `fly.App.listApps()` 31 | - `fly.App.getApp()` 32 | - `fly.App.createApp()` 33 | - `fly.App.deleteApp()` 34 | 35 | **Machines** 36 | 37 | - `fly.Machine.listMachines()` 38 | - `fly.Machine.getMachine()` 39 | - `fly.Machine.createMachine()` 40 | - `fly.Machine.updateMachine()` 41 | - `fly.Machine.startMachine()` 42 | - `fly.Machine.stopMachine()` 43 | - `fly.Machine.deleteMachine()` 44 | - `fly.Machine.restartMachine()` 45 | - `fly.Machine.signalMachine()` 46 | - `fly.Machine.waitMachine()` 47 | - `fly.Machine.cordonMachine()` 48 | - `fly.Machine.uncordonMachine()` 49 | - `fly.Machine.listEvents()` 50 | - `fly.Machine.listVersions()` 51 | - `fly.Machine.listProcesses()` 52 | - `fly.Machine.getLease()` 53 | - `fly.Machine.acquireLease()` 54 | 55 | **Networks** 56 | 57 | - `fly.Network.allocateIpAddress()` 58 | - `fly.Network.releaseIpAddress()` 59 | 60 | **Organizations** 61 | 62 | - `fly.Organization.getOrganization()` 63 | 64 | **Secrets** 65 | 66 | - `fly.Secret.setSecrets()` 67 | - `fly.Secret.unsetSecrets()` 68 | 69 | **Volumes** 70 | 71 | - `fly.Volume.listVolumes()` 72 | - `fly.Volume.getVolume()` 73 | - `fly.Volume.createVolume()` 74 | - `fly.Volume.deleteVolume()` 75 | - `fly.Volume.extendVolume()` 76 | - `fly.Volume.listSnapshots()` 77 | 78 | **TODO** 79 | 80 | - [ ] `fly.Machine.execMachine()` 81 | - [ ] `fly.Machine.releaseLease()` 82 | - [ ] `fly.Machine.getMetadata()` 83 | - [ ] `fly.Machine.updateMetadata()` 84 | - [ ] `fly.Machine.deleteMetadata()` 85 | 86 | ## License 87 | 88 | MIT 89 | -------------------------------------------------------------------------------- /__tests__/app.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it } from '@jest/globals' 3 | import { FLY_API_HOSTNAME } from '../src/client' 4 | import { createClient } from '../src/main' 5 | import { AppResponse, AppStatus } from '../src/lib/app' 6 | 7 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 8 | 9 | describe('app', () => { 10 | const app: AppResponse = { 11 | name: 'fly-app', 12 | status: AppStatus.deployed, 13 | organization: { 14 | name: 'fly-org', 15 | slug: 'personal', 16 | }, 17 | ipAddresses: [ 18 | { 19 | type: 'v4', 20 | address: '1.1.1.1' 21 | }, 22 | { 23 | type: 'v6', 24 | address: '2001:db8::1' 25 | } 26 | ] 27 | } 28 | 29 | it('lists apps', async () => { 30 | const org_slug = app.organization.slug 31 | nock(FLY_API_HOSTNAME) 32 | .get('/v1/apps') 33 | .query({ org_slug }) 34 | .reply(200, { 35 | total_apps: 1, 36 | apps: [ 37 | { 38 | name: app.name, 39 | machine_count: 1, 40 | network: 'default', 41 | }, 42 | ], 43 | }) 44 | const data = await fly.App.listApps(org_slug) 45 | console.dir(data, { depth: 10 }) 46 | }) 47 | 48 | it('get app', async () => { 49 | const app_name = app.name 50 | nock(FLY_API_HOSTNAME).get(`/v1/apps/${app_name}`).reply(200, app) 51 | const data = await fly.App.getApp(app_name) 52 | console.dir(data, { depth: 10 }) 53 | }) 54 | 55 | it('creates app', async () => { 56 | const body = { 57 | org_slug: app.organization.slug, 58 | app_name: app.name, 59 | } 60 | nock(FLY_API_HOSTNAME).post('/v1/apps', body).reply(201) 61 | const data = await fly.App.createApp(body) 62 | console.dir(data, { depth: 10 }) 63 | }) 64 | 65 | it('deletes app', async () => { 66 | const app_name = app.name 67 | nock(FLY_API_HOSTNAME).delete(`/v1/apps/${app_name}`).reply(202) 68 | const data = await fly.App.deleteApp(app_name) 69 | console.dir(data, { depth: 10 }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /__tests__/machine.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it } from '@jest/globals' 3 | import { 4 | ConnectionHandler, 5 | MachineConfig, 6 | MachineResponse, 7 | MachineState, 8 | } from '../src/lib/machine' 9 | import { FLY_API_HOSTNAME } from '../src/client' 10 | import { createClient } from '../src/main' 11 | import { SignalRequestSignalEnum, StateEnum } from '../src/lib/types' 12 | 13 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 14 | 15 | describe('machine', () => { 16 | const app_name = 'ctwntjgykzxhfncfwrfo' 17 | const machine: MachineResponse = { 18 | id: '17811953c92e18', 19 | name: 'ctwntjgykzxhfncfwrfo', 20 | state: MachineState.Created, 21 | region: 'hkg', 22 | instance_id: '01GSYXD50E7F114CX7SRCT2H41', 23 | private_ip: 'fdaa:1:698b:a7b:a8:33bd:e6da:2', 24 | config: { 25 | env: { PGDATA: '/mnt/postgres/data' }, 26 | init: {}, 27 | image: 'sweatybridge/postgres:all-in-one', 28 | mounts: [ 29 | { 30 | encrypted: true, 31 | path: '/mnt/postgres', 32 | size_gb: 2, 33 | volume: 'vol_g67340kqe5pvydxw', 34 | name: 'ctwntjgykzxhfncfwrfo_pgdata', 35 | }, 36 | ], 37 | services: [ 38 | { 39 | protocol: 'tcp', 40 | internal_port: 8000, 41 | ports: [ 42 | { 43 | port: 443, 44 | handlers: [ConnectionHandler.TLS, ConnectionHandler.HTTP], 45 | }, 46 | { port: 80, handlers: [ConnectionHandler.HTTP] }, 47 | ], 48 | }, 49 | { 50 | protocol: 'tcp', 51 | internal_port: 5432, 52 | ports: [ 53 | { 54 | port: 5432, 55 | handlers: [ConnectionHandler.PG_TLS], 56 | }, 57 | ], 58 | concurrency: { 59 | type: 'connections', 60 | hard_limit: 60, 61 | soft_limit: 60, 62 | }, 63 | }, 64 | ], 65 | size: 'shared-cpu-4x', 66 | restart: {}, 67 | guest: { 68 | cpu_kind: 'shared', 69 | cpus: 4, 70 | memory_mb: 1024, 71 | }, 72 | checks: { 73 | pgrst: { 74 | port: 3000, 75 | type: 'http', 76 | interval: '15s', 77 | timeout: '10s', 78 | method: 'HEAD', 79 | path: '/', 80 | }, 81 | adminapi: { 82 | port: 8085, 83 | type: 'tcp', 84 | interval: '15s', 85 | timeout: '10s', 86 | }, 87 | }, 88 | }, 89 | image_ref: { 90 | registry: 'registry-1.docker.io', 91 | repository: 'sweatybridge/postgres', 92 | tag: 'all-in-one', 93 | digest: 94 | 'sha256:df2014e5d037bf960a1240e300a913a97ef0d4486d22cbd1b7b92a7cbf487a7c', 95 | labels: { 96 | 'org.opencontainers.image.ref.name': 'ubuntu', 97 | 'org.opencontainers.image.version': '20.04', 98 | }, 99 | }, 100 | created_at: '2023-02-23T10:34:20Z', 101 | updated_at: '0001-01-01T00:00:00Z', 102 | events: [ 103 | { 104 | id: '01H28X6YMHE186D9R0BF4CB2ZH', 105 | type: 'launch', 106 | status: 'created', 107 | source: 'user', 108 | timestamp: 1686073735825, 109 | }, 110 | ], 111 | checks: [ 112 | { 113 | name: 'adminapi', 114 | status: 'passing', 115 | output: 'Success', 116 | updated_at: '2023-08-22T23:54:06.176Z', 117 | }, 118 | { 119 | name: 'pgrst', 120 | status: 'warning', 121 | output: 'the machine is created', 122 | updated_at: '2023-02-23T10:34:20.084624847Z', 123 | }, 124 | ], 125 | } 126 | 127 | it('creates machine', async () => { 128 | const config: MachineConfig = { 129 | image: 'sweatybridge/postgres:all-in-one', 130 | size: 'shared-cpu-4x', 131 | env: { 132 | PGDATA: '/mnt/postgres/data', 133 | }, 134 | services: [ 135 | { 136 | ports: [ 137 | { 138 | port: 443, 139 | handlers: [ConnectionHandler.TLS, ConnectionHandler.HTTP], 140 | }, 141 | { 142 | port: 80, 143 | handlers: [ConnectionHandler.HTTP], 144 | }, 145 | ], 146 | protocol: 'tcp', 147 | internal_port: 8000, 148 | }, 149 | { 150 | ports: [ 151 | { 152 | port: 5432, 153 | handlers: [ConnectionHandler.PG_TLS], 154 | }, 155 | ], 156 | protocol: 'tcp', 157 | internal_port: 5432, 158 | }, 159 | ], 160 | mounts: [ 161 | { 162 | volume: 'vol_g67340kqe5pvydxw', 163 | path: '/mnt/postgres', 164 | }, 165 | ], 166 | checks: { 167 | pgrst: { 168 | type: 'http', 169 | port: 3000, 170 | method: 'HEAD', 171 | path: '/', 172 | interval: '15s', 173 | timeout: '10s', 174 | }, 175 | }, 176 | } 177 | nock(FLY_API_HOSTNAME) 178 | .post(`/v1/apps/${app_name}/machines`, { 179 | name: machine.name, 180 | config: config as any, 181 | }) 182 | .reply(200, machine) 183 | const data = await fly.Machine.createMachine({ 184 | app_name, 185 | name: machine.name, 186 | config, 187 | }) 188 | console.dir(data, { depth: 10 }) 189 | }) 190 | 191 | it('updates machine', async () => { 192 | const machine_id = machine.id 193 | const config = { 194 | image: 'sweatybridge/postgres:all-in-one', 195 | services: [], 196 | } 197 | nock(FLY_API_HOSTNAME) 198 | .post(`/v1/apps/${app_name}/machines/${machine_id}`, { config }) 199 | .reply(200, machine) 200 | const data = await fly.Machine.updateMachine({ 201 | app_name, 202 | machine_id, 203 | config, 204 | }) 205 | console.dir(data, { depth: 5 }) 206 | }) 207 | 208 | it('deletes machine', async () => { 209 | const machine_id = machine.id 210 | nock(FLY_API_HOSTNAME) 211 | .delete(`/v1/apps/${app_name}/machines/${machine_id}`) 212 | .reply(200, { ok: true }) 213 | const data = await fly.Machine.deleteMachine({ 214 | app_name, 215 | machine_id, 216 | }) 217 | console.dir(data, { depth: 5 }) 218 | }) 219 | 220 | it('stops machine', async () => { 221 | const machine_id = machine.id 222 | nock(FLY_API_HOSTNAME) 223 | .post(`/v1/apps/${app_name}/machines/${machine_id}/stop`, { 224 | signal: 'SIGTERM', 225 | }) 226 | .reply(200, { ok: true }) 227 | const data = await fly.Machine.stopMachine({ 228 | app_name, 229 | machine_id, 230 | }) 231 | console.dir(data, { depth: 5 }) 232 | }) 233 | 234 | it('starts machine', async () => { 235 | const machine_id = machine.id 236 | nock(FLY_API_HOSTNAME) 237 | .post(`/v1/apps/${app_name}/machines/${machine_id}/start`) 238 | .reply(200, { ok: true }) 239 | const data = await fly.Machine.startMachine({ 240 | app_name, 241 | machine_id, 242 | }) 243 | console.dir(data, { depth: 5 }) 244 | }) 245 | 246 | it('lists machines', async () => { 247 | nock(FLY_API_HOSTNAME) 248 | .get(`/v1/apps/${app_name}/machines`) 249 | .reply(200, [machine]) 250 | const data = await fly.Machine.listMachines(app_name) 251 | console.dir(data, { depth: 10 }) 252 | }) 253 | 254 | it('gets machine', async () => { 255 | const machine_id = machine.id 256 | nock(FLY_API_HOSTNAME) 257 | .get(`/v1/apps/${app_name}/machines/${machine_id}`) 258 | .reply(200, { 259 | ...machine, 260 | state: MachineState.Started, 261 | events: [ 262 | { 263 | id: '01H28X7D7GGZFSQPZ7YWVG17RH', 264 | type: 'start', 265 | status: 'started', 266 | source: 'flyd', 267 | timestamp: 1686073750768, 268 | }, 269 | ...machine.events, 270 | ], 271 | }) 272 | const data = await fly.Machine.getMachine({ 273 | app_name, 274 | machine_id, 275 | }) 276 | console.dir(data, { depth: 10 }) 277 | }) 278 | 279 | it('restarts machine', async () => { 280 | const machine_id = machine.id 281 | nock(FLY_API_HOSTNAME) 282 | .post(`/v1/apps/${app_name}/machines/${machine_id}/restart`) 283 | .reply(200, { ok: true }) 284 | const data = await fly.Machine.restartMachine({ 285 | app_name, 286 | machine_id, 287 | }) 288 | console.dir(data, { depth: 10 }) 289 | }) 290 | 291 | it('signals machine', async () => { 292 | const machine_id = machine.id 293 | const signal = SignalRequestSignalEnum.SIGHUP 294 | nock(FLY_API_HOSTNAME) 295 | .post(`/v1/apps/${app_name}/machines/${machine_id}/signal`, { signal }) 296 | .reply(200, { ok: true }) 297 | const data = await fly.Machine.signalMachine({ 298 | app_name, 299 | machine_id, 300 | signal, 301 | }) 302 | console.dir(data, { depth: 10 }) 303 | }) 304 | 305 | it('waits machine', async () => { 306 | const machine_id = machine.id 307 | const state = StateEnum.Started 308 | nock(FLY_API_HOSTNAME) 309 | .get(`/v1/apps/${app_name}/machines/${machine_id}/wait`) 310 | .query({ state }) 311 | .reply(200, { ok: true }) 312 | const data = await fly.Machine.waitMachine({ 313 | app_name, 314 | machine_id, 315 | state, 316 | }) 317 | console.dir(data, { depth: 10 }) 318 | }) 319 | 320 | it('lists events', async () => { 321 | const machine_id = machine.id 322 | nock(FLY_API_HOSTNAME) 323 | .get(`/v1/apps/${app_name}/machines/${machine_id}/events`) 324 | .reply(200, [ 325 | { 326 | id: '01H9QFJCZ03MEGZKYPYY4ZSTTS', 327 | type: 'exit', 328 | status: 'stopped', 329 | request: { 330 | exit_event: { 331 | requested_stop: true, 332 | restarting: false, 333 | guest_exit_code: 0, 334 | guest_signal: -1, 335 | guest_error: '', 336 | exit_code: 143, 337 | signal: -1, 338 | error: '', 339 | oom_killed: false, 340 | exited_at: '2023-09-07T09:28:59.832Z', 341 | }, 342 | restart_count: 0, 343 | }, 344 | source: 'flyd', 345 | timestamp: 1694078940128, 346 | }, 347 | ]) 348 | const data = await fly.Machine.listEvents({ 349 | app_name, 350 | machine_id, 351 | }) 352 | console.dir(data, { depth: 10 }) 353 | }) 354 | 355 | it('lists versions', async () => { 356 | const machine_id = machine.id 357 | nock(FLY_API_HOSTNAME) 358 | .get(`/v1/apps/${app_name}/machines/${machine_id}/versions`) 359 | .reply(200, [ 360 | { 361 | version: '01H28X6YK062GWVQHQ6CF8FG5S', 362 | user_config: machine.config, 363 | }, 364 | ]) 365 | const data = await fly.Machine.listVersions({ 366 | app_name, 367 | machine_id, 368 | }) 369 | console.dir(data, { depth: 10 }) 370 | }) 371 | 372 | it('lists processes', async () => { 373 | const machine_id = machine.id 374 | nock(FLY_API_HOSTNAME) 375 | .get(`/v1/apps/${app_name}/machines/${machine_id}/ps`) 376 | .reply(200, [ 377 | { 378 | pid: 713, 379 | stime: 2, 380 | rtime: 847, 381 | command: 'nginx: worker process', 382 | directory: '/', 383 | cpu: 0, 384 | rss: 97005568, 385 | listen_sockets: [ 386 | { proto: 'tcp', address: '0.0.0.0:8000' }, 387 | { proto: 'tcp', address: '0.0.0.0:8443' }, 388 | ], 389 | }, 390 | ]) 391 | const data = await fly.Machine.listProcesses({ 392 | app_name, 393 | machine_id, 394 | }) 395 | console.dir(data, { depth: 10 }) 396 | }) 397 | 398 | it('gets lease', async () => { 399 | const machine_id = machine.id 400 | nock(FLY_API_HOSTNAME) 401 | .get(`/v1/apps/${app_name}/machines/${machine_id}/lease`) 402 | .reply(200, { 403 | status: 'success', 404 | data: { 405 | nonce: '45b8f9200c72', 406 | expires_at: 1694080223, 407 | owner: 'example@fly.io', 408 | description: '', 409 | }, 410 | }) 411 | const data = await fly.Machine.getLease({ 412 | app_name, 413 | machine_id, 414 | }) 415 | console.dir(data, { depth: 10 }) 416 | }) 417 | 418 | it('acquires lease', async () => { 419 | const body = { ttl: 60 } 420 | const machine_id = machine.id 421 | nock(FLY_API_HOSTNAME) 422 | .post(`/v1/apps/${app_name}/machines/${machine_id}/lease`, body) 423 | .reply(200, { 424 | status: 'success', 425 | data: { 426 | nonce: '45b8f9200c72', 427 | expires_at: 1694080223, 428 | owner: 'example@fly.io', 429 | description: '', 430 | }, 431 | }) 432 | const data = await fly.Machine.acquireLease({ 433 | app_name, 434 | machine_id, 435 | ...body, 436 | }) 437 | console.dir(data, { depth: 10 }) 438 | }) 439 | 440 | it('cordons machine', async () => { 441 | const machine_id = machine.id 442 | nock(FLY_API_HOSTNAME) 443 | .post(`/v1/apps/${app_name}/machines/${machine_id}/cordon`) 444 | .reply(200, { 445 | status: 'success', 446 | data: { 447 | nonce: '45b8f9200c72', 448 | expires_at: 1694080223, 449 | owner: 'example@fly.io', 450 | description: '', 451 | }, 452 | }) 453 | const data = await fly.Machine.cordonMachine({ 454 | app_name, 455 | machine_id, 456 | }) 457 | console.dir(data, { depth: 10 }) 458 | }) 459 | 460 | it('uncordons machine', async () => { 461 | const machine_id = machine.id 462 | nock(FLY_API_HOSTNAME) 463 | .post(`/v1/apps/${app_name}/machines/${machine_id}/uncordon`) 464 | .reply(200, { ok: true }) 465 | const data = await fly.Machine.uncordonMachine({ 466 | app_name, 467 | machine_id, 468 | }) 469 | console.dir(data, { depth: 10 }) 470 | }) 471 | }) 472 | -------------------------------------------------------------------------------- /__tests__/network.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it } from '@jest/globals' 3 | import { AddressType } from '../src/lib/network' 4 | import { FLY_API_GRAPHQL } from '../src/client' 5 | import { createClient } from '../src/main' 6 | 7 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 8 | 9 | describe('network', () => { 10 | it('allocates ip address', async () => { 11 | nock(FLY_API_GRAPHQL) 12 | .post('/graphql') 13 | .reply(200, { 14 | data: { 15 | allocateIpAddress: { 16 | ipAddress: { 17 | id: 'ip_lm6k9x4qw0g1qp7r', 18 | address: '2a09:8280:1::a:e929', 19 | type: 'v6', 20 | region: 'global', 21 | createdAt: '2023-02-23T11:01:36Z', 22 | }, 23 | }, 24 | }, 25 | }) 26 | const data = await fly.Network.allocateIpAddress({ 27 | appId: 'ctwntjgykzxhfncfwrfo', 28 | type: AddressType.v6, 29 | }) 30 | console.dir(data, { depth: 5 }) 31 | }) 32 | 33 | it('releases ip address', async () => { 34 | nock(FLY_API_GRAPHQL) 35 | .post('/graphql') 36 | .reply(200, { 37 | data: { 38 | releaseIpAddress: { 39 | app: { 40 | name: 'ctwntjgykzxhfncfwrfo', 41 | }, 42 | }, 43 | }, 44 | }) 45 | const data = await fly.Network.releaseIpAddress({ 46 | appId: 'ctwntjgykzxhfncfwrfo', 47 | ip: '2a09:8280:1::1:e80d', 48 | }) 49 | console.dir(data, { depth: 5 }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /__tests__/organization.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it } from '@jest/globals' 3 | import { FLY_API_GRAPHQL } from '../src/client' 4 | import { createClient } from '../src/main' 5 | 6 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 7 | 8 | describe('organization', () => { 9 | it('get personal', async () => { 10 | nock(FLY_API_GRAPHQL) 11 | .post('/graphql') 12 | .reply(200, { 13 | data: { 14 | organization: { 15 | id: 'D307G6NwgR0z2u4vPG23jYy8a3cg3xbYR', 16 | slug: 'personal', 17 | name: 'Test User', 18 | type: 'PERSONAL', 19 | viewerRole: 'admin', 20 | }, 21 | }, 22 | }) 23 | const data = await fly.Organization.getOrganization('personal') 24 | console.dir(data, { depth: 5 }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /__tests__/regions.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it, expect } from '@jest/globals' 3 | import { FLY_API_GRAPHQL } from '../src/client' 4 | import { createClient } from '../src/main' 5 | 6 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 7 | 8 | describe('regions', () => { 9 | it('get regions', async () => { 10 | const mockResponse = { 11 | data: { 12 | platform: { 13 | requestRegion: 'sin', 14 | regions: [ 15 | { 16 | name: 'Amsterdam, Netherlands', 17 | code: 'ams', 18 | latitude: 52.374342, 19 | longitude: 4.895439, 20 | gatewayAvailable: true, 21 | requiresPaidPlan: false, 22 | }, 23 | // Add more mock regions as needed 24 | ], 25 | }, 26 | }, 27 | } 28 | 29 | nock(FLY_API_GRAPHQL).post('/graphql').reply(200, mockResponse) 30 | 31 | const data = await fly.Regions.getRegions() 32 | console.dir(data, { depth: 5 }) 33 | 34 | // Optionally, add assertions to verify the response 35 | expect(data).toBeDefined() 36 | expect(data.platform).toBeDefined() 37 | expect(data.platform.regions).toBeInstanceOf(Array) 38 | // Add more assertions as needed 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /__tests__/secret.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it } from '@jest/globals' 3 | import { FLY_API_GRAPHQL } from '../src/client' 4 | import { createClient } from '../src/main' 5 | 6 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 7 | 8 | describe('secret', () => { 9 | it('sets secret', async () => { 10 | nock(FLY_API_GRAPHQL) 11 | .post('/graphql') 12 | .reply(200, { 13 | data: { 14 | setSecrets: { 15 | release: null, 16 | }, 17 | }, 18 | }) 19 | const data = await fly.Secret.setSecrets({ 20 | appId: 'ctwntjgykzxhfncfwrfo', 21 | secrets: [ 22 | { 23 | key: 'POSTGRES_PASSWORD', 24 | value: 'password', 25 | }, 26 | { 27 | key: 'JWT_SECRET', 28 | value: 'super-secret-jwt-token-with-at-least-32-characters-long', 29 | }, 30 | ], 31 | }) 32 | console.dir(data, { depth: 5 }) 33 | }) 34 | 35 | it('unsets secret', async () => { 36 | nock(FLY_API_GRAPHQL) 37 | .post('/graphql') 38 | .reply(200, { 39 | data: { 40 | unsetSecrets: { 41 | release: null, 42 | }, 43 | }, 44 | }) 45 | const data = await fly.Secret.unsetSecrets({ 46 | appId: 'ctwntjgykzxhfncfwrfo', 47 | keys: ['test-key'], 48 | }) 49 | console.dir(data, { depth: 5 }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /__tests__/volume.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { describe, it } from '@jest/globals' 3 | import { FLY_API_HOSTNAME } from '../src/client' 4 | import { createClient } from '../src/main' 5 | import { VolumeResponse } from '../src/lib/volume' 6 | 7 | const fly = createClient(process.env.FLY_API_TOKEN || 'test-token') 8 | 9 | describe('volume', () => { 10 | const app_name = 'ctwntjgykzxhfncfwrfo' 11 | const volume: VolumeResponse = { 12 | id: 'vol_0vdyzpeqgpzl383v', 13 | name: 'pgdata', 14 | state: 'created', 15 | size_gb: 2, 16 | region: 'hkg', 17 | zone: '553e', 18 | encrypted: true, 19 | attached_machine_id: null, 20 | attached_alloc_id: null, 21 | created_at: '2023-09-06T10:04:03.905Z', 22 | blocks: 0, 23 | block_size: 0, 24 | blocks_free: 0, 25 | blocks_avail: 0, 26 | fstype: '', 27 | host_dedication_key: '', 28 | } 29 | 30 | it('lists volumes', async () => { 31 | nock(FLY_API_HOSTNAME) 32 | .get(`/v1/apps/${app_name}/volumes`) 33 | .reply(200, [ 34 | { 35 | ...volume, 36 | attached_machine_id: '17811953c92e18', 37 | blocks: 252918, 38 | block_size: 4096, 39 | blocks_free: 234544, 40 | blocks_avail: 217392, 41 | fstype: 'ext4', 42 | }, 43 | ]) 44 | const data = await fly.Volume.listVolumes(app_name) 45 | console.dir(data, { depth: 5 }) 46 | }) 47 | 48 | it('gets volume', async () => { 49 | const volume_id = volume.id 50 | nock(FLY_API_HOSTNAME) 51 | .get(`/v1/apps/${app_name}/volumes/${volume_id}`) 52 | .reply(200, volume) 53 | const data = await fly.Volume.getVolume({ 54 | app_name, 55 | volume_id, 56 | }) 57 | console.dir(data, { depth: 5 }) 58 | }) 59 | 60 | it('creates volume', async () => { 61 | const body = { 62 | name: 'pgdata', 63 | region: 'hkg', 64 | size_gb: 2, 65 | } 66 | nock(FLY_API_HOSTNAME) 67 | .post(`/v1/apps/${app_name}/volumes`, body) 68 | .reply(200, volume) 69 | const data = await fly.Volume.createVolume({ 70 | app_name, 71 | ...body, 72 | }) 73 | console.dir(data, { depth: 5 }) 74 | }) 75 | 76 | it('deletes volume', async () => { 77 | const volume_id = volume.id 78 | nock(FLY_API_HOSTNAME) 79 | .delete(`/v1/apps/${app_name}/volumes/${volume_id}`) 80 | .reply(200, { 81 | ...volume, 82 | state: 'destroyed', 83 | }) 84 | const data = await fly.Volume.deleteVolume({ 85 | app_name, 86 | volume_id, 87 | }) 88 | console.dir(data, { depth: 5 }) 89 | }) 90 | 91 | it('forks volume', async () => { 92 | const body = { 93 | name: 'forked', 94 | region: '', 95 | source_volume_id: volume.id, 96 | } 97 | nock(FLY_API_HOSTNAME) 98 | .post(`/v1/apps/${app_name}/volumes`, body) 99 | .reply(200, { 100 | ...volume, 101 | id: 'vol_5456e1j33p16378r', 102 | name: 'forked', 103 | state: 'hydrating', 104 | }) 105 | const data = await fly.Volume.createVolume({ 106 | app_name, 107 | ...body, 108 | }) 109 | console.dir(data, { depth: 5 }) 110 | }) 111 | 112 | it('extends volume', async () => { 113 | const body = { size_gb: 4 } 114 | const volume_id = volume.id 115 | nock(FLY_API_HOSTNAME) 116 | .put(`/v1/apps/${app_name}/volumes/${volume_id}/extend`, body) 117 | .reply(200, { 118 | needs_restart: true, 119 | volume: { 120 | ...volume, 121 | ...body, 122 | }, 123 | }) 124 | const data = await fly.Volume.extendVolume({ 125 | app_name, 126 | volume_id, 127 | ...body, 128 | }) 129 | console.dir(data, { depth: 5 }) 130 | }) 131 | 132 | it('lists snapshots', async () => { 133 | const volume_id = volume.id 134 | nock(FLY_API_HOSTNAME) 135 | .get(`/v1/apps/${app_name}/volumes/${volume_id}/snapshots`) 136 | .reply(200, []) 137 | const data = await fly.Volume.listSnapshots({ 138 | app_name, 139 | volume_id, 140 | }) 141 | console.dir(data, { depth: 5 }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | setupFiles: ['dotenv/config'], 5 | testMatch: ['**/*.test.ts'], 6 | transform: { 7 | '^.+\\.ts$': 'ts-jest', 8 | }, 9 | verbose: true, 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fly-admin", 3 | "version": "0.0.0-automated", 4 | "description": "A Typescript client for managing Fly infrastructure.", 5 | "main": "dist/main.js", 6 | "types": "dist/main.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "format": "prettier --write '**/*.ts'", 13 | "format-check": "prettier --check '**/*.ts'", 14 | "lint": "eslint src/**/*.ts", 15 | "test": "jest", 16 | "gen:types": "npx swagger-typescript-api -p https://docs.machines.dev/swagger/doc.json -o ./src/lib --extract-enums --extract-request-params --no-client -n types.ts", 17 | "all": "npm run build && npm run format && npm run lint && npm test" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/supabase/fly-admin.git" 22 | }, 23 | "keywords": [ 24 | "fly", 25 | "api", 26 | "client" 27 | ], 28 | "author": "Supabase", 29 | "license": "MIT", 30 | "dependencies": { 31 | "cross-fetch": "^3.1.5" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^18.15.0", 35 | "@typescript-eslint/parser": "^5.58.0", 36 | "dotenv": "^16.3.1", 37 | "eslint": "^8.38.0", 38 | "eslint-plugin-github": "^4.7.0", 39 | "eslint-plugin-jest": "^27.2.1", 40 | "jest": "^29.5.0", 41 | "nock": "^13.3.0", 42 | "prettier": "^2.8.7", 43 | "ts-jest": "^29.1.0", 44 | "typescript": "^5.0.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import crossFetch from 'cross-fetch' 2 | import { App } from './lib/app' 3 | import { Machine } from './lib/machine' 4 | import { Network } from './lib/network' 5 | import { Organization } from './lib/organization' 6 | import { Secret } from './lib/secret' 7 | import { Volume } from './lib/volume' 8 | import { Regions } from './lib/regions' 9 | 10 | export const FLY_API_GRAPHQL = 'https://api.fly.io' 11 | export const FLY_API_HOSTNAME = 'https://api.machines.dev' 12 | 13 | interface GraphQLRequest { 14 | query: string 15 | variables?: Record 16 | } 17 | 18 | interface GraphQLResponse { 19 | data: T 20 | errors?: { 21 | message: string 22 | locations: { 23 | line: number 24 | column: number 25 | }[] 26 | }[] 27 | } 28 | 29 | interface ClientConfig { 30 | graphqlUrl?: string 31 | apiUrl?: string 32 | } 33 | 34 | class Client { 35 | private graphqlUrl: string 36 | private apiUrl: string 37 | private apiKey: string 38 | App: App 39 | Machine: Machine 40 | Regions: Regions 41 | Network: Network 42 | Organization: Organization 43 | Secret: Secret 44 | Volume: Volume 45 | 46 | constructor(apiKey: string, { graphqlUrl, apiUrl }: ClientConfig = {}) { 47 | if (!apiKey) { 48 | throw new Error('Fly API Key is required') 49 | } 50 | this.graphqlUrl = graphqlUrl || FLY_API_GRAPHQL 51 | this.apiUrl = apiUrl || FLY_API_HOSTNAME 52 | this.apiKey = apiKey 53 | this.App = new App(this) 54 | this.Machine = new Machine(this) 55 | this.Network = new Network(this) 56 | this.Regions = new Regions(this) 57 | this.Organization = new Organization(this) 58 | this.Secret = new Secret(this) 59 | this.Volume = new Volume(this) 60 | } 61 | 62 | getApiKey(): string { 63 | return this.apiKey 64 | } 65 | 66 | getApiUrl(): string { 67 | return this.apiUrl 68 | } 69 | 70 | getGraphqlUrl(): string { 71 | return this.graphqlUrl 72 | } 73 | 74 | async gqlPostOrThrow(payload: GraphQLRequest): Promise { 75 | const token = this.apiKey 76 | const resp = await crossFetch(`${this.graphqlUrl}/graphql`, { 77 | method: 'POST', 78 | headers: { 79 | Authorization: `Bearer ${token}`, 80 | 'Content-Type': 'application/json', 81 | }, 82 | body: JSON.stringify(payload), 83 | }) 84 | const text = await resp.text() 85 | if (!resp.ok) { 86 | throw new Error(`${resp.status}: ${text}`) 87 | } 88 | const { data, errors }: GraphQLResponse = JSON.parse(text) 89 | if (errors) { 90 | throw new Error(JSON.stringify(errors)) 91 | } 92 | return data 93 | } 94 | 95 | async restOrThrow( 96 | path: string, 97 | method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', 98 | body?: U 99 | ): Promise { 100 | const resp = await crossFetch(`${this.apiUrl}/v1/${path}`, { 101 | method, 102 | headers: { 103 | Authorization: `Bearer ${this.apiKey}`, 104 | 'Content-Type': 'application/json', 105 | }, 106 | body: JSON.stringify(body), 107 | }) 108 | const text = await resp.text() 109 | if (!resp.ok) { 110 | throw new Error(`${resp.status}: ${text}`) 111 | } 112 | return text ? JSON.parse(text) : undefined 113 | } 114 | } 115 | 116 | export default Client 117 | -------------------------------------------------------------------------------- /src/lib/app.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | 3 | export type ListAppRequest = string 4 | 5 | export interface ListAppResponse { 6 | total_apps: number 7 | apps: { 8 | name: string 9 | machine_count: number 10 | network: string 11 | }[] 12 | } 13 | 14 | export type GetAppRequest = string 15 | 16 | const getAppQuery = `query($name: String!) { 17 | app(name: $name) { 18 | name 19 | status 20 | organization { 21 | name 22 | slug 23 | } 24 | ipAddresses { 25 | nodes { 26 | type 27 | region 28 | address 29 | } 30 | } 31 | } 32 | }` 33 | 34 | export enum AppStatus { 35 | deployed = 'deployed', 36 | pending = 'pending', 37 | suspended = 'suspended', 38 | } 39 | 40 | export interface AppResponse { 41 | name: string 42 | status: AppStatus 43 | organization: { 44 | name: string 45 | slug: string 46 | } 47 | ipAddresses: IPAddress[] 48 | } 49 | 50 | export interface IPAddress { 51 | type: string 52 | address: string 53 | } 54 | 55 | export interface CreateAppRequest { 56 | org_slug: string 57 | app_name: string 58 | network?: string 59 | } 60 | 61 | export type DeleteAppRequest = string 62 | 63 | export class App { 64 | private client: Client 65 | 66 | constructor(client: Client) { 67 | this.client = client 68 | } 69 | 70 | async listApps(org_slug: ListAppRequest): Promise { 71 | const path = `apps?org_slug=${org_slug}` 72 | return await this.client.restOrThrow(path) 73 | } 74 | 75 | async getApp(app_name: GetAppRequest): Promise { 76 | const path = `apps/${app_name}` 77 | return await this.client.restOrThrow(path) 78 | } 79 | 80 | async getAppDetailed(app_name: GetAppRequest): Promise { 81 | const { app } = await this.client.gqlPostOrThrow({ 82 | query: getAppQuery, 83 | variables: { name: app_name }, 84 | }) as { app: AppResponse } 85 | 86 | const ipAddresses = app.ipAddresses as unknown as { nodes: IPAddress[] } 87 | 88 | return { 89 | ...app, 90 | ipAddresses: ipAddresses.nodes, 91 | } 92 | } 93 | 94 | async createApp(payload: CreateAppRequest): Promise { 95 | const path = 'apps' 96 | return await this.client.restOrThrow(path, 'POST', payload) 97 | } 98 | 99 | async deleteApp(app_name: DeleteAppRequest): Promise { 100 | const path = `apps/${app_name}` 101 | return await this.client.restOrThrow(path, 'DELETE') 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib/machine.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | import { 3 | ApiMachineConfig, 4 | ApiMachineInit, 5 | ApiMachineService, 6 | ApiMachineMount, 7 | ApiMachinePort, 8 | ApiMachineCheck, 9 | ApiMachineRestart, 10 | ApiMachineGuest, 11 | CheckStatus as ApiCheckStatus, 12 | CreateMachineRequest as ApiCreateMachineRequest, 13 | ImageRef as ApiImageRef, 14 | Machine as ApiMachine, 15 | StateEnum as ApiMachineState, 16 | SignalRequestSignalEnum as ApiMachineSignal, 17 | } from './types' 18 | 19 | // We override the generated types from openapi spec to mark fields as non-optional 20 | export interface MachineConfig extends ApiMachineConfig { 21 | // The Docker image to run 22 | image: string 23 | // Optionally one of hourly, daily, weekly, monthly. Runs machine at the given interval. Interval starts at time of machine creation 24 | schedule?: 'hourly' | 'daily' | 'weekly' | 'monthly' 25 | } 26 | 27 | export type ListMachineRequest = 28 | | string 29 | | { 30 | app_name: string 31 | include_deleted?: '' 32 | region?: string 33 | } 34 | 35 | // Ref: https://fly.io/docs/machines/working-with-machines/#create-a-machine 36 | export interface CreateMachineRequest extends ApiCreateMachineRequest { 37 | app_name: string 38 | config: MachineConfig 39 | } 40 | 41 | interface BaseEvent { 42 | id: string 43 | type: string 44 | status: string 45 | source: 'flyd' | 'user' 46 | timestamp: number 47 | } 48 | 49 | interface StartEvent extends BaseEvent { 50 | type: 'start' 51 | status: 'started' | 'starting' 52 | } 53 | 54 | interface LaunchEvent extends BaseEvent { 55 | type: 'launch' 56 | status: 'created' 57 | source: 'user' 58 | } 59 | 60 | interface RestartEvent extends BaseEvent { 61 | type: 'restart' 62 | status: 'starting' | 'stopping' 63 | source: 'flyd' | 'user' 64 | } 65 | 66 | interface ExitEvent extends BaseEvent { 67 | type: 'exit' 68 | status: 'stopped' 69 | source: 'flyd' 70 | request: { 71 | exit_event: { 72 | requested_stop: boolean 73 | restarting: boolean 74 | guest_exit_code: number 75 | guest_signal: number 76 | guest_error: string 77 | exit_code: number 78 | signal: number 79 | error: string 80 | oom_killed: boolean 81 | exited_at: string 82 | } 83 | restart_count: number 84 | } 85 | } 86 | 87 | export type MachineEvent = LaunchEvent | StartEvent | RestartEvent | ExitEvent 88 | 89 | export enum MachineState { 90 | Created = 'created', 91 | Starting = 'starting', 92 | Started = 'started', 93 | Stopping = 'stopping', 94 | Stopped = 'stopped', 95 | Replacing = 'replacing', 96 | Destroying = 'destroying', 97 | Destroyed = 'destroyed', 98 | } 99 | 100 | interface MachineMount extends ApiMachineMount { 101 | encrypted: boolean 102 | // Absolute path on the VM where the volume should be mounted. i.e. /data 103 | path: string 104 | size_gb: number 105 | // The volume ID, visible in fly volumes list, i.e. vol_2n0l3vl60qpv635d 106 | volume: string 107 | name: string 108 | } 109 | 110 | export enum ConnectionHandler { 111 | // Convert TLS connection to unencrypted TCP 112 | TLS = 'tls', 113 | // Handle TLS for PostgreSQL connections 114 | PG_TLS = 'pg_tls', 115 | // Convert TCP connection to HTTP 116 | HTTP = 'http', 117 | // Wrap TCP connection in PROXY protocol 118 | PROXY_PROTO = 'proxy_proto', 119 | } 120 | 121 | interface MachinePort extends ApiMachinePort { 122 | // Public-facing port number 123 | port: number 124 | // Array of connection handlers for TCP-based services. 125 | handlers?: ConnectionHandler[] 126 | } 127 | 128 | interface MachineService extends ApiMachineService { 129 | protocol: 'tcp' | 'udp' 130 | internal_port: number 131 | ports: MachinePort[] 132 | // load balancing concurrency settings 133 | concurrency?: { 134 | // connections (TCP) or requests (HTTP). Defaults to connections. 135 | type: 'connections' | 'requests' 136 | // "ideal" service concurrency. We will attempt to spread load to keep services at or below this limit 137 | soft_limit: number 138 | // maximum allowed concurrency. We will queue or reject when a service is at this limit 139 | hard_limit: number 140 | } 141 | } 142 | 143 | interface MachineCheck extends ApiMachineCheck { 144 | // tcp or http 145 | type: 'tcp' | 'http' 146 | // The port to connect to, likely should be the same as internal_port 147 | port: number 148 | // The time between connectivity checks 149 | interval: string 150 | // The maximum time a connection can take before being reported as failing its healthcheck 151 | timeout: string 152 | } 153 | 154 | interface MachineGuest extends ApiMachineGuest { 155 | cpu_kind: 'shared' | 'performance' 156 | cpus: number 157 | memory_mb: number 158 | } 159 | 160 | interface CheckStatus extends ApiCheckStatus { 161 | name: string 162 | status: 'passing' | 'warning' | 'critical' 163 | output: string 164 | updated_at: string 165 | } 166 | 167 | interface MachineImageRef extends Omit { 168 | registry: string 169 | repository: string 170 | tag: string 171 | digest: string 172 | labels: Record | null 173 | } 174 | 175 | export interface MachineResponse extends Omit { 176 | id: string 177 | name: string 178 | state: MachineState 179 | region: string 180 | instance_id: string 181 | private_ip: string 182 | config: { 183 | env: Record 184 | init: ApiMachineInit 185 | mounts: MachineMount[] 186 | services: MachineService[] 187 | checks: Record 188 | restart: ApiMachineRestart 189 | guest: MachineGuest 190 | size: 'shared-cpu-1x' | 'shared-cpu-2x' | 'shared-cpu-4x' 191 | } & MachineConfig 192 | image_ref: MachineImageRef 193 | created_at: string 194 | updated_at: string 195 | events: MachineEvent[] 196 | checks: CheckStatus[] 197 | } 198 | 199 | export interface GetMachineRequest { 200 | app_name: string 201 | machine_id: string 202 | } 203 | 204 | interface OkResponse { 205 | ok: boolean 206 | } 207 | 208 | export interface DeleteMachineRequest extends GetMachineRequest { 209 | // If true, the machine will be deleted even if it is in any other state than running. 210 | force?: boolean 211 | } 212 | 213 | export interface RestartMachineRequest extends GetMachineRequest { 214 | timeout?: string 215 | } 216 | 217 | export interface SignalMachineRequest extends GetMachineRequest { 218 | signal: ApiMachineSignal 219 | } 220 | 221 | export interface StopMachineRequest extends RestartMachineRequest { 222 | signal?: ApiMachineSignal 223 | } 224 | 225 | export type StartMachineRequest = GetMachineRequest 226 | 227 | export interface UpdateMachineRequest extends GetMachineRequest { 228 | config: MachineConfig 229 | } 230 | 231 | export type ListEventsRequest = GetMachineRequest 232 | 233 | export type ListVersionsRequest = GetMachineRequest 234 | 235 | export interface ListProcessesRequest extends GetMachineRequest { 236 | sort_by?: string 237 | order?: string 238 | } 239 | 240 | export interface ProcessResponse { 241 | command: string 242 | cpu: number 243 | directory: string 244 | listen_sockets: [ 245 | { 246 | address: string 247 | proto: string 248 | } 249 | ] 250 | pid: number 251 | rss: number 252 | rtime: number 253 | stime: number 254 | } 255 | 256 | export interface WaitMachineRequest extends GetMachineRequest { 257 | instance_id?: string 258 | // Default timeout is 60 (seconds) 259 | timeout?: string 260 | state?: ApiMachineState 261 | } 262 | 263 | export interface WaitMachineStopRequest extends WaitMachineRequest { 264 | instance_id: string 265 | state?: ApiMachineState.Stopped 266 | } 267 | 268 | export interface MachineVersionResponse { 269 | user_config: MachineResponse 270 | version: string 271 | } 272 | 273 | export type GetLeaseRequest = GetMachineRequest 274 | 275 | export interface LeaseResponse { 276 | status: 'success' 277 | data: { 278 | description: string 279 | expires_at: number 280 | nonce: string 281 | owner: string 282 | } 283 | } 284 | 285 | export interface AcquireLeaseRequest extends GetLeaseRequest { 286 | description?: string 287 | ttl: number 288 | } 289 | 290 | export interface DeleteLeaseRequest extends GetLeaseRequest { 291 | nonce: string 292 | } 293 | 294 | export type CordonMachineRequest = GetMachineRequest 295 | 296 | export type UncordonMachineRequest = GetMachineRequest 297 | 298 | export class Machine { 299 | private client: Client 300 | 301 | constructor(client: Client) { 302 | this.client = client 303 | } 304 | 305 | async listMachines(app_name: ListMachineRequest): Promise { 306 | let path: string 307 | if (typeof app_name === 'string') { 308 | path = `apps/${app_name}/machines` 309 | } else { 310 | const { app_name: appId, ...params } = app_name 311 | path = `apps/${appId}/machines` 312 | const query = new URLSearchParams(params).toString() 313 | if (query) path += `?${query}` 314 | } 315 | return await this.client.restOrThrow(path) 316 | } 317 | 318 | async getMachine(payload: GetMachineRequest): Promise { 319 | const { app_name, machine_id } = payload 320 | const path = `apps/${app_name}/machines/${machine_id}` 321 | return await this.client.restOrThrow(path) 322 | } 323 | 324 | async createMachine(payload: CreateMachineRequest): Promise { 325 | const { app_name, ...body } = payload 326 | const path = `apps/${app_name}/machines` 327 | return await this.client.restOrThrow(path, 'POST', body) 328 | } 329 | 330 | async updateMachine(payload: UpdateMachineRequest): Promise { 331 | const { app_name, machine_id, ...body } = payload 332 | const path = `apps/${app_name}/machines/${machine_id}` 333 | return await this.client.restOrThrow(path, 'POST', body) 334 | } 335 | 336 | async deleteMachine(payload: DeleteMachineRequest): Promise { 337 | const { app_name, machine_id, force } = payload 338 | const query = force ? '?kill=true' : '' 339 | const path = `apps/${app_name}/machines/${machine_id}${query}` 340 | return await this.client.restOrThrow(path, 'DELETE') 341 | } 342 | 343 | async startMachine(payload: StartMachineRequest): Promise { 344 | const { app_name, machine_id } = payload 345 | const path = `apps/${app_name}/machines/${machine_id}/start` 346 | return await this.client.restOrThrow(path, 'POST') 347 | } 348 | 349 | async stopMachine(payload: StopMachineRequest): Promise { 350 | const { app_name, machine_id, ...body } = payload 351 | const path = `apps/${app_name}/machines/${machine_id}/stop` 352 | return await this.client.restOrThrow(path, 'POST', { 353 | signal: ApiMachineSignal.SIGTERM, 354 | ...body, 355 | }) 356 | } 357 | 358 | async restartMachine(payload: RestartMachineRequest): Promise { 359 | const { app_name, machine_id, ...body } = payload 360 | const path = `apps/${app_name}/machines/${machine_id}/restart` 361 | return await this.client.restOrThrow(path, 'POST', body) 362 | } 363 | 364 | async signalMachine(payload: SignalMachineRequest): Promise { 365 | const { app_name, machine_id, ...body } = payload 366 | const path = `apps/${app_name}/machines/${machine_id}/signal` 367 | return await this.client.restOrThrow(path, 'POST', body) 368 | } 369 | 370 | async listEvents( 371 | payload: ListEventsRequest 372 | ): Promise { 373 | const { app_name, machine_id } = payload 374 | const path = `apps/${app_name}/machines/${machine_id}/events` 375 | return await this.client.restOrThrow(path) 376 | } 377 | 378 | async listVersions( 379 | payload: ListVersionsRequest 380 | ): Promise { 381 | const { app_name, machine_id } = payload 382 | const path = `apps/${app_name}/machines/${machine_id}/versions` 383 | return await this.client.restOrThrow(path) 384 | } 385 | 386 | async listProcesses(payload: ListProcessesRequest): Promise { 387 | const { app_name, machine_id, ...params } = payload 388 | let path = `apps/${app_name}/machines/${machine_id}/ps` 389 | const query = new URLSearchParams(params).toString() 390 | if (query) path += `?${query}` 391 | return await this.client.restOrThrow(path) 392 | } 393 | 394 | async waitMachine(payload: WaitMachineRequest): Promise { 395 | const { app_name, machine_id, ...params } = payload 396 | let path = `apps/${app_name}/machines/${machine_id}/wait` 397 | if (params.timeout?.endsWith('s')) 398 | params.timeout = params.timeout.slice(0, -1) 399 | const query = new URLSearchParams(params).toString() 400 | if (query) path += `?${query}` 401 | return await this.client.restOrThrow(path) 402 | } 403 | 404 | async getLease(payload: GetLeaseRequest): Promise { 405 | const { app_name, machine_id } = payload 406 | const path = `apps/${app_name}/machines/${machine_id}/lease` 407 | return await this.client.restOrThrow(path) 408 | } 409 | 410 | async acquireLease(payload: AcquireLeaseRequest): Promise { 411 | const { app_name, machine_id, ...body } = payload 412 | const path = `apps/${app_name}/machines/${machine_id}/lease` 413 | return await this.client.restOrThrow(path, 'POST', body) 414 | } 415 | 416 | async cordonMachine(payload: CordonMachineRequest): Promise { 417 | const { app_name, machine_id } = payload 418 | const path = `apps/${app_name}/machines/${machine_id}/cordon` 419 | return await this.client.restOrThrow(path, 'POST') 420 | } 421 | 422 | async uncordonMachine(payload: UncordonMachineRequest): Promise { 423 | const { app_name, machine_id } = payload 424 | const path = `apps/${app_name}/machines/${machine_id}/uncordon` 425 | return await this.client.restOrThrow(path, 'POST') 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /src/lib/network.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | 3 | export enum AddressType { 4 | v4 = 'v4', 5 | v6 = 'v6', 6 | private_v6 = 'private_v6', 7 | shared_v4 = 'shared_v4', 8 | } 9 | 10 | export interface AllocateIPAddressInput { 11 | appId: string 12 | type: AddressType 13 | organizationId?: string 14 | region?: string 15 | network?: string 16 | } 17 | 18 | export interface AllocateIPAddressOutput { 19 | allocateIpAddress: { 20 | ipAddress: { 21 | id: string 22 | address: string 23 | type: AddressType 24 | region: string 25 | createdAt: string 26 | } | null 27 | } 28 | } 29 | 30 | const allocateIpAddressQuery = `mutation($input: AllocateIPAddressInput!) { 31 | allocateIpAddress(input: $input) { 32 | ipAddress { 33 | id 34 | address 35 | type 36 | region 37 | createdAt 38 | } 39 | } 40 | }` 41 | 42 | export interface ReleaseIPAddressInput { 43 | appId?: string 44 | ipAddressId?: string 45 | ip?: string 46 | } 47 | 48 | export interface ReleaseIPAddressOutput { 49 | releaseIpAddress: { 50 | app: { 51 | name: string 52 | } 53 | } 54 | } 55 | 56 | const releaseIpAddressQuery = `mutation($input: ReleaseIPAddressInput!) { 57 | releaseIpAddress(input: $input) { 58 | app { 59 | name 60 | } 61 | } 62 | }` 63 | 64 | export class Network { 65 | private client: Client 66 | 67 | constructor(client: Client) { 68 | this.client = client 69 | } 70 | 71 | // Ref: https://github.com/superfly/flyctl/blob/master/api/resource_ip_addresses.go#L79 72 | async allocateIpAddress( 73 | input: AllocateIPAddressInput 74 | ): Promise { 75 | return this.client.gqlPostOrThrow({ 76 | query: allocateIpAddressQuery, 77 | variables: { input }, 78 | }) 79 | } 80 | 81 | async releaseIpAddress( 82 | input: ReleaseIPAddressInput 83 | ): Promise { 84 | return this.client.gqlPostOrThrow({ 85 | query: releaseIpAddressQuery, 86 | variables: { input }, 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/lib/organization.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | 3 | export type GetOrganizationInput = string 4 | 5 | interface OrganizationResponse { 6 | id: string 7 | slug: string 8 | name: string 9 | type: 'PERSONAL' | 'SHARED' 10 | viewerRole: 'admin' | 'member' 11 | } 12 | 13 | export interface GetOrganizationOutput { 14 | organization: OrganizationResponse 15 | } 16 | 17 | const getOrganizationQuery = `query($slug: String!) { 18 | organization(slug: $slug) { 19 | id 20 | slug 21 | name 22 | type 23 | viewerRole 24 | } 25 | }` 26 | 27 | export class Organization { 28 | private client: Client 29 | 30 | constructor(client: Client) { 31 | this.client = client 32 | } 33 | 34 | async getOrganization( 35 | slug: GetOrganizationInput 36 | ): Promise { 37 | return this.client.gqlPostOrThrow({ 38 | query: getOrganizationQuery, 39 | variables: { slug }, 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/regions.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | 3 | interface RegionResponse { 4 | name: string 5 | code: string 6 | latitude: number 7 | longitude: number 8 | gatewayAvailable: boolean 9 | requiresPaidPlan: boolean 10 | } 11 | 12 | interface PlatformResponse { 13 | requestRegion: string 14 | regions: RegionResponse[] 15 | } 16 | 17 | export interface GetRegionsOutput { 18 | platform: PlatformResponse 19 | } 20 | 21 | // Ref: https://github.com/superfly/flyctl/blob/master/api/resource_platform.go 22 | const getRegionsQuery = `query { 23 | platform { 24 | requestRegion 25 | regions { 26 | name 27 | code 28 | latitude 29 | longitude 30 | gatewayAvailable 31 | requiresPaidPlan 32 | } 33 | } 34 | }` 35 | 36 | export class Regions { 37 | private client: Client 38 | 39 | constructor(client: Client) { 40 | this.client = client 41 | } 42 | 43 | async getRegions(): Promise { 44 | return this.client.gqlPostOrThrow({ 45 | query: getRegionsQuery, 46 | variables: {}, 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/secret.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | 3 | export interface SetSecretsInput { 4 | appId: string 5 | secrets: { key: string; value: string }[] 6 | replaceAll?: boolean 7 | } 8 | 9 | export interface SetSecretsOutput { 10 | setSecrets: { 11 | release: { 12 | id: string 13 | version: string 14 | reason: string 15 | description: string 16 | user: { 17 | id: string 18 | email: string 19 | name: string 20 | } 21 | evaluationId: string 22 | createdAt: string 23 | } | null 24 | } 25 | } 26 | 27 | const setSecretsQuery = `mutation($input: SetSecretsInput!) { 28 | setSecrets(input: $input) { 29 | release { 30 | id 31 | version 32 | reason 33 | description 34 | user { 35 | id 36 | email 37 | name 38 | } 39 | evaluationId 40 | createdAt 41 | } 42 | } 43 | }` 44 | 45 | export interface UnsetSecretsInput { 46 | appId: string 47 | keys: string[] 48 | } 49 | 50 | export interface UnsetSecretsOutput { 51 | unsetSecrets: { 52 | release: { 53 | id: string 54 | version: string 55 | reason: string 56 | description: string 57 | user: { 58 | id: string 59 | email: string 60 | name: string 61 | } 62 | evaluationId: string 63 | createdAt: string 64 | } | null 65 | } 66 | } 67 | 68 | const unsetSecretsQuery = `mutation($input: UnsetSecretsInput!) { 69 | unsetSecrets(input: $input) { 70 | release { 71 | id 72 | version 73 | reason 74 | description 75 | user { 76 | id 77 | email 78 | name 79 | } 80 | evaluationId 81 | createdAt 82 | } 83 | } 84 | }` 85 | 86 | export class Secret { 87 | private client: Client 88 | 89 | constructor(client: Client) { 90 | this.client = client 91 | } 92 | 93 | async setSecrets(input: SetSecretsInput): Promise { 94 | return await this.client.gqlPostOrThrow({ 95 | query: setSecretsQuery, 96 | variables: { input }, 97 | }) 98 | } 99 | 100 | async unsetSecrets(input: UnsetSecretsInput): Promise { 101 | return await this.client.gqlPostOrThrow({ 102 | query: unsetSecretsQuery, 103 | variables: { input }, 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | /* 4 | * --------------------------------------------------------------- 5 | * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## 6 | * ## ## 7 | * ## AUTHOR: acacode ## 8 | * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## 9 | * --------------------------------------------------------------- 10 | */ 11 | 12 | export interface App { 13 | id?: string 14 | name?: string 15 | organization?: Organization 16 | status?: string 17 | } 18 | 19 | export interface CheckStatus { 20 | name?: string 21 | output?: string 22 | status?: string 23 | updated_at?: string 24 | } 25 | 26 | export interface CreateAppRequest { 27 | app_name?: string 28 | app_role_id?: string 29 | network?: string 30 | org_slug?: string 31 | } 32 | 33 | export interface CreateLeaseRequest { 34 | description?: string 35 | ttl?: number 36 | } 37 | 38 | export interface CreateMachineRequest { 39 | /** An object defining the Machine configuration */ 40 | config?: ApiMachineConfig 41 | lease_ttl?: number 42 | lsvd?: boolean 43 | /** Unique name for this Machine. If omitted, one is generated for you */ 44 | name?: string 45 | /** The target region. Omitting this param launches in the same region as your WireGuard peer connection (somewhere near you). */ 46 | region?: string 47 | skip_launch?: boolean 48 | skip_service_registration?: boolean 49 | } 50 | 51 | export interface CreateVolumeRequest { 52 | compute?: ApiMachineGuest 53 | encrypted?: boolean 54 | fstype?: string 55 | machines_only?: boolean 56 | name?: string 57 | region?: string 58 | require_unique_zone?: boolean 59 | size_gb?: number 60 | /** restore from snapshot */ 61 | snapshot_id?: string 62 | snapshot_retention?: number 63 | /** fork from remote volume */ 64 | source_volume_id?: string 65 | } 66 | 67 | export interface ErrorResponse { 68 | /** Deprecated */ 69 | details?: any 70 | error?: string 71 | status?: MainStatusCode 72 | } 73 | 74 | export interface ExtendVolumeRequest { 75 | size_gb?: number 76 | } 77 | 78 | export interface ExtendVolumeResponse { 79 | needs_restart?: boolean 80 | volume?: Volume 81 | } 82 | 83 | export interface ImageRef { 84 | digest?: string 85 | labels?: Record 86 | registry?: string 87 | repository?: string 88 | tag?: string 89 | } 90 | 91 | export interface Lease { 92 | /** Description or reason for the Lease. */ 93 | description?: string 94 | /** ExpiresAt is the unix timestamp in UTC to denote when the Lease will no longer be valid. */ 95 | expires_at?: number 96 | /** Nonce is the unique ID autogenerated and associated with the Lease. */ 97 | nonce?: string 98 | /** Owner is the user identifier which acquired the Lease. */ 99 | owner?: string 100 | /** Machine version */ 101 | version?: string 102 | } 103 | 104 | export interface ListApp { 105 | id?: string 106 | machine_count?: number 107 | name?: string 108 | network?: any 109 | } 110 | 111 | export interface ListAppsResponse { 112 | apps?: ListApp[] 113 | total_apps?: number 114 | } 115 | 116 | export interface ListenSocket { 117 | address?: string 118 | proto?: string 119 | } 120 | 121 | export interface Machine { 122 | checks?: CheckStatus[] 123 | config?: ApiMachineConfig 124 | created_at?: string 125 | events?: MachineEvent[] 126 | id?: string 127 | image_ref?: ImageRef 128 | /** InstanceID is unique for each version of the machine */ 129 | instance_id?: string 130 | name?: string 131 | /** Nonce is only every returned on machine creation if a lease_duration was provided. */ 132 | nonce?: string 133 | /** PrivateIP is the internal 6PN address of the machine. */ 134 | private_ip?: string 135 | region?: string 136 | state?: string 137 | updated_at?: string 138 | } 139 | 140 | export interface MachineEvent { 141 | id?: string 142 | request?: any 143 | source?: string 144 | status?: string 145 | timestamp?: number 146 | type?: string 147 | } 148 | 149 | export interface MachineExecRequest { 150 | /** Deprecated: use Command instead */ 151 | cmd?: string 152 | command?: string[] 153 | timeout?: number 154 | } 155 | 156 | export interface MachineVersion { 157 | user_config?: ApiMachineConfig 158 | version?: string 159 | } 160 | 161 | export interface Organization { 162 | name?: string 163 | slug?: string 164 | } 165 | 166 | export interface ProcessStat { 167 | command?: string 168 | cpu?: number 169 | directory?: string 170 | listen_sockets?: ListenSocket[] 171 | pid?: number 172 | rss?: number 173 | rtime?: number 174 | stime?: number 175 | } 176 | 177 | export interface SignalRequest { 178 | signal?: SignalRequestSignalEnum 179 | } 180 | 181 | export interface StopRequest { 182 | signal?: string 183 | timeout?: string 184 | } 185 | 186 | export interface UpdateMachineRequest { 187 | /** An object defining the Machine configuration */ 188 | config?: ApiMachineConfig 189 | current_version?: string 190 | lease_ttl?: number 191 | lsvd?: boolean 192 | /** Unique name for this Machine. If omitted, one is generated for you */ 193 | name?: string 194 | /** The target region. Omitting this param launches in the same region as your WireGuard peer connection (somewhere near you). */ 195 | region?: string 196 | skip_launch?: boolean 197 | skip_service_registration?: boolean 198 | } 199 | 200 | export interface UpdateVolumeRequest { 201 | snapshot_retention?: number 202 | } 203 | 204 | export interface Volume { 205 | attached_alloc_id?: string 206 | attached_machine_id?: string 207 | block_size?: number 208 | blocks?: number 209 | blocks_avail?: number 210 | blocks_free?: number 211 | created_at?: string 212 | encrypted?: boolean 213 | fstype?: string 214 | id?: string 215 | name?: string 216 | region?: string 217 | size_gb?: number 218 | snapshot_retention?: number 219 | state?: string 220 | zone?: string 221 | } 222 | 223 | export interface VolumeSnapshot { 224 | created_at?: string 225 | digest?: string 226 | id?: string 227 | size?: number 228 | status?: string 229 | } 230 | 231 | export interface ApiDNSConfig { 232 | skip_registration?: boolean 233 | } 234 | 235 | /** A file that will be written to the Machine. One of RawValue or SecretName must be set. */ 236 | export interface ApiFile { 237 | /** 238 | * GuestPath is the path on the machine where the file will be written and must be an absolute path. 239 | * For example: /full/path/to/file.json 240 | */ 241 | guest_path?: string 242 | /** The base64 encoded string of the file contents. */ 243 | raw_value?: string 244 | /** The name of the secret that contains the base64 encoded file contents. */ 245 | secret_name?: string 246 | } 247 | 248 | export interface ApiHTTPOptions { 249 | compress?: boolean 250 | h2_backend?: boolean 251 | response?: ApiHTTPResponseOptions 252 | } 253 | 254 | export interface ApiHTTPResponseOptions { 255 | headers?: Record 256 | } 257 | 258 | /** An optional object that defines one or more named checks. The key for each check is the check name. */ 259 | export interface ApiMachineCheck { 260 | /** The time to wait after a VM starts before checking its health */ 261 | grace_period?: string 262 | headers?: ApiMachineHTTPHeader[] 263 | /** The time between connectivity checks */ 264 | interval?: string 265 | /** For http checks, the HTTP method to use to when making the request */ 266 | method?: string 267 | /** For http checks, the path to send the request to */ 268 | path?: string 269 | /** The port to connect to, often the same as internal_port */ 270 | port?: number 271 | /** For http checks, whether to use http or https */ 272 | protocol?: string 273 | /** The maximum time a connection can take before being reported as failing its health check */ 274 | timeout?: string 275 | /** If the protocol is https, the hostname to use for TLS certificate validation */ 276 | tls_server_name?: string 277 | /** For http checks with https protocol, whether or not to verify the TLS certificate */ 278 | tls_skip_verify?: boolean 279 | /** tcp or http */ 280 | type?: string 281 | } 282 | 283 | export interface ApiMachineConfig { 284 | /** Optional boolean telling the Machine to destroy itself once it’s complete (default false) */ 285 | auto_destroy?: boolean 286 | checks?: Record 287 | /** Deprecated: use Service.Autostart instead */ 288 | disable_machine_autostart?: boolean 289 | dns?: ApiDNSConfig 290 | /** An object filled with key/value pairs to be set as environment variables */ 291 | env?: Record 292 | files?: ApiFile[] 293 | guest?: ApiMachineGuest 294 | /** The docker image to run */ 295 | image?: string 296 | init?: ApiMachineInit 297 | metadata?: Record 298 | metrics?: ApiMachineMetrics 299 | mounts?: ApiMachineMount[] 300 | processes?: ApiMachineProcess[] 301 | /** The Machine restart policy defines whether and how flyd restarts a Machine after its main process exits. See https://fly.io/docs/machines/guides-examples/machine-restart-policy/. */ 302 | restart?: ApiMachineRestart 303 | schedule?: string 304 | services?: ApiMachineService[] 305 | /** Deprecated: use Guest instead */ 306 | size?: string 307 | /** 308 | * Standbys enable a machine to be a standby for another. In the event of a hardware failure, 309 | * the standby machine will be started. 310 | */ 311 | standbys?: string[] 312 | statics?: ApiStatic[] 313 | stop_config?: ApiStopConfig 314 | } 315 | 316 | export interface ApiMachineGuest { 317 | cpu_kind?: string 318 | cpus?: number 319 | gpu_kind?: string 320 | host_dedication_id?: string 321 | kernel_args?: string[] 322 | memory_mb?: number 323 | } 324 | 325 | /** For http checks, an array of objects with string field Name and array of strings field Values. The key/value pairs specify header and header values that will get passed with the check call. */ 326 | export interface ApiMachineHTTPHeader { 327 | /** The header name */ 328 | name?: string 329 | /** The header value */ 330 | values?: string[] 331 | } 332 | 333 | export interface ApiMachineInit { 334 | cmd?: string[] 335 | entrypoint?: string[] 336 | exec?: string[] 337 | kernel_args?: string[] 338 | swap_size_mb?: number 339 | tty?: boolean 340 | } 341 | 342 | export interface ApiMachineMetrics { 343 | path?: string 344 | port?: number 345 | } 346 | 347 | export interface ApiMachineMount { 348 | add_size_gb?: number 349 | encrypted?: boolean 350 | extend_threshold_percent?: number 351 | name?: string 352 | path?: string 353 | size_gb?: number 354 | size_gb_limit?: number 355 | volume?: string 356 | } 357 | 358 | export interface ApiMachinePort { 359 | end_port?: number 360 | force_https?: boolean 361 | handlers?: string[] 362 | http_options?: ApiHTTPOptions 363 | port?: number 364 | proxy_proto_options?: ApiProxyProtoOptions 365 | start_port?: number 366 | tls_options?: ApiTLSOptions 367 | } 368 | 369 | export interface ApiMachineProcess { 370 | cmd?: string[] 371 | entrypoint?: string[] 372 | env?: Record 373 | exec?: string[] 374 | user?: string 375 | } 376 | 377 | /** The Machine restart policy defines whether and how flyd restarts a Machine after its main process exits. See https://fly.io/docs/machines/guides-examples/machine-restart-policy/. */ 378 | export interface ApiMachineRestart { 379 | /** When policy is on-failure, the maximum number of times to attempt to restart the Machine before letting it stop. */ 380 | max_retries?: number 381 | /** 382 | * * no - Never try to restart a Machine automatically when its main process exits, whether that’s on purpose or on a crash. 383 | * * always - Always restart a Machine automatically and never let it enter a stopped state, even when the main process exits cleanly. 384 | * * on-failure - Try up to MaxRetries times to automatically restart the Machine if it exits with a non-zero exit code. Default when no explicit policy is set, and for Machines with schedules. 385 | */ 386 | policy?: ApiMachineRestartPolicyEnum 387 | } 388 | 389 | export interface ApiMachineService { 390 | autostart?: boolean 391 | autostop?: boolean 392 | checks?: ApiMachineCheck[] 393 | concurrency?: ApiMachineServiceConcurrency 394 | force_instance_description?: string 395 | force_instance_key?: string 396 | internal_port?: number 397 | min_machines_running?: number 398 | ports?: ApiMachinePort[] 399 | protocol?: string 400 | } 401 | 402 | export interface ApiMachineServiceConcurrency { 403 | hard_limit?: number 404 | soft_limit?: number 405 | type?: string 406 | } 407 | 408 | export interface ApiProxyProtoOptions { 409 | version?: string 410 | } 411 | 412 | export interface ApiStatic { 413 | guest_path: string 414 | url_prefix: string 415 | } 416 | 417 | export interface ApiStopConfig { 418 | signal?: string 419 | timeout?: string 420 | } 421 | 422 | export interface ApiTLSOptions { 423 | alpn?: string[] 424 | default_self_signed?: boolean 425 | versions?: string[] 426 | } 427 | 428 | export enum MainStatusCode { 429 | Unknown = 'unknown', 430 | CapacityErr = 'insufficient_capacity', 431 | } 432 | 433 | export enum SignalRequestSignalEnum { 434 | SIGABRT = 'SIGABRT', 435 | SIGALRM = 'SIGALRM', 436 | SIGFPE = 'SIGFPE', 437 | SIGHUP = 'SIGHUP', 438 | SIGILL = 'SIGILL', 439 | SIGINT = 'SIGINT', 440 | SIGKILL = 'SIGKILL', 441 | SIGPIPE = 'SIGPIPE', 442 | SIGQUIT = 'SIGQUIT', 443 | SIGSEGV = 'SIGSEGV', 444 | SIGTERM = 'SIGTERM', 445 | SIGTRAP = 'SIGTRAP', 446 | SIGUSR1 = 'SIGUSR1', 447 | } 448 | 449 | /** 450 | * * no - Never try to restart a Machine automatically when its main process exits, whether that’s on purpose or on a crash. 451 | * * always - Always restart a Machine automatically and never let it enter a stopped state, even when the main process exits cleanly. 452 | * * on-failure - Try up to MaxRetries times to automatically restart the Machine if it exits with a non-zero exit code. Default when no explicit policy is set, and for Machines with schedules. 453 | */ 454 | export enum ApiMachineRestartPolicyEnum { 455 | No = 'no', 456 | Always = 'always', 457 | OnFailure = 'on-failure', 458 | } 459 | 460 | export interface AppsListParams { 461 | /** The org slug, or 'personal', to filter apps */ 462 | org_slug: string 463 | } 464 | 465 | export interface MachinesListParams { 466 | /** Include deleted machines */ 467 | include_deleted?: boolean 468 | /** Region filter */ 469 | region?: string 470 | /** Fly App Name */ 471 | appName: string 472 | } 473 | 474 | export interface MachinesListProcessesParams { 475 | /** Sort by */ 476 | sort_by?: string 477 | /** Order */ 478 | order?: string 479 | /** Fly App Name */ 480 | appName: string 481 | /** Machine ID */ 482 | machineId: string 483 | } 484 | 485 | export interface MachinesRestartParams { 486 | /** Restart timeout as a Go duration string or number of seconds */ 487 | timeout?: string 488 | /** Fly App Name */ 489 | appName: string 490 | /** Machine ID */ 491 | machineId: string 492 | } 493 | 494 | export interface MachinesWaitParams { 495 | /** instance? version? TODO */ 496 | instance_id?: string 497 | /** wait timeout. default 60s */ 498 | timeout?: number 499 | /** desired state */ 500 | state?: StateEnum 501 | /** Fly App Name */ 502 | appName: string 503 | /** Machine ID */ 504 | machineId: string 505 | } 506 | 507 | /** desired state */ 508 | export enum StateEnum { 509 | Started = 'started', 510 | Stopped = 'stopped', 511 | Destroyed = 'destroyed', 512 | } 513 | 514 | /** desired state */ 515 | export enum MachinesWaitParams1StateEnum { 516 | Started = 'started', 517 | Stopped = 'stopped', 518 | Destroyed = 'destroyed', 519 | } 520 | -------------------------------------------------------------------------------- /src/lib/volume.ts: -------------------------------------------------------------------------------- 1 | import Client from '../client' 2 | import { CreateVolumeRequest as ApiCreateVolumeRequest } from './types' 3 | 4 | export type ListVolumesRequest = string 5 | 6 | // Ref: https://github.com/superfly/flyctl/blob/master/api/volume_types.go#L23 7 | export interface CreateVolumeRequest extends ApiCreateVolumeRequest { 8 | app_name: string 9 | name: string 10 | region: string 11 | } 12 | 13 | // Ref: https://github.com/superfly/flyctl/blob/master/api/volume_types.go#L5 14 | export interface VolumeResponse { 15 | id: string 16 | name: string 17 | state: string 18 | size_gb: number 19 | region: string 20 | zone: string 21 | encrypted: boolean 22 | attached_machine_id: string | null 23 | attached_alloc_id: string | null 24 | created_at: string 25 | blocks: number 26 | block_size: number 27 | blocks_free: number 28 | blocks_avail: number 29 | fstype: string 30 | host_dedication_key: string 31 | } 32 | 33 | export interface GetVolumeRequest { 34 | app_name: string 35 | volume_id: string 36 | } 37 | 38 | export type DeleteVolumeRequest = GetVolumeRequest 39 | 40 | export interface ExtendVolumeRequest extends GetVolumeRequest { 41 | size_gb: number 42 | } 43 | 44 | export interface ExtendVolumeResponse { 45 | needs_restart: boolean 46 | volume: VolumeResponse 47 | } 48 | 49 | export type ListSnapshotsRequest = GetVolumeRequest 50 | 51 | export interface SnapshotResponse { 52 | id: string 53 | created_at: string 54 | digest: string 55 | size: number 56 | } 57 | 58 | export class Volume { 59 | private client: Client 60 | 61 | constructor(client: Client) { 62 | this.client = client 63 | } 64 | 65 | async listVolumes(app_name: ListVolumesRequest): Promise { 66 | const path = `apps/${app_name}/volumes` 67 | return await this.client.restOrThrow(path) 68 | } 69 | 70 | async getVolume(payload: GetVolumeRequest): Promise { 71 | const { app_name, volume_id } = payload 72 | const path = `apps/${app_name}/volumes/${volume_id}` 73 | return await this.client.restOrThrow(path) 74 | } 75 | 76 | async createVolume(payload: CreateVolumeRequest): Promise { 77 | const { app_name, ...body } = payload 78 | const path = `apps/${app_name}/volumes` 79 | return await this.client.restOrThrow(path, 'POST', body) 80 | } 81 | 82 | async deleteVolume(payload: DeleteVolumeRequest): Promise { 83 | const { app_name, volume_id } = payload 84 | const path = `apps/${app_name}/volumes/${volume_id}` 85 | return await this.client.restOrThrow(path, 'DELETE') 86 | } 87 | 88 | async extendVolume( 89 | payload: ExtendVolumeRequest 90 | ): Promise { 91 | const { app_name, volume_id, ...body } = payload 92 | const path = `apps/${app_name}/volumes/${volume_id}/extend` 93 | return await this.client.restOrThrow(path, 'PUT', body) 94 | } 95 | 96 | async listSnapshots( 97 | payload: ListSnapshotsRequest 98 | ): Promise { 99 | const { app_name, volume_id } = payload 100 | const path = `apps/${app_name}/volumes/${volume_id}/snapshots` 101 | return await this.client.restOrThrow(path) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Client from './client' 2 | 3 | function createClient(API_TOKEN: string): Client { 4 | return new Client(API_TOKEN) 5 | } 6 | 7 | export { createClient } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | "outDir": "./dist", /* Redirect output structure to the directory. */ 8 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 9 | "strict": true, /* Enable all strict type-checking options. */ 10 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 12 | }, 13 | "include": ["src"] 14 | } 15 | --------------------------------------------------------------------------------