Spaces:
Running
Running
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);
});
}
|