@volter/twin-fly 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +125 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +78 -0
- package/dist/src/fly-budget.d.ts +41 -0
- package/dist/src/fly-budget.js +94 -0
- package/dist/src/fly-capabilities.d.ts +4 -0
- package/dist/src/fly-capabilities.js +1212 -0
- package/dist/src/fly-conformance.d.ts +8 -0
- package/dist/src/fly-conformance.js +142 -0
- package/dist/src/fly-connector.d.ts +75 -0
- package/dist/src/fly-connector.js +277 -0
- package/dist/src/fly-docker.d.ts +121 -0
- package/dist/src/fly-docker.js +509 -0
- package/dist/src/fly-machines.d.ts +152 -0
- package/dist/src/fly-machines.js +293 -0
- package/dist/src/fly-runtime-choice.d.ts +12 -0
- package/dist/src/fly-runtime-choice.js +21 -0
- package/dist/src/fly-server.d.ts +31 -0
- package/dist/src/fly-server.js +116 -0
- package/dist/src/fly-twin.d.ts +25 -0
- package/dist/src/fly-twin.js +1272 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +117 -0
- package/package.json +51 -0
- package/src/cli.ts +77 -0
- package/src/fly-budget.ts +121 -0
- package/src/fly-capabilities.ts +1261 -0
- package/src/fly-conformance.ts +168 -0
- package/src/fly-connector.ts +278 -0
- package/src/fly-docker.ts +539 -0
- package/src/fly-machines.ts +383 -0
- package/src/fly-runtime-choice.ts +38 -0
- package/src/fly-server.ts +116 -0
- package/src/fly-twin.ts +1218 -0
- package/src/index.ts +171 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @volter/twin-fly
|
|
2
|
+
|
|
3
|
+
A local, stateful, vendor-faithful twin of **Fly.io's Machines API** (`api.machines.dev/v1`) —
|
|
4
|
+
with a twist that makes it the repo's first **real-execution-plane compute twin**: the control
|
|
5
|
+
plane is twinned, and the execution plane is **real local Docker**.
|
|
6
|
+
|
|
7
|
+
- **Control plane (twinned, offline):** apps / machines / volumes / app-secrets CRUD, the machine
|
|
8
|
+
lifecycle (`created → started → stopped → suspended → destroyed`) with a ledgered
|
|
9
|
+
`MachineEvent` stream, `wait`, leases (nonce header, wrap shape), machine metadata, machine
|
|
10
|
+
versions — all kernel event-sourced (`@volter/world-core`), Bearer-auth faked-but-enforced, unmodeled
|
|
11
|
+
operations failing like the vendor.
|
|
12
|
+
- **Execution plane (REAL, optional):** when a machine is created/started through the twin with
|
|
13
|
+
local execution (`world-fly serve --local-execution`), `config.image` is **actually run** as a local
|
|
14
|
+
container — `config.env` (plus Fly's documented `FLY_*` runtime environment) becomes the
|
|
15
|
+
container env, every service `internal_port` is published on an ephemeral loopback port
|
|
16
|
+
(ledgered as the disclosed twin-only `twin_local_ports` field), `config.mounts` become named
|
|
17
|
+
docker volumes, `stop` is `docker stop`, destroy is `docker rm -f`, and `exec` is `docker exec`.
|
|
18
|
+
**`start` on a stopped machine RECREATES the container from `config.image` — it is never
|
|
19
|
+
`docker start`** — because that is what the vendor does: "Stopped Machines that are restarted
|
|
20
|
+
are completely reset to their original state so that they start clean on the next run"
|
|
21
|
+
(fly.io/docs/machines/api). Only volume-mounted data survives a stop→start: the named
|
|
22
|
+
`fly-twin-<volume id>` volumes are re-mounted onto a clean rootfs, so an app that keeps state
|
|
23
|
+
outside a mount breaks here exactly as it would on real Fly, instead of passing local rehearsal
|
|
24
|
+
and failing on the first real stop/start cycle. A restart is the same stop→start reset. The
|
|
25
|
+
consequence is disclosed: a recreated container draws NEW ephemeral loopback ports, so
|
|
26
|
+
`twin_local_ports` changes across a stop→start (Fly's own service ports are logical and do not).
|
|
27
|
+
This is the supabase doctrine ("twin the control plane, run the real engine") applied to
|
|
28
|
+
compute: an app that speaks Fly's API exclusively gets **real running machines** during local
|
|
29
|
+
dev, and machine spawn/reap flows can be rehearsed entirely offline.
|
|
30
|
+
|
|
31
|
+
Admission measures the substrate it is actually on before it runs anything: the docker root when
|
|
32
|
+
it is host-visible (Linux), and otherwise the growable disk of the hidden Linux machine — a Colima
|
|
33
|
+
profile, OrbStack's sparse data image, or Docker Desktop's data volume. A substrate it cannot
|
|
34
|
+
measure is REFUSED, never assumed.
|
|
35
|
+
|
|
36
|
+
Docker is **never required**: the default execution plane is the pure-ledger `virtual` runtime,
|
|
37
|
+
every capability `verify()` and the whole gate run without a Docker daemon (the seam is exercised
|
|
38
|
+
with injected fakes), and the real-Docker proof lives in `fly-docker.integration.test.ts`, which
|
|
39
|
+
**skips loudly** when `docker info` fails.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# serve the Machines API twin (offline, virtual execution plane)
|
|
43
|
+
bun packages/twin/fly/src/cli.ts serve --port 4280
|
|
44
|
+
|
|
45
|
+
# serve with the REAL local-Docker execution plane (fails loudly if the daemon is unreachable)
|
|
46
|
+
bun packages/twin/fly/src/cli.ts serve --port 4280 --local-execution
|
|
47
|
+
|
|
48
|
+
# ...on a small disk: admission keeps 2048 MiB of writable storage free beyond each machine's own
|
|
49
|
+
# need; the reserve is configuration, so a tight host lowers it rather than being refused
|
|
50
|
+
bun packages/twin/fly/src/cli.ts serve --port 4280 --local-execution --storage-reserve-mib 256
|
|
51
|
+
|
|
52
|
+
# then point any Machines API client at it, exactly as Fly's docs describe:
|
|
53
|
+
export FLY_API_HOSTNAME="http://127.0.0.1:4280"
|
|
54
|
+
export FLY_API_TOKEN="anything-nonempty" # auth is faked-but-enforced (empty/missing -> 401)
|
|
55
|
+
curl "$FLY_API_HOSTNAME/v1/apps" -H "Authorization: Bearer $FLY_API_TOKEN" \
|
|
56
|
+
-X POST -d '{"app_name":"demo","org_slug":"personal"}'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Grounding
|
|
60
|
+
|
|
61
|
+
Surface + schemas from Fly's **first-party OpenAPI** (live-fetched
|
|
62
|
+
`docs.machines.dev/swagger/doc.json`, "Machines API 1.0", 68 paths) cross-checked against
|
|
63
|
+
**superfly/fly-go** — flyctl's own client — with fly-go winning every disagreement (the lease
|
|
64
|
+
response wrap `{status,data}`, the `fly-machine-lease-nonce` header, bare-array volume lists, the
|
|
65
|
+
`{previous_state}` start reply). Hosts, response-code table and rate limits from
|
|
66
|
+
`fly.io/docs/machines/api/working-with-machines-api` (live-fetched). No live account/token was
|
|
67
|
+
available to probe negative-path bodies — every unverifiable error string is marked
|
|
68
|
+
`⚠ doc-unverified` in source and recorded in `spec-sources.json`. See that census entry for the
|
|
69
|
+
full list of modeling choices.
|
|
70
|
+
|
|
71
|
+
## Coverage
|
|
72
|
+
|
|
73
|
+
Honest and partial — the manifest (`src/fly-capabilities.ts`) enumerates the **real** Machines
|
|
74
|
+
API surface top-down from the OpenAPI's tags (Apps, Machines, Volumes, Secrets, TLS Certificates,
|
|
75
|
+
Tokens, Organizations, Platform, Postgres Clusters) and the current done/todo split is in the
|
|
76
|
+
generated repo tables (`../../../docs/contributing/conformance.md`). Modeled today: apps CRUD, the full machine lifecycle +
|
|
77
|
+
events + wait + leases + metadata + exec seam, volumes (create/list/get/update/delete/extend/
|
|
78
|
+
snapshots, attach semantics), app secrets, connector pull, and the execution-plane seam behaviors.
|
|
79
|
+
Restart policy is MODELED (fly.MachineRestart, done/core): a non-zero container exit under the
|
|
80
|
+
default `on-failure` policy relaunches the container on the next read (bounded by
|
|
81
|
+
`max_retries`, defaulting to 10 when `config.restart` is absent — a disclosed twin choice, the
|
|
82
|
+
vendor publishes no default), `always` relaunches even clean exits, `no` folds to stopped; the
|
|
83
|
+
exit code is ledgered into the event's fly-go-shaped request payload. `DELETE /v1/apps/{name}`
|
|
84
|
+
destroys the app's machines, VOLUMES and SECRETS with it (real Fly semantics: a recreated name
|
|
85
|
+
is a brand-new, empty app). Enumerated as todos: machine ps/memory endpoints, certificates,
|
|
86
|
+
ip_assignments, secretkeys, tokens, org-wide listings, platform regions/placements, Managed
|
|
87
|
+
Postgres, auto-destroy/schedule semantics, connector push.
|
|
88
|
+
|
|
89
|
+
The local execution plane is Docker containers — genuine compute (env/ports/volumes/exec/exit are
|
|
90
|
+
all real) behind a control-plane surface identical to Fly's, rather than hardware-isolated microVMs.
|
|
91
|
+
`private_ip` is ledgered faithfully in the `fdaa:…` 6PN shape but nothing routes it locally;
|
|
92
|
+
published loopback ports are the local reachability story. Certificate CRUD *metadata* and the
|
|
93
|
+
token endpoints stay on the todo backlog.
|
|
94
|
+
|
|
95
|
+
**Binding, live-verified 2026-09-03:** on real Fly a machine is reachable ONLY at that IPv6 address,
|
|
96
|
+
so a service bound to `0.0.0.0` answers on the twin's published IPv4 loopback ports and is
|
|
97
|
+
unreachable on Fly. Bind `::` (dual-stack) to pass both.
|
|
98
|
+
|
|
99
|
+
Twin-only additive fields, disclosed: `twin_runtime` (which execution plane ran the machine:
|
|
100
|
+
`virtual` / `docker` / `real-fly` for pulled machines) and `twin_local_ports` (the published
|
|
101
|
+
loopback ports) on machine views. Neither replaces a real field.
|
|
102
|
+
|
|
103
|
+
### No UI mirror
|
|
104
|
+
|
|
105
|
+
Fly.io is an API-first vendor: when someone does Fly's core job they **write code / drive
|
|
106
|
+
flyctl** — deploys, machine orchestration, CI. The fly.io dashboard is incidental tooling for
|
|
107
|
+
billing, tokens and log viewing, not where the work happens (Fly's own docs route every Machines
|
|
108
|
+
workflow through the API/flyctl). Per ../../../docs/contributing/architecture.md C1b the twin therefore ships **no mirror
|
|
109
|
+
and no UI capabilities**; coverage is API + connector + the execution plane.
|
|
110
|
+
|
|
111
|
+
## Connector + rate budget
|
|
112
|
+
|
|
113
|
+
`syncFlyFromReal(execute, { orgSlug, root })` pulls the real account's apps → machines + volumes
|
|
114
|
+
over an **injected executor** and folds them through the kernel's the kernel observation path (idempotent re-pull;
|
|
115
|
+
a refused pull **throws** rather than folding an empty account). Push is a deliberate, filed todo:
|
|
116
|
+
pushing a local machine-create provisions real **billable** compute, so it waits for an
|
|
117
|
+
operator-confirmed plan.
|
|
118
|
+
|
|
119
|
+
Every live call goes through `liveFlyExecute(token)` — the one place a real
|
|
120
|
+
`api.machines.dev` request is issued — guarded by the kernel `RateBudget` (`src/fly-budget.ts`):
|
|
121
|
+
Fly publishes per-action per-machine limits (1 req/s per action, burst 3; Get Machine 5 req/s,
|
|
122
|
+
burst 10; app deletions 100/min) but **no account-wide scalar**, so the ceiling is pinned to the
|
|
123
|
+
kernel fallback (60 units/60 s, reads at weight 2 = 30 calls/min) with provisioning POSTs priced
|
|
124
|
+
at 3 (a runaway create loop stops at 20/min). Fail-closed, persisted across processes, cooldown
|
|
125
|
+
on 429 — proven against a counting fake fetch, offline (`src/fly-budget.test.ts`).
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// world-fly CLI: serve the Machines API twin, or run conformance. There is no `mirror` command —
|
|
3
|
+
// Fly is an API-first vendor with no product UI to mirror (see README `### No UI mirror`).
|
|
4
|
+
//
|
|
5
|
+
// The EXECUTION PLANE is explicit: `serve` defaults to the pure-ledger VIRTUAL runtime (offline,
|
|
6
|
+
// nothing runs); `serve --local-execution` runs real local Machines and FAILS LOUDLY if local
|
|
7
|
+
// execution is unavailable — never a silent downgrade a caller could mistake for real machines.
|
|
8
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
9
|
+
import { createFlyTwinServer } from "./fly-server.js";
|
|
10
|
+
import { resolveFlyRuntime } from "./fly-runtime-choice.js";
|
|
11
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
12
|
+
/** A malformed port must FAIL, not silently become an ephemeral one (the smtp `--port 1O25`
|
|
13
|
+
* lesson). `0` is accepted and means "ephemeral". */
|
|
14
|
+
function portOption(flag) {
|
|
15
|
+
const raw = optionValue(rest, flag);
|
|
16
|
+
if (raw === undefined || raw === '')
|
|
17
|
+
return undefined;
|
|
18
|
+
const value = Number(raw);
|
|
19
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
20
|
+
process.stderr.write(`world-fly: ${flag} must be an integer 0-65535 (got ${JSON.stringify(raw)})\n`);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
return value === 0 ? undefined : value;
|
|
24
|
+
}
|
|
25
|
+
/** A non-negative whole number of MiB, or undefined when the flag is absent. Malformed FAILS
|
|
26
|
+
* (the `--port 1O25` lesson) — a World that asked for a different admission floor and silently
|
|
27
|
+
* got the default would be told nothing. */
|
|
28
|
+
function reserveOption(flag) {
|
|
29
|
+
const raw = optionValue(rest, flag);
|
|
30
|
+
if (raw === undefined || raw === '')
|
|
31
|
+
return undefined;
|
|
32
|
+
const value = Number(raw);
|
|
33
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
34
|
+
process.stderr.write(`world-fly: ${flag} must be a non-negative integer number of MiB (got ${JSON.stringify(raw)})\n`);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
const port = portOption('--port');
|
|
40
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
41
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
42
|
+
const localExecution = hasFlag(rest, '--local-execution') || hasFlag(rest, '--docker');
|
|
43
|
+
// The writable-storage RESERVE admission keeps free beyond a machine's own need — a mechanism
|
|
44
|
+
// value the World configures (default 2048 MiB), never a constant compiled into the runtime.
|
|
45
|
+
const storageReserveMiB = reserveOption('--storage-reserve-mib');
|
|
46
|
+
if (cmd === 'serve') {
|
|
47
|
+
const server = await createFlyTwinServer({
|
|
48
|
+
cleanupOwnedOnStart: process.env.VOLTER_WORLD_NAME !== undefined,
|
|
49
|
+
readOnly,
|
|
50
|
+
runtime: await resolveFlyRuntime(localExecution ? 'docker' : 'virtual', root, {
|
|
51
|
+
...(storageReserveMiB !== undefined ? { storageReserveMiB } : {}),
|
|
52
|
+
}),
|
|
53
|
+
...(root ? { root } : {}),
|
|
54
|
+
...(port ? { port } : {}),
|
|
55
|
+
});
|
|
56
|
+
process.stdout.write(`machine provider${readOnly ? ' [read-only]' : ''} ready on http://127.0.0.1:${server.port}\n`);
|
|
57
|
+
const signal = await new Promise((resolveSignal) => {
|
|
58
|
+
process.once('SIGINT', () => resolveSignal('SIGINT'));
|
|
59
|
+
process.once('SIGTERM', () => resolveSignal('SIGTERM'));
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
await server.stop();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
process.stderr.write(`machine provider: ${signal} cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (cmd === 'conformance') {
|
|
70
|
+
const { checkFlyConformance } = await import("./fly-conformance.js"); // lazy — dev-only
|
|
71
|
+
const report = await checkFlyConformance();
|
|
72
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
73
|
+
if (!report.ok)
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
process.stdout.write('Usage: world-fly serve|conformance [--port N] [--root DIR] [--read-only] [--local-execution] [--storage-reserve-mib N]\n');
|
|
78
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
|
|
2
|
+
/** Rolling window, in ms (the kernel fallback's window — a window may never be shorter). */
|
|
3
|
+
export declare const FLY_BUDGET_WINDOW_MS = 60000;
|
|
4
|
+
/** Weighted units allowed inside one window. Pinned to the kernel fallback — see the header. */
|
|
5
|
+
export declare const FLY_BUDGET_CEILING = 60;
|
|
6
|
+
/** Seconds. A `Retry-After` above this means the credential is throttled hard — fail loudly. */
|
|
7
|
+
export declare const FLY_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
8
|
+
/** Per-call cost. `create` is machine/volume/app creation (billable provisioning); `other` is
|
|
9
|
+
* everything else (reads and lifecycle actions). */
|
|
10
|
+
export declare const FLY_CALL_WEIGHTS: {
|
|
11
|
+
readonly create: 3;
|
|
12
|
+
readonly other: 2;
|
|
13
|
+
};
|
|
14
|
+
/** THE PACK'S DECLARATION — pure data, the only Fly-specific thing in the whole budget.
|
|
15
|
+
* Calls are priced by a `METHOD /path` key (see flyCallWeight); the create rule matches the
|
|
16
|
+
* three provisioning POSTs (apps, machines, volumes collection endpoints). */
|
|
17
|
+
export declare const FLY_RATE_BUDGET: RateBudgetDeclaration;
|
|
18
|
+
/** Price one Machines API call by `METHOD /path` (query string stripped by the caller). */
|
|
19
|
+
export declare function flyCallWeight(method: string, path: string): number;
|
|
20
|
+
/** Where Fly's ledger lives. Token-keyed and cwd-independent by default (Fly limits per
|
|
21
|
+
* identifier under one account/token, so a cwd-scoped ledger would hand the same token a fresh
|
|
22
|
+
* allowance in every checkout, worktree and CI matrix leg); pass `root` for world-scoped
|
|
23
|
+
* accounting. */
|
|
24
|
+
export declare function flyBudgetPath(opts?: {
|
|
25
|
+
root?: string;
|
|
26
|
+
token?: string;
|
|
27
|
+
} | string): string;
|
|
28
|
+
/** Construction options for Fly's budget. The vendor is fixed; everything else may only TIGHTEN. */
|
|
29
|
+
export type FlyBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
30
|
+
/**
|
|
31
|
+
* Fly's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
32
|
+
* not an alias, so `budget instanceof FlyBudget` means "a budget that accounts against this
|
|
33
|
+
* vendor's ledger under this vendor's ceiling".
|
|
34
|
+
*/
|
|
35
|
+
export declare class FlyBudget extends RateBudget {
|
|
36
|
+
constructor(opts?: FlyBudgetOptions);
|
|
37
|
+
}
|
|
38
|
+
export type { RateBudgetErrorKind as FlyBudgetErrorKind } from '@volter/world-core';
|
|
39
|
+
export { RateBudgetError as FlyBudgetError } from '@volter/world-core';
|
|
40
|
+
export type FlyBudgetReservation = RateBudgetReservation;
|
|
41
|
+
export type FlyBudgetSnapshot = RateBudgetSnapshot;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Fly.io's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the vendor-bound
|
|
2
|
+
// bindings `liveFlyExecute` (fly-connector.ts) routes every live Machines API call through. The
|
|
3
|
+
// MECHANISM — the durable token-keyed ledger, the rolling window, reserve-under-lock, the
|
|
4
|
+
// `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger — lives ONCE in the vendor-agnostic
|
|
5
|
+
// kernel (`@volter/world-core` → rateBudget.ts). This module is modeled on supabase-budget.ts (the
|
|
6
|
+
// same shape: the pack builds its own HTTP executor, so the guard sits inside the one function
|
|
7
|
+
// that issues a live request).
|
|
8
|
+
//
|
|
9
|
+
// ── WHAT FLY ACTUALLY PUBLISHES (live-fetched 2026-08-20,
|
|
10
|
+
// fly.io/docs/machines/api/working-with-machines-api "Rate Limits") ───────────────────────
|
|
11
|
+
// "Machines API rate limits apply per-action, per-machine and are scoped per identifier
|
|
12
|
+
// (Machine ID or App ID). The limit is 1 request, per second, per action — with a short-term
|
|
13
|
+
// burst limit up to 3 req/s, per action. This applies to all actions except Get Machine which
|
|
14
|
+
// is 5 req/s, with a short-term burst limit up to 10 req/s. Additionally, app deletions are
|
|
15
|
+
// limited to 100 per minute."
|
|
16
|
+
//
|
|
17
|
+
// Those are PER-ACTION PER-RESOURCE limits; Fly publishes NO account-wide scalar, and this
|
|
18
|
+
// ledger is account-wide (one ledger per token) — a shape mismatch this declaration resolves in
|
|
19
|
+
// the tight direction: the ceiling is pinned to the kernel's conservative fallback
|
|
20
|
+
// (DEFAULT_RATE_BUDGET: 60 weighted units / 60s at weight 2 = 30 calls/minute — a rate the
|
|
21
|
+
// vendor's own per-action allowance of 60/min/action trivially admits), rather than multiplying
|
|
22
|
+
// per-action numbers into an invented account-wide figure. Machine CREATION is priced heavier
|
|
23
|
+
// (weight 3) than reads/actions — creation provisions real billable infrastructure and is the
|
|
24
|
+
// call a runaway loop repeats; pricing it at 3 means a creation-only loop is stopped after 20
|
|
25
|
+
// creates in a minute, tighter than Fly's own 60/min-per-app. Same-burst-as-fallback, so no
|
|
26
|
+
// VENDOR_BURST_ANCHOR entry is needed (the anchor binds only out-bursting declarations).
|
|
27
|
+
import { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
|
|
28
|
+
const VENDOR = 'fly';
|
|
29
|
+
/** Rolling window, in ms (the kernel fallback's window — a window may never be shorter). */
|
|
30
|
+
export const FLY_BUDGET_WINDOW_MS = 60_000;
|
|
31
|
+
/** Weighted units allowed inside one window. Pinned to the kernel fallback — see the header. */
|
|
32
|
+
export const FLY_BUDGET_CEILING = 60;
|
|
33
|
+
/** Seconds. A `Retry-After` above this means the credential is throttled hard — fail loudly. */
|
|
34
|
+
export const FLY_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
35
|
+
/** Per-call cost. `create` is machine/volume/app creation (billable provisioning); `other` is
|
|
36
|
+
* everything else (reads and lifecycle actions). */
|
|
37
|
+
export const FLY_CALL_WEIGHTS = {
|
|
38
|
+
create: 3,
|
|
39
|
+
other: 2,
|
|
40
|
+
};
|
|
41
|
+
/** THE PACK'S DECLARATION — pure data, the only Fly-specific thing in the whole budget.
|
|
42
|
+
* Calls are priced by a `METHOD /path` key (see flyCallWeight); the create rule matches the
|
|
43
|
+
* three provisioning POSTs (apps, machines, volumes collection endpoints). */
|
|
44
|
+
export const FLY_RATE_BUDGET = {
|
|
45
|
+
windowMs: FLY_BUDGET_WINDOW_MS,
|
|
46
|
+
ceiling: FLY_BUDGET_CEILING,
|
|
47
|
+
defaultWeight: FLY_CALL_WEIGHTS.other,
|
|
48
|
+
maxRetryAfterSeconds: FLY_BUDGET_MAX_RETRY_AFTER_S,
|
|
49
|
+
// First-match-wins. POST on a collection endpoint (…/apps, …/machines, …/volumes) is a
|
|
50
|
+
// billable CREATE; the $ anchors keep one-resource POSTs (…/machines/{id}, lifecycle actions)
|
|
51
|
+
// at the default price.
|
|
52
|
+
rules: [
|
|
53
|
+
{ match: '^POST /v1/apps$', weight: FLY_CALL_WEIGHTS.create },
|
|
54
|
+
{ match: '^POST /v1/apps/[^/]+/machines$', weight: FLY_CALL_WEIGHTS.create },
|
|
55
|
+
{ match: '^POST /v1/apps/[^/]+/volumes$', weight: FLY_CALL_WEIGHTS.create },
|
|
56
|
+
],
|
|
57
|
+
reason: 'Fly publishes PER-ACTION, PER-RESOURCE Machines API limits (fly.io/docs/machines/api/working-with-machines-api, ' +
|
|
58
|
+
'live-fetched 2026-08-20): 1 req/s per action per machine with short bursts to 3 req/s; Get Machine 5 req/s ' +
|
|
59
|
+
'bursting to 10; app deletions 100/min. There is NO published account-wide scalar, and this ledger is ' +
|
|
60
|
+
'account-wide (per token), so the ceiling is pinned to the kernel fallback (60 units / 60s at weight 2 = ' +
|
|
61
|
+
'30 calls/min — a rate the per-action allowance of 60/min/action trivially admits) rather than multiplying ' +
|
|
62
|
+
'per-action numbers into an invented account figure. Creation POSTs (apps/machines/volumes) cost 3: they ' +
|
|
63
|
+
'provision real billable infrastructure, so a runaway create loop is refused after 20/min, tighter than ' +
|
|
64
|
+
"Fly's own 60 creates/min/app. Window and burst equal the fallback's, so nothing here is more permissive " +
|
|
65
|
+
'than an undeclared vendor already gets.',
|
|
66
|
+
};
|
|
67
|
+
// Declared at module load, so merely importing this module (which fly-connector.ts does) is
|
|
68
|
+
// enough to arm the real ceiling.
|
|
69
|
+
declareRateBudget(VENDOR, FLY_RATE_BUDGET);
|
|
70
|
+
/** Price one Machines API call by `METHOD /path` (query string stripped by the caller). */
|
|
71
|
+
export function flyCallWeight(method, path) {
|
|
72
|
+
return rateBudgetWeight(VENDOR, `${method.toUpperCase()} ${path}`);
|
|
73
|
+
}
|
|
74
|
+
/** Where Fly's ledger lives. Token-keyed and cwd-independent by default (Fly limits per
|
|
75
|
+
* identifier under one account/token, so a cwd-scoped ledger would hand the same token a fresh
|
|
76
|
+
* allowance in every checkout, worktree and CI matrix leg); pass `root` for world-scoped
|
|
77
|
+
* accounting. */
|
|
78
|
+
export function flyBudgetPath(opts = {}) {
|
|
79
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
80
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through must not
|
|
81
|
+
// redirect this pack's ledger to another vendor's file.
|
|
82
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Fly's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
86
|
+
* not an alias, so `budget instanceof FlyBudget` means "a budget that accounts against this
|
|
87
|
+
* vendor's ledger under this vendor's ceiling".
|
|
88
|
+
*/
|
|
89
|
+
export class FlyBudget extends RateBudget {
|
|
90
|
+
constructor(opts = {}) {
|
|
91
|
+
super({ ...opts, vendor: VENDOR });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export { RateBudgetError as FlyBudgetError } from '@volter/world-core';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
|
|
2
|
+
export declare const FLY_CAPABILITIES: CapabilitySpec[];
|
|
3
|
+
export declare const FLY_AREAS: readonly ["apps", "machines", "execution", "leases", "volumes", "secrets", "secretkeys", "certificates", "ip_assignments", "tokens", "orgs", "platform", "postgres", "auth", "errors", "connector", "conformance"];
|
|
4
|
+
export declare function flyCapabilities(): Promise<CapabilityReport>;
|