@tokenbuddy/tb-admin 1.0.13 → 1.0.15
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/dist/src/bootstrap-registry.d.ts +1 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -1
- package/dist/src/bootstrap-registry.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +30 -16
- package/dist/src/cli.js.map +1 -1
- package/dist/src/server-cmd.d.ts +27 -2
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +131 -26
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +88 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +763 -0
- package/dist/src/ui-actions.js.map +1 -0
- package/dist/src/ui-command.d.ts +4 -0
- package/dist/src/ui-command.d.ts.map +1 -0
- package/dist/src/ui-command.js +37 -0
- package/dist/src/ui-command.js.map +1 -0
- package/dist/src/ui-server.d.ts +23 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +245 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +134 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +407 -0
- package/dist/src/ui-state.js.map +1 -0
- package/dist/src/ui-static.d.ts +2 -0
- package/dist/src/ui-static.d.ts.map +1 -0
- package/dist/src/ui-static.js +144 -0
- package/dist/src/ui-static.js.map +1 -0
- package/dist/src/upstream-balance-probe.d.ts +41 -0
- package/dist/src/upstream-balance-probe.d.ts.map +1 -0
- package/dist/src/upstream-balance-probe.js +379 -0
- package/dist/src/upstream-balance-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/bootstrap-registry.ts +1 -0
- package/src/cli.ts +32 -16
- package/src/server-cmd.ts +163 -29
- package/src/ui-actions.ts +901 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +308 -0
- package/src/ui-state.ts +575 -0
- package/src/ui-static.ts +144 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +893 -4
package/src/server-cmd.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
1
|
+
import { execSync, spawnSync, type SpawnSyncReturns } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
2
3
|
import { SellerProviderConfig } from "./config.js";
|
|
3
4
|
|
|
5
|
+
type ExecRunner = (command: string, options?: Parameters<typeof execSync>[1]) => string | Buffer;
|
|
6
|
+
type SpawnRunner = (command: string, args?: string[], options?: Parameters<typeof spawnSync>[2]) => SpawnSyncReturns<string | Buffer>;
|
|
7
|
+
|
|
8
|
+
export interface DockerImageInspection {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
exitCode?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ImageInspectRunner = (image: string) => DockerImageInspection;
|
|
15
|
+
|
|
16
|
+
export interface FlyProviderRuntime {
|
|
17
|
+
checkFlyctlInstalled?: (flyctlPath?: string) => boolean;
|
|
18
|
+
execSync?: ExecRunner;
|
|
19
|
+
spawnSync?: SpawnRunner;
|
|
20
|
+
imageInspector?: ImageInspectRunner;
|
|
21
|
+
}
|
|
22
|
+
|
|
4
23
|
/**
|
|
5
24
|
* 检查 flyctl 是否在 PATH 中(或在指定路径)。
|
|
6
25
|
*
|
|
@@ -16,6 +35,70 @@ export function checkFlyctlInstalled(flyctlPath?: string): boolean {
|
|
|
16
35
|
}
|
|
17
36
|
}
|
|
18
37
|
|
|
38
|
+
export function inspectDockerImage(image: string): DockerImageInspection {
|
|
39
|
+
const result = spawnSync("docker", ["buildx", "imagetools", "inspect", image], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
42
|
+
});
|
|
43
|
+
return dockerImageInspectionFromResult(result);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function requirePublishedDockerImage(image: string, inspectImage: ImageInspectRunner = inspectDockerImage): void {
|
|
47
|
+
const inspection = inspectImage(image);
|
|
48
|
+
if (inspection.ok) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const detail = inspection.error ? ` Detail: ${inspection.error.trim()}` : "";
|
|
52
|
+
throw new Error(
|
|
53
|
+
`seller image is not published or is not accessible: ${image}. ` +
|
|
54
|
+
`Publish it first with RELEASE_VERSION=<v> bash scripts/release/all.sh, or pass an existing registry.fly.io/tb-seller:<v> tag. ` +
|
|
55
|
+
`No Fly app was created.${detail}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function dockerImageInspectionFromResult(result: SpawnSyncReturns<string>): DockerImageInspection {
|
|
60
|
+
if (result.error) {
|
|
61
|
+
return { ok: false, error: result.error.message };
|
|
62
|
+
}
|
|
63
|
+
if (result.status !== 0) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
exitCode: result.status ?? undefined,
|
|
67
|
+
error: (result.stderr || result.stdout || "").toString()
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseFlyMachineIds(json: string, app: string): string[] {
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(json || "[]");
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(`fly machines list returned invalid JSON for ${app}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!Array.isArray(parsed)) {
|
|
82
|
+
throw new Error(`fly machines list returned an unexpected shape for ${app}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ids = parsed
|
|
86
|
+
.map((item) => {
|
|
87
|
+
if (item && typeof item === "object" && "id" in item) {
|
|
88
|
+
const id = (item as { id?: unknown }).id;
|
|
89
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
})
|
|
93
|
+
.filter((id): id is string => Boolean(id));
|
|
94
|
+
|
|
95
|
+
if (ids.length === 0) {
|
|
96
|
+
throw new Error(`fly app ${app} has no machines to update`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return ids;
|
|
100
|
+
}
|
|
101
|
+
|
|
19
102
|
/**
|
|
20
103
|
* `FlyProvider.createSeller` 的输入。
|
|
21
104
|
* 字段未提供时回退到 `SellerProviderConfig` 的默认值。
|
|
@@ -31,6 +114,7 @@ export interface SellerCreateOptions {
|
|
|
31
114
|
volumeSizeGb?: number;
|
|
32
115
|
volumeId?: string;
|
|
33
116
|
volumeSnapshotRetentionDays?: number;
|
|
117
|
+
initialConfigPath?: string;
|
|
34
118
|
dryRun?: boolean;
|
|
35
119
|
}
|
|
36
120
|
|
|
@@ -40,9 +124,17 @@ export interface SellerCreateOptions {
|
|
|
40
124
|
*/
|
|
41
125
|
export class FlyProvider {
|
|
42
126
|
private providerConfig?: SellerProviderConfig;
|
|
127
|
+
private readonly runtime: Required<FlyProviderRuntime>;
|
|
43
128
|
|
|
44
|
-
constructor(providerConfig?: SellerProviderConfig) {
|
|
129
|
+
constructor(providerConfig?: SellerProviderConfig, runtime?: FlyProviderRuntime) {
|
|
45
130
|
this.providerConfig = providerConfig;
|
|
131
|
+
this.runtime = {
|
|
132
|
+
checkFlyctlInstalled,
|
|
133
|
+
execSync,
|
|
134
|
+
spawnSync,
|
|
135
|
+
imageInspector: inspectDockerImage,
|
|
136
|
+
...runtime
|
|
137
|
+
};
|
|
46
138
|
}
|
|
47
139
|
|
|
48
140
|
private get flyctl(): string {
|
|
@@ -53,10 +145,10 @@ export class FlyProvider {
|
|
|
53
145
|
* List apps on Fly.io
|
|
54
146
|
*/
|
|
55
147
|
public listApps(): string {
|
|
56
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
148
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
57
149
|
throw new Error(`\`${this.flyctl}\` is not installed on your system PATH.`);
|
|
58
150
|
}
|
|
59
|
-
return execSync(`${this.flyctl} apps list`, { encoding: "utf8" });
|
|
151
|
+
return this.runtime.execSync(`${this.flyctl} apps list`, { encoding: "utf8" }) as string;
|
|
60
152
|
}
|
|
61
153
|
|
|
62
154
|
/**
|
|
@@ -83,6 +175,7 @@ export class FlyProvider {
|
|
|
83
175
|
|| 1;
|
|
84
176
|
const volumeSnapshotRetentionDays = options.volumeSnapshotRetentionDays;
|
|
85
177
|
const volumeId = options.volumeId;
|
|
178
|
+
const initialConfigPath = options.initialConfigPath;
|
|
86
179
|
|
|
87
180
|
if (!targetImage) {
|
|
88
181
|
throw new Error("seller create requires --image registry.fly.io/tb-seller:<v>");
|
|
@@ -100,35 +193,59 @@ export class FlyProvider {
|
|
|
100
193
|
` Volume: ${volumeName} (${volumeSizeGb}GB)`,
|
|
101
194
|
];
|
|
102
195
|
if (flyConfig) lines.push(` Fly config: ${flyConfig}`);
|
|
196
|
+
if (initialConfigPath) lines.push(` Initial config secret: TOKENBUDDY_SELLER_CONFIG_B64 from ${initialConfigPath}`);
|
|
103
197
|
if (volumeId) lines.push(` Volume ID: ${volumeId}`);
|
|
104
198
|
if (volumeSnapshotRetentionDays !== undefined) lines.push(` Volume snapshot retention: ${volumeSnapshotRetentionDays} days`);
|
|
105
199
|
return lines.join("\n");
|
|
106
200
|
}
|
|
107
201
|
|
|
108
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
202
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
109
203
|
throw new Error(`\`${this.flyctl}\` is not installed on PATH.`);
|
|
110
204
|
}
|
|
111
205
|
|
|
112
206
|
if (!operatorSecret) {
|
|
113
207
|
throw new Error("operator_secret is required. Provide --operator-secret or configure seller_providers.fly.operator_secret");
|
|
114
208
|
}
|
|
209
|
+
requireReadableFile(flyConfig, "Fly config");
|
|
210
|
+
if (initialConfigPath) {
|
|
211
|
+
requireReadableFile(initialConfigPath, "Initial seller config");
|
|
212
|
+
}
|
|
213
|
+
requirePublishedDockerImage(targetImage, this.runtime.imageInspector);
|
|
115
214
|
|
|
116
215
|
console.log(`[Fly.io] Creating app ${appName}...`);
|
|
117
|
-
execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
|
|
216
|
+
this.runtime.execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
|
|
118
217
|
|
|
119
218
|
console.log(`[Fly.io] Setting secrets...`);
|
|
120
|
-
|
|
121
|
-
`${this.flyctl} secrets set ALLOW_MOCK=false OPERATOR_SECRET=${operatorSecret} --app ${appName}`,
|
|
122
|
-
{ stdio: "inherit" }
|
|
123
|
-
);
|
|
219
|
+
this.importCreateSecrets(appName, operatorSecret, initialConfigPath);
|
|
124
220
|
|
|
125
221
|
console.log(`[Fly.io] Deploying image ${targetImage}...`);
|
|
126
|
-
const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --region ${targetRegion} --app ${appName} --now`;
|
|
127
|
-
execSync(deployCmd, { stdio: "inherit" });
|
|
222
|
+
const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --primary-region ${targetRegion} --app ${appName} --now`;
|
|
223
|
+
this.runtime.execSync(deployCmd, { stdio: "inherit" });
|
|
128
224
|
|
|
129
225
|
return `Successfully deployed ${appName} on Fly.io`;
|
|
130
226
|
}
|
|
131
227
|
|
|
228
|
+
private importCreateSecrets(appName: string, operatorSecret: string, initialConfigPath: string | undefined): void {
|
|
229
|
+
const lines = [
|
|
230
|
+
"ALLOW_MOCK=false",
|
|
231
|
+
`OPERATOR_SECRET=${operatorSecret}`
|
|
232
|
+
];
|
|
233
|
+
if (initialConfigPath) {
|
|
234
|
+
const configContent = fs.readFileSync(initialConfigPath, "utf8");
|
|
235
|
+
lines.push(`TOKENBUDDY_SELLER_CONFIG_B64=${Buffer.from(configContent, "utf8").toString("base64")}`);
|
|
236
|
+
}
|
|
237
|
+
const result = this.runtime.spawnSync(this.flyctl, ["secrets", "import", "--stage", "--app", appName], {
|
|
238
|
+
input: `${lines.join("\n")}\n`,
|
|
239
|
+
stdio: ["pipe", "inherit", "inherit"]
|
|
240
|
+
});
|
|
241
|
+
if (result.error) {
|
|
242
|
+
throw result.error;
|
|
243
|
+
}
|
|
244
|
+
if (result.status !== 0) {
|
|
245
|
+
throw new Error(`flyctl secrets import failed with exit code ${result.status}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
132
249
|
/**
|
|
133
250
|
* Destroy a seller app on Fly.io.
|
|
134
251
|
* @param nameOrApp Either a bare name (e.g. "86d81e") or a full app name (e.g. "tbs-86d81e").
|
|
@@ -141,12 +258,12 @@ export class FlyProvider {
|
|
|
141
258
|
return `[DRY-RUN] Will destroy fly app: ${appName}`;
|
|
142
259
|
}
|
|
143
260
|
|
|
144
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
261
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
145
262
|
throw new Error(`\`${this.flyctl}\` is not installed.`);
|
|
146
263
|
}
|
|
147
264
|
|
|
148
265
|
console.log(`[Fly.io] Destroying app ${appName}...`);
|
|
149
|
-
execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
|
|
266
|
+
this.runtime.execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
|
|
150
267
|
|
|
151
268
|
return `Successfully destroyed ${appName} on Fly.io`;
|
|
152
269
|
}
|
|
@@ -156,14 +273,17 @@ export class FlyProvider {
|
|
|
156
273
|
*/
|
|
157
274
|
public statusApp(name: string): string {
|
|
158
275
|
const appName = name.includes("-") ? name : `tb-seller-${name}`;
|
|
159
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
276
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
160
277
|
throw new Error(`\`${this.flyctl}\` is not installed.`);
|
|
161
278
|
}
|
|
162
|
-
return execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" });
|
|
279
|
+
return this.runtime.execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" }) as string;
|
|
163
280
|
}
|
|
164
281
|
|
|
165
282
|
/**
|
|
166
|
-
*
|
|
283
|
+
* Update an existing seller app's Machines to a new image.
|
|
284
|
+
*
|
|
285
|
+
* This intentionally does not call `fly deploy --config`: existing seller
|
|
286
|
+
* deploys must not reconcile volumes, mounts, or service configuration.
|
|
167
287
|
*/
|
|
168
288
|
public deploySeller(options: {
|
|
169
289
|
app: string;
|
|
@@ -171,32 +291,46 @@ export class FlyProvider {
|
|
|
171
291
|
image?: string;
|
|
172
292
|
dryRun?: boolean;
|
|
173
293
|
}): string {
|
|
174
|
-
const { app,
|
|
175
|
-
const flyConfig = config;
|
|
294
|
+
const { app, image, dryRun } = options;
|
|
176
295
|
const targetImage = image;
|
|
177
296
|
|
|
178
|
-
if (!flyConfig) {
|
|
179
|
-
throw new Error("seller deploy requires --fly-config deploy/fly.io/fly.tb-seller.toml");
|
|
180
|
-
}
|
|
181
297
|
if (!targetImage) {
|
|
182
298
|
throw new Error("seller deploy requires --image registry.fly.io/tb-seller:<v>");
|
|
183
299
|
}
|
|
184
300
|
|
|
185
301
|
if (dryRun) {
|
|
186
|
-
const lines = [`[DRY-RUN] Will
|
|
302
|
+
const lines = [`[DRY-RUN] Will update existing app machines: ${app}`];
|
|
187
303
|
lines.push(` Image: ${targetImage}`);
|
|
188
|
-
lines.push(
|
|
304
|
+
lines.push(" Config: unchanged");
|
|
305
|
+
lines.push(" Volumes: unchanged");
|
|
189
306
|
return lines.join("\n");
|
|
190
307
|
}
|
|
191
308
|
|
|
192
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
309
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
193
310
|
throw new Error(`\`${this.flyctl}\` is not installed.`);
|
|
194
311
|
}
|
|
195
312
|
|
|
196
|
-
const
|
|
313
|
+
const machinesJson = this.runtime.execSync(`${this.flyctl} machines list --app ${app} --json`, { encoding: "utf8" }) as string;
|
|
314
|
+
const machineIds = parseFlyMachineIds(machinesJson, app);
|
|
197
315
|
|
|
198
|
-
console.log(`[Fly.io]
|
|
199
|
-
|
|
200
|
-
|
|
316
|
+
console.log(`[Fly.io] Updating ${app} image on ${machineIds.length} machine(s)...`);
|
|
317
|
+
for (const machineId of machineIds) {
|
|
318
|
+
this.runtime.execSync(
|
|
319
|
+
`${this.flyctl} machine update ${machineId} --app ${app} --image ${targetImage} --yes`,
|
|
320
|
+
{ stdio: "inherit" }
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return `Successfully updated ${app} image`;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function requireReadableFile(filePath: string, label: string): void {
|
|
328
|
+
if (!fs.existsSync(filePath)) {
|
|
329
|
+
throw new Error(`${label} file does not exist: ${filePath}`);
|
|
330
|
+
}
|
|
331
|
+
const stat = fs.statSync(filePath);
|
|
332
|
+
if (!stat.isFile()) {
|
|
333
|
+
throw new Error(`${label} path is not a file: ${filePath}`);
|
|
201
334
|
}
|
|
335
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
202
336
|
}
|