@cardelli/ambit 0.1.3 → 0.1.5
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/esm/deno.d.ts +1 -0
- package/esm/deno.js +3 -2
- package/esm/deps/jsr.io/@std/cli/1.0.28/_data.d.ts.map +1 -0
- package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_run_length.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/mod.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/parse_args.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/parse_args.js +17 -3
- package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/prompt_secret.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/unicode_width.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/collections/{1.1.3 → 1.1.6}/deep_merge.d.ts +2 -2
- package/esm/deps/jsr.io/@std/collections/{1.1.3 → 1.1.6}/deep_merge.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/collections/{1.1.3 → 1.1.6}/deep_merge.js +30 -20
- package/esm/deps/jsr.io/@std/toml/1.0.11/_parser.js +1 -1
- package/esm/deps/jsr.io/@zod/zod/4.3.6/src/v4/core/json-schema-generator.d.ts +1 -1
- package/esm/lib/cli.d.ts +0 -1
- package/esm/lib/cli.d.ts.map +1 -1
- package/esm/lib/cli.js +41 -26
- package/esm/lib/command.d.ts +23 -48
- package/esm/lib/command.d.ts.map +1 -1
- package/esm/lib/command.js +60 -89
- package/esm/lib/output.d.ts +1 -1
- package/esm/lib/output.d.ts.map +1 -1
- package/esm/lib/output.js +5 -9
- package/esm/lib/result.d.ts +19 -7
- package/esm/lib/result.d.ts.map +1 -1
- package/esm/lib/result.js +47 -1
- package/esm/main.d.ts.map +1 -1
- package/esm/main.js +3 -2
- package/esm/src/cli/commands/create.js +111 -97
- package/esm/src/cli/commands/deploy.js +61 -35
- package/esm/src/cli/commands/destroy.js +224 -29
- package/esm/src/cli/commands/doctor.js +2 -2
- package/esm/src/cli/commands/list.js +1 -1
- package/esm/src/cli/commands/status.js +1 -1
- package/esm/src/cli/mod.d.ts.map +1 -1
- package/esm/src/cli/mod.js +4 -3
- package/esm/src/discovery.d.ts +13 -0
- package/esm/src/discovery.d.ts.map +1 -1
- package/esm/src/discovery.js +42 -0
- package/esm/src/docker/router/start.sh +18 -72
- package/esm/src/providers/fly.d.ts +6 -0
- package/esm/src/providers/fly.d.ts.map +1 -1
- package/esm/src/providers/fly.js +77 -81
- package/esm/src/providers/tailscale.d.ts.map +1 -1
- package/esm/src/providers/tailscale.js +5 -11
- package/esm/src/template.d.ts +8 -7
- package/esm/src/template.d.ts.map +1 -1
- package/esm/src/template.js +32 -26
- package/package.json +7 -1
- package/esm/deps/jsr.io/@std/cli/1.0.27/_data.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/collections/1.1.3/_utils.d.ts +0 -6
- package/esm/deps/jsr.io/@std/collections/1.1.3/_utils.d.ts.map +0 -1
- package/esm/deps/jsr.io/@std/collections/1.1.3/_utils.js +0 -18
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_data.d.ts +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_data.js +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_run_length.d.ts +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_run_length.js +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/mod.d.ts +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/mod.js +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/parse_args.d.ts +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/prompt_secret.d.ts +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/prompt_secret.js +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/unicode_width.d.ts +0 -0
- /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/unicode_width.js +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
2
|
// Create Command - Create Tailscale Subnet Router on Fly.io Custom Network
|
|
3
3
|
// =============================================================================
|
|
4
|
-
import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.
|
|
4
|
+
import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.28/mod.js";
|
|
5
5
|
import { bold, randomId, readSecret } from "../../../lib/cli.js";
|
|
6
6
|
import { createOutput } from "../../../lib/output.js";
|
|
7
7
|
import { registerCommand } from "../mod.js";
|
|
@@ -11,13 +11,14 @@ import { createFlyProvider, FlyDeployError, getRouterAppName, } from "../../prov
|
|
|
11
11
|
import { createTailscaleProvider, enableAcceptRoutes, isAcceptRoutesEnabled, isTailscaleInstalled, waitForDevice, } from "../../providers/tailscale.js";
|
|
12
12
|
import { getCredentialStore } from "../../credentials.js";
|
|
13
13
|
import { resolveOrg } from "../../resolve.js";
|
|
14
|
+
import { findRouterApp } from "../../discovery.js";
|
|
14
15
|
// =============================================================================
|
|
15
16
|
// Create Command
|
|
16
17
|
// =============================================================================
|
|
17
18
|
const create = async (argv) => {
|
|
18
19
|
const args = parseArgs(argv, {
|
|
19
20
|
string: ["org", "region", "api-key", "tag"],
|
|
20
|
-
boolean: ["help", "yes", "json", "
|
|
21
|
+
boolean: ["help", "yes", "json", "no-auto-approve"],
|
|
21
22
|
alias: { y: "yes" },
|
|
22
23
|
});
|
|
23
24
|
if (args.help) {
|
|
@@ -32,9 +33,9 @@ ${bold("OPTIONS")}
|
|
|
32
33
|
--region <region> Fly.io region (default: iad)
|
|
33
34
|
--api-key <key> Tailscale API access token (tskey-api-...)
|
|
34
35
|
--tag <tag> Tailscale ACL tag for the router (default: tag:ambit-<network>)
|
|
35
|
-
--
|
|
36
|
+
--no-auto-approve Skip waiting for router and approving routes
|
|
36
37
|
-y, --yes Skip confirmation prompts
|
|
37
|
-
--json Output as JSON
|
|
38
|
+
--json Output as JSON (implies --no-auto-approve)
|
|
38
39
|
|
|
39
40
|
${bold("DESCRIPTION")}
|
|
40
41
|
Deploys a Tailscale subnet router onto a Fly.io custom private network.
|
|
@@ -45,7 +46,6 @@ ${bold("DESCRIPTION")}
|
|
|
45
46
|
${bold("EXAMPLES")}
|
|
46
47
|
ambit create browsers
|
|
47
48
|
ambit create browsers --org my-org --region sea
|
|
48
|
-
ambit create browsers --self-approve
|
|
49
49
|
`);
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
@@ -59,7 +59,7 @@ ${bold("EXAMPLES")}
|
|
|
59
59
|
return out.die(`"${network}" Is a Public TLD and Cannot Be Used as a Network Name`);
|
|
60
60
|
}
|
|
61
61
|
const tag = args.tag || getRouterTag(network);
|
|
62
|
-
const
|
|
62
|
+
const shouldApprove = !(args["no-auto-approve"] || args.json);
|
|
63
63
|
out.blank()
|
|
64
64
|
.header("=".repeat(50))
|
|
65
65
|
.header(` ambit Create: ${network}`)
|
|
@@ -75,7 +75,13 @@ ${bold("EXAMPLES")}
|
|
|
75
75
|
out.ok(`Authenticated as ${email}`);
|
|
76
76
|
const org = await resolveOrg(fly, args, out);
|
|
77
77
|
const region = args.region || "iad";
|
|
78
|
-
out.ok(`Using Region: ${region}`)
|
|
78
|
+
out.ok(`Using Region: ${region}`);
|
|
79
|
+
const existingRouter = await findRouterApp(fly, org, network);
|
|
80
|
+
if (existingRouter) {
|
|
81
|
+
return out.die(`A Router Already Exists for Network "${network}": ${existingRouter.appName}. ` +
|
|
82
|
+
`Use "ambit destroy ${network}" First, or Choose a Different Network Name.`);
|
|
83
|
+
}
|
|
84
|
+
out.blank();
|
|
79
85
|
// ==========================================================================
|
|
80
86
|
// Step 2: Tailscale Configuration
|
|
81
87
|
// ==========================================================================
|
|
@@ -86,8 +92,8 @@ ${bold("EXAMPLES")}
|
|
|
86
92
|
if (args.json) {
|
|
87
93
|
return out.die("--api-key Is Required in JSON Mode");
|
|
88
94
|
}
|
|
89
|
-
out.dim("
|
|
90
|
-
.dim("Create
|
|
95
|
+
out.dim("Ambit Needs an API Access Token (Not an Auth Key) to Manage Your Tailnet.")
|
|
96
|
+
.dim("Create One at: https://login.tailscale.com/admin/settings/keys")
|
|
91
97
|
.blank();
|
|
92
98
|
apiKey = await readSecret("API access token (tskey-api-...): ");
|
|
93
99
|
if (!apiKey) {
|
|
@@ -114,10 +120,10 @@ ${bold("EXAMPLES")}
|
|
|
114
120
|
if (!hasTagOwner) {
|
|
115
121
|
tagOwnerSpinner.fail(`Tag ${tag} Not Configured in tagOwners`);
|
|
116
122
|
out.blank()
|
|
117
|
-
.text(` The
|
|
118
|
-
.text(" Tailscale
|
|
123
|
+
.text(` The Tag ${tag} Does Not Exist in Your Tailscale ACL tagOwners.`)
|
|
124
|
+
.text(" Tailscale Will Reject Auth Keys for Undefined Tags.")
|
|
119
125
|
.blank()
|
|
120
|
-
.text(" Add
|
|
126
|
+
.text(" Add This Tag in Your Tailscale ACL Settings:")
|
|
121
127
|
.dim(" https://login.tailscale.com/admin/acls/visual/tags")
|
|
122
128
|
.blank()
|
|
123
129
|
.dim(` "tagOwners": { "${tag}": ["autogroup:admin"] }`)
|
|
@@ -125,46 +131,31 @@ ${bold("EXAMPLES")}
|
|
|
125
131
|
return out.die(`Add ${tag} to tagOwners Before Creating Router`);
|
|
126
132
|
}
|
|
127
133
|
tagOwnerSpinner.success(`Tag ${tag} Configured in tagOwners`);
|
|
128
|
-
// ==========================================================================
|
|
129
|
-
// Step 2.6: Check autoApprovers
|
|
130
|
-
// ==========================================================================
|
|
131
|
-
if (!selfApprove) {
|
|
132
|
-
const autoApproverSpinner = out.spinner("Checking autoApprovers Configuration");
|
|
133
|
-
const hasAutoApprover = await tailscale.isAutoApproverConfigured(tag);
|
|
134
|
-
if (!hasAutoApprover) {
|
|
135
|
-
autoApproverSpinner.fail("autoApprovers Not Configured");
|
|
136
|
-
out.blank()
|
|
137
|
-
.text(` The tag ${tag} is not listed in your Tailscale ACL autoApprovers.`)
|
|
138
|
-
.text(" Routes advertised by the router will not be auto-approved.")
|
|
139
|
-
.blank()
|
|
140
|
-
.text(" Either configure autoApprovers in your Tailscale ACL policy:")
|
|
141
|
-
.dim(` "autoApprovers": { "routes": { "fdaa:X:XXXX::/48": ["${tag}"] } }`)
|
|
142
|
-
.blank()
|
|
143
|
-
.text(" Or re-run with --self-approve to approve routes via API:")
|
|
144
|
-
.dim(` ambit create ${network} --self-approve`)
|
|
145
|
-
.blank();
|
|
146
|
-
return out.die("Configure autoApprovers or Use --self-approve");
|
|
147
|
-
}
|
|
148
|
-
autoApproverSpinner.success("autoApprovers Configured");
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
out.info("Self-Approve Mode: Routes Will Be Approved via API");
|
|
152
|
-
}
|
|
153
134
|
out.blank();
|
|
154
135
|
// ==========================================================================
|
|
155
136
|
// Step 3: Deploy Router on Custom 6PN
|
|
156
137
|
// ==========================================================================
|
|
138
|
+
let hasAutoApprover = false;
|
|
157
139
|
out.header("Step 3: Deploy Subnet Router").blank();
|
|
158
|
-
const
|
|
140
|
+
const suffix = randomId(8);
|
|
141
|
+
const routerAppName = getRouterAppName(network, suffix);
|
|
159
142
|
out.info(`Creating Router App: ${routerAppName}`)
|
|
160
143
|
.info(`Custom Network: ${network}`)
|
|
161
144
|
.info(`Router Tag: ${tag}`);
|
|
162
145
|
await fly.createApp(routerAppName, org, { network });
|
|
163
146
|
out.ok(`Created App: ${routerAppName}`);
|
|
147
|
+
const authKeySpinner = out.spinner("Creating Tag-Scoped Auth Key");
|
|
148
|
+
const authKey = await tailscale.createAuthKey({
|
|
149
|
+
reusable: false,
|
|
150
|
+
ephemeral: false,
|
|
151
|
+
preauthorized: true,
|
|
152
|
+
tags: [tag],
|
|
153
|
+
});
|
|
154
|
+
authKeySpinner.success("Auth Key Created (Single-Use, 5min Expiry)");
|
|
164
155
|
await fly.setSecrets(routerAppName, {
|
|
165
|
-
|
|
156
|
+
TAILSCALE_AUTHKEY: authKey,
|
|
166
157
|
NETWORK_NAME: network,
|
|
167
|
-
|
|
158
|
+
ROUTER_ID: suffix,
|
|
168
159
|
}, { stage: true });
|
|
169
160
|
out.ok("Set Router Secrets");
|
|
170
161
|
const dockerDir = new URL("../../docker/router", globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).url).pathname;
|
|
@@ -180,61 +171,79 @@ ${bold("EXAMPLES")}
|
|
|
180
171
|
throw e;
|
|
181
172
|
}
|
|
182
173
|
out.ok("Router Deployed");
|
|
183
|
-
out.blank();
|
|
184
|
-
const joinSpinner = out.spinner("Waiting for Router to Join Tailnet");
|
|
185
|
-
const device = await waitForDevice(tailscale, routerAppName, 180000);
|
|
186
|
-
joinSpinner.success(`Router Joined Tailnet: ${device.addresses[0]}`);
|
|
187
|
-
const machines = await fly.listMachines(routerAppName);
|
|
188
|
-
const routerMachine = machines.find((m) => m.private_ip);
|
|
189
|
-
const subnet = routerMachine?.private_ip
|
|
190
|
-
? extractSubnet(routerMachine.private_ip)
|
|
191
|
-
: null;
|
|
192
|
-
if (subnet) {
|
|
193
|
-
out.ok(`Subnet: ${subnet}`);
|
|
194
|
-
}
|
|
195
|
-
if (selfApprove && subnet) {
|
|
196
|
-
const approveSpinner = out.spinner("Approving Subnet Routes via API");
|
|
197
|
-
await tailscale.approveSubnetRoutes(device.id, [subnet]);
|
|
198
|
-
approveSpinner.success("Subnet Routes Approved via API");
|
|
199
|
-
}
|
|
200
|
-
if (device.advertisedRoutes && device.advertisedRoutes.length > 0) {
|
|
201
|
-
out.ok(`Routes: ${device.advertisedRoutes.join(", ")}`);
|
|
202
|
-
}
|
|
203
|
-
const dnsSpinner = out.spinner("Configuring Split DNS");
|
|
204
|
-
await tailscale.setSplitDns(network, [device.addresses[0]]);
|
|
205
|
-
dnsSpinner.success(`Split DNS Configured: *.${network} -> Router`);
|
|
206
174
|
// ==========================================================================
|
|
207
|
-
// Step 4:
|
|
175
|
+
// Step 4: Wait for Router, Approve Routes, Configure DNS
|
|
208
176
|
// ==========================================================================
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
177
|
+
let device = null;
|
|
178
|
+
let routerMachine;
|
|
179
|
+
let subnet = null;
|
|
180
|
+
if (shouldApprove) {
|
|
181
|
+
out.blank();
|
|
182
|
+
const joinSpinner = out.spinner("Waiting for Router to Join Tailnet");
|
|
183
|
+
device = await waitForDevice(tailscale, routerAppName, 180000);
|
|
184
|
+
joinSpinner.success(`Router Joined Tailnet: ${device.addresses[0]}`);
|
|
185
|
+
const machines = await fly.listMachines(routerAppName);
|
|
186
|
+
routerMachine = machines.find((m) => m.private_ip);
|
|
187
|
+
subnet = routerMachine?.private_ip
|
|
188
|
+
? extractSubnet(routerMachine.private_ip)
|
|
189
|
+
: null;
|
|
190
|
+
if (subnet) {
|
|
191
|
+
out.ok(`Subnet: ${subnet}`);
|
|
192
|
+
hasAutoApprover = await tailscale.isAutoApproverConfigured(tag);
|
|
193
|
+
if (!hasAutoApprover) {
|
|
194
|
+
const approveSpinner = out.spinner("Approving Subnet Routes");
|
|
195
|
+
await tailscale.approveSubnetRoutes(device.id, [subnet]);
|
|
196
|
+
approveSpinner.success("Subnet Routes Approved");
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
out.ok("Routes Auto-Approved via ACL Policy");
|
|
200
|
+
}
|
|
213
201
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
202
|
+
if (device.advertisedRoutes && device.advertisedRoutes.length > 0) {
|
|
203
|
+
out.ok(`Routes: ${device.advertisedRoutes.join(", ")}`);
|
|
204
|
+
}
|
|
205
|
+
const dnsSpinner = out.spinner("Configuring Split DNS");
|
|
206
|
+
await tailscale.setSplitDns(network, [device.addresses[0]]);
|
|
207
|
+
dnsSpinner.success(`Split DNS Configured: *.${network} -> Router`);
|
|
208
|
+
// ========================================================================
|
|
209
|
+
// Step 5: Local Client Configuration
|
|
210
|
+
// ========================================================================
|
|
211
|
+
out.blank().header("Step 5: Local Client Configuration").blank();
|
|
212
|
+
if (await isTailscaleInstalled()) {
|
|
213
|
+
if (await isAcceptRoutesEnabled()) {
|
|
214
|
+
out.ok("Accept Routes Already Enabled");
|
|
218
215
|
}
|
|
219
216
|
else {
|
|
220
|
-
routeSpinner.
|
|
221
|
-
|
|
222
|
-
.
|
|
223
|
-
|
|
217
|
+
const routeSpinner = out.spinner("Enabling Accept Routes");
|
|
218
|
+
if (await enableAcceptRoutes()) {
|
|
219
|
+
routeSpinner.success("Accept Routes Enabled");
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
routeSpinner.fail("Could Not Enable Accept Routes");
|
|
223
|
+
out.blank()
|
|
224
|
+
.dim("Run Manually With Elevated Permissions:")
|
|
225
|
+
.dim(" sudo tailscale set --accept-routes");
|
|
226
|
+
}
|
|
224
227
|
}
|
|
225
228
|
}
|
|
229
|
+
else {
|
|
230
|
+
out.warn("Tailscale CLI Not Found")
|
|
231
|
+
.dim(" Ensure Accept-Routes is Enabled on This Device");
|
|
232
|
+
}
|
|
226
233
|
}
|
|
227
234
|
else {
|
|
228
|
-
out.
|
|
229
|
-
.dim(" Ensure Accept-Routes Is Enabled on This Device");
|
|
235
|
+
out.blank().info("Skipping Route Approval and DNS Configuration (Use ambit doctor to Verify Later)");
|
|
230
236
|
}
|
|
231
237
|
// ==========================================================================
|
|
232
238
|
// Done
|
|
233
239
|
// ==========================================================================
|
|
234
240
|
out.done({
|
|
235
241
|
network,
|
|
236
|
-
router: {
|
|
237
|
-
|
|
242
|
+
router: {
|
|
243
|
+
appName: routerAppName,
|
|
244
|
+
tailscaleIp: device?.addresses[0] ?? "pending",
|
|
245
|
+
},
|
|
246
|
+
subnet: subnet || "pending",
|
|
238
247
|
tag,
|
|
239
248
|
});
|
|
240
249
|
out.blank()
|
|
@@ -244,43 +253,48 @@ ${bold("EXAMPLES")}
|
|
|
244
253
|
.blank()
|
|
245
254
|
.text(`Any Flycast app on the "${network}" network is reachable as:`)
|
|
246
255
|
.text(` <app-name>.${network}`)
|
|
247
|
-
.blank()
|
|
248
|
-
|
|
249
|
-
.text(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
256
|
+
.blank();
|
|
257
|
+
if (routerMachine?.private_ip) {
|
|
258
|
+
out.text("SOCKS5 Proxy Available at:")
|
|
259
|
+
.text(` socks5://[${routerMachine.private_ip}]:1080`)
|
|
260
|
+
.dim("Containers on This Network Can Use It to Reach Your Tailnet.")
|
|
261
|
+
.blank();
|
|
262
|
+
}
|
|
263
|
+
out.dim("Deploy an App to This Network:")
|
|
253
264
|
.dim(` ambit deploy my-app --network ${network}`)
|
|
254
265
|
.blank()
|
|
255
|
-
.dim("Invite
|
|
266
|
+
.dim("Invite People to Your Tailnet:")
|
|
256
267
|
.dim(" https://login.tailscale.com/admin/users")
|
|
257
|
-
.dim("Control
|
|
268
|
+
.dim("Control Their Access:")
|
|
258
269
|
.dim(" https://login.tailscale.com/admin/acls/visual/general-access-rules")
|
|
259
270
|
.blank();
|
|
260
|
-
if (subnet &&
|
|
261
|
-
out.header("Recommended
|
|
271
|
+
if (subnet && !hasAutoApprover) {
|
|
272
|
+
out.header("Recommended: Configure autoApprovers")
|
|
262
273
|
.blank()
|
|
263
|
-
.dim(" Add
|
|
274
|
+
.dim(" Add to Your Tailnet Policy File at:")
|
|
264
275
|
.dim(" https://login.tailscale.com/admin/acls/file")
|
|
265
276
|
.blank()
|
|
266
|
-
.text(` "tagOwners": { "${tag}": ["autogroup:admin"] }`)
|
|
267
277
|
.text(` "autoApprovers": { "routes": { "${subnet}": ["${tag}"] } }`)
|
|
268
278
|
.blank()
|
|
269
|
-
.dim("
|
|
270
|
-
.dim(
|
|
271
|
-
.dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${subnet}:*"]}`)
|
|
279
|
+
.dim(" Routes Were Approved via API for This Session.")
|
|
280
|
+
.dim(" autoApprovers Will Auto-Approve on Future Restarts.")
|
|
272
281
|
.blank();
|
|
273
282
|
}
|
|
274
|
-
|
|
283
|
+
if (subnet) {
|
|
275
284
|
out.header("Recommended ACL Rules:")
|
|
276
285
|
.blank()
|
|
277
|
-
.dim(" To
|
|
286
|
+
.dim(" To Restrict Access, Add ACL Rules to Your Policy File:")
|
|
278
287
|
.dim(" https://login.tailscale.com/admin/acls/file")
|
|
279
288
|
.blank()
|
|
280
289
|
.dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${tag}:53"]}`)
|
|
281
290
|
.dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${subnet}:*"]}`)
|
|
282
291
|
.blank();
|
|
283
292
|
}
|
|
293
|
+
if (!shouldApprove) {
|
|
294
|
+
out.dim("Route Approval Was Skipped. To Complete Setup:")
|
|
295
|
+
.dim(` ambit doctor --network ${network}`)
|
|
296
|
+
.blank();
|
|
297
|
+
}
|
|
284
298
|
out.print();
|
|
285
299
|
};
|
|
286
300
|
// =============================================================================
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// Deploy Command - Safely Deploy a Workload App on a Custom Private Network
|
|
3
3
|
// =============================================================================
|
|
4
4
|
import * as dntShim from "../../../_dnt.shims.js";
|
|
5
|
-
import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.
|
|
5
|
+
import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.28/mod.js";
|
|
6
6
|
import { join } from "../../../deps/jsr.io/@std/path/1.1.4/mod.js";
|
|
7
7
|
import { bold, confirm, fileExists } from "../../../lib/cli.js";
|
|
8
8
|
import { createOutput } from "../../../lib/output.js";
|
|
9
9
|
import { registerCommand } from "../mod.js";
|
|
10
|
-
import { createFlyProvider, FlyDeployError } from "../../providers/fly.js";
|
|
10
|
+
import { createFlyProvider, FlyDeployError, getWorkloadAppName, } from "../../providers/fly.js";
|
|
11
11
|
import { findRouterApp } from "../../discovery.js";
|
|
12
12
|
import { resolveOrg } from "../../resolve.js";
|
|
13
13
|
import { assertNotRouter, auditDeploy, scanFlyToml } from "../../guard.js";
|
|
@@ -99,38 +99,41 @@ const resolveTemplateMode = async (templateRaw, out) => {
|
|
|
99
99
|
const ref = parseTemplateRef(templateRaw);
|
|
100
100
|
if (!ref) {
|
|
101
101
|
out.die(`Invalid Template Reference: "${templateRaw}". ` +
|
|
102
|
-
`Format: owner/repo/path[@ref]`);
|
|
102
|
+
`Format: owner/repo[/path][@ref]`);
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
|
-
const label =
|
|
105
|
+
const label = (ref.path === "."
|
|
106
|
+
? `${ref.owner}/${ref.repo}`
|
|
107
|
+
: `${ref.owner}/${ref.repo}/${ref.path}`) +
|
|
106
108
|
(ref.ref ? `@${ref.ref}` : "");
|
|
107
109
|
out.info(`Template: ${label}`);
|
|
108
110
|
const fetchSpinner = out.spinner("Fetching Template from GitHub");
|
|
109
111
|
const result = await fetchTemplate(ref);
|
|
110
112
|
if (!result.ok) {
|
|
111
113
|
fetchSpinner.fail("Template Fetch Failed");
|
|
112
|
-
out.die(result.
|
|
114
|
+
out.die(result.error);
|
|
113
115
|
return null;
|
|
114
116
|
}
|
|
117
|
+
const { tempDir, templateDir } = result.value;
|
|
115
118
|
fetchSpinner.success("Template Fetched");
|
|
116
119
|
// Find and scan the template's fly.toml
|
|
117
|
-
const configPath = join(
|
|
120
|
+
const configPath = join(templateDir, "fly.toml");
|
|
118
121
|
let tomlContent;
|
|
119
122
|
try {
|
|
120
123
|
tomlContent = await dntShim.Deno.readTextFile(configPath);
|
|
121
124
|
}
|
|
122
125
|
catch {
|
|
123
126
|
try {
|
|
124
|
-
dntShim.Deno.removeSync(
|
|
127
|
+
dntShim.Deno.removeSync(tempDir, { recursive: true });
|
|
125
128
|
}
|
|
126
129
|
catch { /* ignore */ }
|
|
127
|
-
out.die(`Template '${ref.path}' Has No fly.toml`);
|
|
130
|
+
out.die(`Template '${ref.path === "." ? ref.repo : ref.path}' Has No fly.toml`);
|
|
128
131
|
return null;
|
|
129
132
|
}
|
|
130
133
|
const scan = scanFlyToml(tomlContent);
|
|
131
134
|
if (scan.errors.length > 0) {
|
|
132
135
|
try {
|
|
133
|
-
dntShim.Deno.removeSync(
|
|
136
|
+
dntShim.Deno.removeSync(tempDir, { recursive: true });
|
|
134
137
|
}
|
|
135
138
|
catch { /* ignore */ }
|
|
136
139
|
for (const err of scan.errors) {
|
|
@@ -142,11 +145,11 @@ const resolveTemplateMode = async (templateRaw, out) => {
|
|
|
142
145
|
for (const warn of scan.warnings) {
|
|
143
146
|
out.warn(warn);
|
|
144
147
|
}
|
|
145
|
-
out.ok(`Scanned ${ref.path}
|
|
148
|
+
out.ok(`Scanned ${ref.path === "." ? "" : ref.path + "/"}fly.toml`);
|
|
146
149
|
return {
|
|
147
150
|
configPath,
|
|
148
151
|
preflight: { scanned: scan.scanned, warnings: scan.warnings },
|
|
149
|
-
tempDir
|
|
152
|
+
tempDir,
|
|
150
153
|
};
|
|
151
154
|
};
|
|
152
155
|
// =============================================================================
|
|
@@ -172,21 +175,24 @@ const deploy = async (argv) => {
|
|
|
172
175
|
${bold("ambit deploy")} - Deploy an App Safely on a Custom Private Network
|
|
173
176
|
|
|
174
177
|
${bold("USAGE")}
|
|
178
|
+
ambit deploy <app>.<network> [options]
|
|
175
179
|
ambit deploy <app> --network <name> [options]
|
|
176
180
|
|
|
181
|
+
The network can be specified as part of the name (app.network) or with --network.
|
|
182
|
+
|
|
177
183
|
${bold("MODES")}
|
|
178
184
|
Config mode (default):
|
|
179
|
-
ambit deploy
|
|
180
|
-
ambit deploy
|
|
185
|
+
ambit deploy my-app.lab Uses ./fly.toml
|
|
186
|
+
ambit deploy my-app.lab --config path Explicit fly.toml
|
|
181
187
|
|
|
182
188
|
Image mode:
|
|
183
|
-
ambit deploy
|
|
189
|
+
ambit deploy my-app.lab --image <img> Docker image, no toml
|
|
184
190
|
|
|
185
191
|
Template mode:
|
|
186
|
-
ambit deploy
|
|
192
|
+
ambit deploy my-app.lab --template <ref> GitHub template
|
|
187
193
|
|
|
188
194
|
${bold("OPTIONS")}
|
|
189
|
-
--network <name>
|
|
195
|
+
--network <name> Target network
|
|
190
196
|
--org <org> Fly.io organization slug
|
|
191
197
|
--region <region> Primary deployment region
|
|
192
198
|
-y, --yes Skip confirmation prompts
|
|
@@ -200,10 +206,11 @@ ${bold("IMAGE MODE")}
|
|
|
200
206
|
--main-port <port> Internal port for HTTP service (default: 80, "none" to skip)
|
|
201
207
|
|
|
202
208
|
${bold("TEMPLATE MODE")}
|
|
203
|
-
--template <ref> GitHub template as owner/repo/path[@ref]
|
|
209
|
+
--template <ref> GitHub template as owner/repo[/path][@ref]
|
|
204
210
|
|
|
205
211
|
Reference format:
|
|
206
|
-
owner/repo
|
|
212
|
+
owner/repo Fetch repo root from the default branch
|
|
213
|
+
owner/repo/path Fetch subdirectory from the default branch
|
|
207
214
|
owner/repo/path@tag Fetch a tagged release
|
|
208
215
|
owner/repo/path@branch Fetch a specific branch
|
|
209
216
|
owner/repo/path@commit Fetch a specific commit
|
|
@@ -214,11 +221,12 @@ ${bold("SAFETY")}
|
|
|
214
221
|
Pre-flight scan rejects fly.toml with force_https or TLS on 443.
|
|
215
222
|
|
|
216
223
|
${bold("EXAMPLES")}
|
|
217
|
-
ambit deploy my-app
|
|
218
|
-
ambit deploy my-app --
|
|
219
|
-
ambit deploy my-app --
|
|
220
|
-
ambit deploy my-
|
|
221
|
-
ambit deploy my-browser
|
|
224
|
+
ambit deploy my-app.lab
|
|
225
|
+
ambit deploy my-app.lab --image registry/img:latest
|
|
226
|
+
ambit deploy my-app.lab --config ./fly.toml --region sea
|
|
227
|
+
ambit deploy my-claw.lab --template ToxicPine/ambit-openclaw
|
|
228
|
+
ambit deploy my-browser.lab --template ToxicPine/ambit-templates/chromatic
|
|
229
|
+
ambit deploy my-browser --network lab --template ToxicPine/ambit-templates/chromatic@v1.0
|
|
222
230
|
`);
|
|
223
231
|
return;
|
|
224
232
|
}
|
|
@@ -228,11 +236,27 @@ ${bold("EXAMPLES")}
|
|
|
228
236
|
// ==========================================================================
|
|
229
237
|
const appArg = args._[0];
|
|
230
238
|
if (!appArg || typeof appArg !== "string") {
|
|
231
|
-
return out.die("
|
|
239
|
+
return out.die("Missing app name. Usage: ambit deploy <app>.<network>");
|
|
240
|
+
}
|
|
241
|
+
let app;
|
|
242
|
+
let network;
|
|
243
|
+
if (appArg.includes(".")) {
|
|
244
|
+
const parts = appArg.split(".");
|
|
245
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
246
|
+
return out.die(`'${appArg}' should have exactly one dot, like my-app.my-network`);
|
|
247
|
+
}
|
|
248
|
+
if (args.network) {
|
|
249
|
+
return out.die(`Network is already part of the name ('${appArg}'), --network is not needed`);
|
|
250
|
+
}
|
|
251
|
+
app = parts[0];
|
|
252
|
+
network = parts[1];
|
|
232
253
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
254
|
+
else {
|
|
255
|
+
app = appArg;
|
|
256
|
+
if (!args.network) {
|
|
257
|
+
return out.die(`Missing network. Use: ambit deploy ${app}.<network>`);
|
|
258
|
+
}
|
|
259
|
+
network = args.network;
|
|
236
260
|
}
|
|
237
261
|
try {
|
|
238
262
|
assertNotRouter(app);
|
|
@@ -244,7 +268,6 @@ ${bold("EXAMPLES")}
|
|
|
244
268
|
if (modeFlags.length > 1) {
|
|
245
269
|
return out.die("--image, --config, and --template Are Mutually Exclusive");
|
|
246
270
|
}
|
|
247
|
-
const network = args.network;
|
|
248
271
|
out.blank()
|
|
249
272
|
.header("=".repeat(50))
|
|
250
273
|
.header(` ambit Deploy: ${app}`)
|
|
@@ -272,27 +295,29 @@ ${bold("EXAMPLES")}
|
|
|
272
295
|
`Run 'ambit create ${network}' first.`);
|
|
273
296
|
}
|
|
274
297
|
routerSpinner.success(`Router Found: ${router.appName}`);
|
|
298
|
+
const routerId = router.routerId;
|
|
299
|
+
const flyAppName = getWorkloadAppName(app, routerId);
|
|
275
300
|
out.blank();
|
|
276
301
|
// ==========================================================================
|
|
277
302
|
// Phase 3: App Creation (if needed)
|
|
278
303
|
// ==========================================================================
|
|
279
304
|
out.header("Step 3: App Setup").blank();
|
|
280
305
|
let created = false;
|
|
281
|
-
const exists = await fly.appExists(
|
|
306
|
+
const exists = await fly.appExists(flyAppName);
|
|
282
307
|
if (exists) {
|
|
283
|
-
out.ok(`App '${
|
|
308
|
+
out.ok(`App '${flyAppName}' Exists`);
|
|
284
309
|
}
|
|
285
310
|
else {
|
|
286
|
-
out.info(`App '${
|
|
311
|
+
out.info(`App '${flyAppName}' Does Not Exist — Will Create on Network '${network}'`);
|
|
287
312
|
if (!args.yes && !args.json) {
|
|
288
|
-
const confirmed = await confirm(`Create app '${
|
|
313
|
+
const confirmed = await confirm(`Create app '${flyAppName}' on network '${network}'?`);
|
|
289
314
|
if (!confirmed) {
|
|
290
315
|
out.text("Cancelled.");
|
|
291
316
|
return;
|
|
292
317
|
}
|
|
293
318
|
}
|
|
294
|
-
await fly.createApp(app, org, { network });
|
|
295
|
-
out.ok(`Created App '${
|
|
319
|
+
await fly.createApp(app, org, { network, routerId });
|
|
320
|
+
out.ok(`Created App '${flyAppName}' on Network '${network}'`);
|
|
296
321
|
created = true;
|
|
297
322
|
}
|
|
298
323
|
out.blank();
|
|
@@ -320,6 +345,7 @@ ${bold("EXAMPLES")}
|
|
|
320
345
|
out.dim("Deploying with --no-public-ips --flycast ...");
|
|
321
346
|
try {
|
|
322
347
|
await fly.deploySafe(app, {
|
|
348
|
+
routerId,
|
|
323
349
|
image: deployConfig.image,
|
|
324
350
|
config: deployConfig.configPath,
|
|
325
351
|
region: args.region,
|
|
@@ -347,7 +373,7 @@ ${bold("EXAMPLES")}
|
|
|
347
373
|
// ==========================================================================
|
|
348
374
|
out.header("Step 6: Post-flight Audit").blank();
|
|
349
375
|
const auditSpinner = out.spinner("Auditing Deployment");
|
|
350
|
-
const audit = await auditDeploy(fly,
|
|
376
|
+
const audit = await auditDeploy(fly, flyAppName, network);
|
|
351
377
|
auditSpinner.success("Audit Complete");
|
|
352
378
|
if (audit.public_ips_released > 0) {
|
|
353
379
|
out.warn(`Released ${audit.public_ips_released} Public IP(s)`);
|