File size: 12,461 Bytes
3e8ea5d
 
805101e
 
3e8ea5d
 
 
805101e
3e8ea5d
ecb6084
 
 
 
3e8ea5d
 
 
 
 
ecb6084
 
805101e
 
3e8ea5d
 
ecb6084
3e8ea5d
 
ecb6084
 
 
 
 
 
 
 
 
 
 
 
 
3e8ea5d
 
ecb6084
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e8ea5d
 
 
805101e
 
 
3e8ea5d
ecb6084
3e8ea5d
 
ecb6084
805101e
 
3e8ea5d
ecb6084
 
3e8ea5d
 
 
 
 
 
ecb6084
3e8ea5d
 
 
ecb6084
805101e
 
3e8ea5d
 
 
 
805101e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ecb6084
 
 
 
 
 
 
 
 
 
 
 
 
3e8ea5d
 
ecb6084
 
 
 
 
 
 
 
 
3e8ea5d
ecb6084
3e8ea5d
 
ecb6084
3e8ea5d
 
ecb6084
3e8ea5d
 
 
 
 
 
 
ecb6084
3e8ea5d
 
 
 
 
 
ecb6084
 
 
 
 
 
3e8ea5d
ecb6084
 
 
 
 
3e8ea5d
ecb6084
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e8ea5d
 
 
 
 
 
 
 
 
805101e
ecb6084
3e8ea5d
ecb6084
3e8ea5d
 
 
 
 
 
 
ecb6084
 
3e8ea5d
ecb6084
3e8ea5d
 
 
ecb6084
3e8ea5d
ecb6084
3e8ea5d
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
#!/usr/bin/env node

import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';

import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
import { parseEnvContent } from './env-summary.mjs';

const CONTAINER_NAME = 'gpt-image-playground-customer';
const IMAGE_REPOSITORY = 'gpt-image-playground-customer';
const DEFAULT_BIND_HOST = '127.0.0.1';
const DEFAULT_HOST_PORT = '4783';
const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities'];
const PROBE_ATTEMPTS = 30;
const PROBE_INTERVAL_MS = 2000;
const PROBE_TIMEOUT_MS = 5000;
const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000;
const DOCKER_COMPOSE_WAIT_TIMEOUT_SECONDS = 120;
const GIT_REVISION_PATTERN = /^[0-9a-f]{40}$/i;
const IMAGE_AUTO_CLEANUP_ENABLED_ENV = 'WEBUI_IMAGE_AUTO_CLEANUP_ENABLED';
const COMPOSE_ENV_FILE = '.env.local';

export function buildDockerComposeArgs(options = {}) {
    assertSingleDeploymentMode(options);
    const files = ['-f', 'docker-compose.yml'];
    if (options.memory) files.push('-f', 'docker-compose.memory.yml');
    if (options.postgres) files.push('-f', 'docker-compose.postgres.yml');
    return [
        'compose',
        ...files,
        'up',
        '-d',
        '--build',
        '--force-recreate',
        '--remove-orphans',
        '--wait',
        '--wait-timeout',
        String(DOCKER_COMPOSE_WAIT_TIMEOUT_SECONDS)
    ];
}

function assertSingleDeploymentMode(options = {}) {
    if (options.memory && options.postgres) {
        throw new Error('--memory 和 --postgres 不能同时使用。');
    }
}

export function buildDockerComposeEnv(env = process.env, deployment) {
    return {
        ...env,
        COMPOSE_PROGRESS: 'plain',
        ...(deployment
            ? {
                  GIP_IMAGE_REVISION: deployment.revision,
                  GIP_IMAGE_TAG: deployment.imageTag
              }
            : {})
    };
}

export function buildDeploymentImageTag(revision) {
    const normalized = revision?.trim().toLowerCase();
    if (!GIT_REVISION_PATTERN.test(normalized || '')) throw new Error('Git revision 必须是完整的 40 位 SHA。');
    return `local-${normalized}`;
}

export function buildDeploymentImageReference(revision) {
    return `${IMAGE_REPOSITORY}:${buildDeploymentImageTag(revision)}`;
}

export function buildLocalBaseUrl(bindHost = DEFAULT_BIND_HOST, hostPort = DEFAULT_HOST_PORT) {
    const host = bindHost.trim();
    const port = hostPort.trim();
    if (!host) throw new Error('GIP_BIND_HOST 不能为空。');
    if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
        throw new Error(`GIP_PORT 必须是 1 到 65535 的整数,收到:${hostPort}`);
    }

    const probeHost = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '[::1]' : host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
    return `http://${probeHost}:${port}`;
}

export function parsePublishedContainerPortBindings(output) {
    let bindings;
    try {
        bindings = JSON.parse(output);
    } catch {
        throw new Error('无法解析 Docker 容器端口映射。');
    }
    if (!Array.isArray(bindings) || bindings.length === 0) {
        throw new Error('Docker 容器未发布 4783/tcp 端口。');
    }

    const binding = bindings.find((entry) => typeof entry?.HostIp === 'string' && !entry.HostIp.includes(':')) || bindings[0];
    const bindHost = typeof binding?.HostIp === 'string' && binding.HostIp.trim() ? binding.HostIp.trim() : '0.0.0.0';
    const hostPort = typeof binding?.HostPort === 'string' ? binding.HostPort.trim() : '';
    return { bindHost, hostPort, baseUrl: buildLocalBaseUrl(bindHost, hostPort) };
}

export function assertDeploymentImageIdentity(identity, deployment) {
    const expectedImage = buildDeploymentImageReference(deployment.revision);
    if (identity.image !== expectedImage) {
        throw new Error(`运行容器镜像不匹配:expected ${expectedImage}, received ${identity.image || '<missing>'}。`);
    }
    if (identity.revision !== deployment.revision) {
        throw new Error(
            `运行镜像 revision 不匹配:expected ${deployment.revision}, received ${identity.revision || '<missing>'}。`
        );
    }
}

function parseArgs(argv) {
    const unknown = argv.find(
        (arg) => !['--help', '-h', '--memory', '--postgres', '--skip-probe', '--allow-image-auto-cleanup'].includes(arg)
    );
    if (unknown) throw new Error(`Unknown option: ${unknown}`);
    const options = {
        help: argv.includes('--help') || argv.includes('-h'),
        memory: argv.includes('--memory'),
        postgres: argv.includes('--postgres'),
        skipProbe: argv.includes('--skip-probe'),
        allowImageAutoCleanup: argv.includes('--allow-image-auto-cleanup')
    };
    assertSingleDeploymentMode(options);
    return options;
}

function printHelp() {
    console.log(`Usage:
  npm run deploy:local
  npm run deploy:local -- --memory
  npm run deploy:local -- --postgres

Options:
  --memory       Use docker-compose.memory.yml overlay for HF Space-like memory mode.
  --postgres     Use docker-compose.postgres.yml and require GPT_IMAGE_POSTGRES_PASSWORD.
  --allow-image-auto-cleanup
                 Confirm that the configured WebUI automatic image cleanup may run after deployment.
  --skip-probe   Rebuild and start the container without HTTP endpoint probes.
  --help         Show this help.`);
}

export function isImageAutoCleanupEnabled(value) {
    return ['1', 'true', 'yes', 'on'].includes(value?.trim().toLowerCase());
}

export function assertImageAutoCleanupDeploymentAllowed(env = process.env, options = {}) {
    if (!isImageAutoCleanupEnabled(env[IMAGE_AUTO_CLEANUP_ENABLED_ENV])) return;
    if (options.allowImageAutoCleanup) return;
    throw new Error(
        `检测到 ${IMAGE_AUTO_CLEANUP_ENABLED_ENV} 已启用。部署会在服务启动后执行自动图片清理;如已确认,添加 --allow-image-auto-cleanup 后重试。`
    );
}

export function readImageAutoCleanupValueFromComposeEnvFile(cwd = process.cwd()) {
    const filepath = path.join(cwd, COMPOSE_ENV_FILE);
    if (!existsSync(filepath)) return undefined;
    let configuredValue;
    for (const entry of parseEnvContent(readFileSync(filepath, 'utf8'))) {
        if (entry.name === IMAGE_AUTO_CLEANUP_ENABLED_ENV) configuredValue = entry.value;
    }
    return configuredValue;
}

export function assertComposeImageAutoCleanupDeploymentAllowed(options = {}, cwd = process.cwd()) {
    return assertImageAutoCleanupDeploymentAllowed(
        { [IMAGE_AUTO_CLEANUP_ENABLED_ENV]: readImageAutoCleanupValueFromComposeEnvFile(cwd) },
        options
    );
}

function readCleanGitRevision() {
    const revisionResult = runCommand('git', ['rev-parse', '--verify', 'HEAD']);
    if (!revisionResult.ok) throw new Error(`无法读取当前 Git revision:${pickFailureOutput(revisionResult)}`);

    const revision = revisionResult.stdout.trim().toLowerCase();
    const imageTag = buildDeploymentImageTag(revision);
    const statusResult = runCommand('git', ['status', '--porcelain=v1', '--untracked-files=all']);
    if (!statusResult.ok) throw new Error(`无法检查 Git 工作区状态:${pickFailureOutput(statusResult)}`);
    if (statusResult.stdout) {
        throw new Error('拒绝部署脏工作区。请先提交或清理当前改动,再运行 npm run deploy:local。');
    }

    return { revision, imageTag };
}

async function fetchJson(path, baseUrl) {
    return fetchJsonWithTimeout(new URL(path, baseUrl), { timeoutMs: PROBE_TIMEOUT_MS });
}

export async function waitForLocalEndpoints(baseUrl, options = {}) {
    const attempts = options.attempts ?? PROBE_ATTEMPTS;
    const intervalMs = options.intervalMs ?? PROBE_INTERVAL_MS;
    const requestJson = options.fetchJson || fetchJson;
    const sleep = options.sleep || delay;
    let lastError = '';
    for (let attempt = 1; attempt <= attempts; attempt += 1) {
        try {
            const responses = {};
            for (const path of PROBE_PATHS) responses[path] = await requestJson(path, baseUrl);
            return {
                attempts: attempt,
                baseUrl,
                authRequired: responses['/api/auth-status'].passwordRequired,
                stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend,
                imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode,
                streamingBatch: responses['/api/runtime-capabilities'].streamingBatch
            };
        } catch (error) {
            lastError = error instanceof Error ? error.message : String(error);
            if (attempt < attempts) await sleep(intervalMs);
        }
    }
    throw new Error(`Local container did not pass HTTP probes: ${lastError}`);
}

export function assertLocalProbeMatchesMode(probe, options = {}) {
    assertSingleDeploymentMode(options);
    const expected = options.memory
        ? { label: 'Memory', stateBackend: 'memory', imageStorageMode: 'indexeddb' }
        : options.postgres
          ? { label: 'PostgreSQL', stateBackend: 'postgres', imageStorageMode: 'fs' }
          : { label: 'SQLite', stateBackend: 'sqlite', imageStorageMode: 'fs' };
    const mismatches = [];
    if (probe.stateBackend !== expected.stateBackend) {
        mismatches.push(`stateBackend=${probe.stateBackend ?? '<missing>'} expected ${expected.stateBackend}`);
    }
    if (probe.imageStorageMode !== expected.imageStorageMode) {
        mismatches.push(`imageStorageMode=${probe.imageStorageMode ?? '<missing>'} expected ${expected.imageStorageMode}`);
    }
    if (mismatches.length) throw new Error(`${expected.label} deployment mode did not take effect: ${mismatches.join(', ')}.`);
}

function inspectDeploymentImage(deployment) {
    const expectedImage = buildDeploymentImageReference(deployment.revision);
    const containerImage = runCommand('docker', ['inspect', '--format', '{{.Config.Image}}', CONTAINER_NAME]);
    if (!containerImage.ok) throw new Error(`无法读取部署容器镜像:${pickFailureOutput(containerImage)}`);

    const imageRevision = runCommand('docker', [
        'image',
        'inspect',
        '--format',
        '{{ index .Config.Labels "org.opencontainers.image.revision" }}',
        expectedImage
    ]);
    if (!imageRevision.ok) throw new Error(`无法读取部署镜像 revision:${pickFailureOutput(imageRevision)}`);

    const identity = { image: containerImage.stdout.trim(), revision: imageRevision.stdout.trim().toLowerCase() };
    assertDeploymentImageIdentity(identity, deployment);
    return identity;
}

function inspectPublishedContainerPort() {
    const result = runCommand('docker', [
        'inspect',
        '--format',
        '{{json (index .NetworkSettings.Ports "4783/tcp")}}',
        CONTAINER_NAME
    ]);
    if (!result.ok) throw new Error(`无法读取部署容器端口映射:${pickFailureOutput(result)}`);
    return parsePublishedContainerPortBindings(result.stdout.trim());
}

async function main() {
    const options = parseArgs(process.argv.slice(2));
    if (options.help) {
        printHelp();
        return;
    }

    assertComposeImageAutoCleanupDeploymentAllowed(options);
    const deployment = readCleanGitRevision();
    const docker = runCommand('docker', buildDockerComposeArgs(options), {
        env: buildDockerComposeEnv(process.env, deployment),
        timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS
    });
    if (!docker.ok) {
        printJson({ ok: false, phase: 'docker-compose', output: pickFailureOutput(docker) });
        process.exit(1);
    }

    const image = inspectDeploymentImage(deployment);
    const localConfig = inspectPublishedContainerPort();
    if (options.skipProbe) {
        printJson({ ok: true, phase: 'docker-compose', image, probe: 'skipped' });
        return;
    }

    const probe = await waitForLocalEndpoints(localConfig.baseUrl);
    assertLocalProbeMatchesMode(probe, options);
    printJson({ ok: true, phase: 'ready', deployment: { ...deployment, ...localConfig }, image, probe });
}

if (isMainModule(import.meta.url, process.argv[1])) {
    main().catch((error) => {
        printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
        process.exit(1);
    });
}