769 lines
27 KiB
PHP
769 lines
27 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Channel;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Process;
|
||
use RuntimeException;
|
||
|
||
class ChannelProjectService
|
||
{
|
||
public const SUPPORT_TEMPLATES = ['test', 'blank'];
|
||
|
||
public const DEFAULT_SUPPORT_TEMPLATE = 'blank';
|
||
|
||
public const BUILDER_OLD = Channel::BUILDER_OLD;
|
||
|
||
public const BUILDER_NEW = Channel::BUILDER_NEW;
|
||
|
||
public const RESULT_MARKER = 'CORUNA_BUILD_RESULT ';
|
||
|
||
/**
|
||
* Build channel web assets via the old or new channel builder.
|
||
*
|
||
* @return array{
|
||
* channel_id: string,
|
||
* builder_type: string,
|
||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||
* seeds_initialized: bool,
|
||
* sync_rebuilt: bool,
|
||
* support_path: string,
|
||
* weifile_path: ?string,
|
||
* daily_path: string,
|
||
* support_template?: string,
|
||
* }
|
||
*/
|
||
public function generate(
|
||
string $channelId,
|
||
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
|
||
?string $deploymentSeed = null,
|
||
?string $reportingSeed = null,
|
||
string $builderType = self::BUILDER_OLD,
|
||
): array {
|
||
$builderType = $this->normalizeBuilderType($builderType);
|
||
|
||
if ($builderType === self::BUILDER_NEW) {
|
||
return $this->generateNew(
|
||
$channelId,
|
||
$supportTemplate,
|
||
dsDomain: (string) config('coruna.xxbb.ds_domain', ''),
|
||
);
|
||
}
|
||
|
||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
|
||
$deploymentSeed,
|
||
$reportingSeed,
|
||
);
|
||
|
||
return $this->generateOld(
|
||
$this->normalizeChannelId($channelId),
|
||
$supportTemplate,
|
||
$deploymentSeed,
|
||
$reportingSeed,
|
||
);
|
||
}
|
||
|
||
public function deleteWebTree(
|
||
string $channelId,
|
||
string $builderType = self::BUILDER_OLD,
|
||
): void {
|
||
$builderType = $this->normalizeBuilderType($builderType);
|
||
|
||
if ($builderType === self::BUILDER_NEW) {
|
||
$code = Channel::normalizeNewChannelId($channelId);
|
||
if ($code === null) {
|
||
return;
|
||
}
|
||
$dir = $this->newChannelDirPath($code);
|
||
if (is_dir($dir) && ! $this->removeDirectory($dir)) {
|
||
throw new RuntimeException('删除渠道资源失败: '.$dir);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$channelId = $this->normalizeChannelId($channelId);
|
||
} catch (RuntimeException) {
|
||
return;
|
||
}
|
||
|
||
$cmd = [
|
||
$this->pythonBinary($builderType),
|
||
$this->builderScript('delete_channel_web.py', $builderType),
|
||
'--artifact-root',
|
||
$this->artifactRoot(),
|
||
'--channel-id',
|
||
$channelId,
|
||
];
|
||
|
||
$this->runBuilder($cmd, '删除渠道资源失败', $this->builderCwd($builderType));
|
||
}
|
||
|
||
/**
|
||
* @return array{
|
||
* channel_id: string,
|
||
* builder_type: string,
|
||
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
|
||
* domains: array{deployment: list<string>, reporting: list<string>},
|
||
* seeds_initialized: bool,
|
||
* sync_rebuilt: bool,
|
||
* support_path: string,
|
||
* weifile_path: ?string,
|
||
* daily_path: string,
|
||
* support_template?: string,
|
||
* }
|
||
*/
|
||
private function generateOld(
|
||
string $channelId,
|
||
string $supportTemplate,
|
||
?string $deploymentSeed,
|
||
?string $reportingSeed,
|
||
): array {
|
||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||
$cmd = [
|
||
$this->pythonBinary(self::BUILDER_OLD),
|
||
$this->builderScript('new_project.py', self::BUILDER_OLD),
|
||
'--artifact-root',
|
||
$this->artifactRoot(),
|
||
'--state-root',
|
||
$this->stateRoot(self::BUILDER_OLD),
|
||
'--channel-id',
|
||
$channelId,
|
||
'--support-template',
|
||
$supportTemplate,
|
||
'--force',
|
||
];
|
||
if ($deploymentSeed !== null && $reportingSeed !== null) {
|
||
$cmd[] = '--deployment-seed';
|
||
$cmd[] = $deploymentSeed;
|
||
$cmd[] = '--reporting-seed';
|
||
$cmd[] = $reportingSeed;
|
||
}
|
||
|
||
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD));
|
||
$supportPath = (string) ($result['support_path'] ?? '/web/'.$channelId.'/support.html');
|
||
|
||
return [
|
||
'channel_id' => (string) ($result['channel_id'] ?? $channelId),
|
||
'builder_type' => self::BUILDER_OLD,
|
||
'seeds' => [
|
||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
|
||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
|
||
],
|
||
'domains' => [
|
||
'deployment' => array_values((array) data_get($result, 'domains.deployment', [])),
|
||
'reporting' => array_values((array) data_get($result, 'domains.reporting', [])),
|
||
],
|
||
'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false),
|
||
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
|
||
'support_path' => $supportPath,
|
||
'weifile_path' => null,
|
||
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
|
||
'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Rebuild shared /details and staged weifile from shared channel `c`.
|
||
* Channel create always runs this with XXBB_CHANNEL_C from env (no random).
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function buildSharedArtifacts(?string $channelC = null, bool $randomC = false): array
|
||
{
|
||
if ($randomC && $channelC !== null && $channelC !== '') {
|
||
throw new RuntimeException('不能同时指定 channel-c 和 random-c');
|
||
}
|
||
|
||
$cmd = [
|
||
$this->pythonBinary(self::BUILDER_NEW),
|
||
$this->builderScript('build.py', self::BUILDER_NEW),
|
||
'--artifact-root',
|
||
$this->artifactRoot(),
|
||
'--state-root',
|
||
$this->stateRoot(self::BUILDER_NEW),
|
||
'--apply',
|
||
'--force',
|
||
];
|
||
if ($randomC) {
|
||
$cmd[] = '--random-c';
|
||
} else {
|
||
$c = $this->normalizeSharedChannelC($channelC);
|
||
if ($c === null) {
|
||
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
|
||
}
|
||
$cmd[] = '--channel-c';
|
||
$cmd[] = $c;
|
||
}
|
||
|
||
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
|
||
}
|
||
|
||
/**
|
||
* Rebuild existing new-builder channels in place (same channel_id / ver patch).
|
||
* Shared /details + staged weifile are built once from XXBB_CHANNEL_C, then each
|
||
* public/channel/{id}/ tree is overwritten. Channel DB rows are not changed.
|
||
*
|
||
* @param list<string>|null $channelIds null = all builder_type=new rows
|
||
* @return array{shared: array<string, mixed>, channels: list<array<string, mixed>>}
|
||
*/
|
||
public function rebuildNewChannels(
|
||
?array $channelIds = null,
|
||
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
|
||
bool $rebuildShared = true,
|
||
string $dsDomain = '',
|
||
): array {
|
||
$ids = $this->resolveNewChannelIds($channelIds);
|
||
if ($ids === []) {
|
||
throw new RuntimeException('没有可重打的新版渠道(builder_type=new)');
|
||
}
|
||
|
||
$shared = [];
|
||
if ($rebuildShared) {
|
||
$shared = $this->buildSharedArtifacts($this->requireEnvChannelC(), false);
|
||
}
|
||
|
||
$channels = [];
|
||
foreach ($ids as $id) {
|
||
$channels[] = $this->generateNew($id, $supportTemplate, rebuildShared: false, dsDomain: $dsDomain);
|
||
}
|
||
|
||
return [
|
||
'shared' => $shared,
|
||
'channels' => $channels,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param list<string>|null $channelIds
|
||
* @return list<string>
|
||
*/
|
||
public function resolveNewChannelIds(?array $channelIds = null): array
|
||
{
|
||
if ($channelIds === null) {
|
||
return Channel::query()
|
||
->where('builder_type', self::BUILDER_NEW)
|
||
->orderBy('id')
|
||
->pluck('channel_id')
|
||
->map(static fn ($id) => Channel::normalizeNewChannelId((string) $id))
|
||
->filter()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
$ids = [];
|
||
foreach ($channelIds as $raw) {
|
||
$normalized = Channel::normalizeNewChannelId((string) $raw);
|
||
if ($normalized === null) {
|
||
throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1): '.$raw);
|
||
}
|
||
$ids[] = $normalized;
|
||
}
|
||
|
||
return array_values(array_unique($ids));
|
||
}
|
||
|
||
private function generateNew(
|
||
string $channelId,
|
||
string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE,
|
||
bool $rebuildShared = true,
|
||
string $dsDomain = '',
|
||
): array {
|
||
$channelId = Channel::normalizeNewChannelId($channelId);
|
||
if ($channelId === null) {
|
||
throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)');
|
||
}
|
||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||
|
||
// Shared details + staged weifile always rebuilt from env XXBB_CHANNEL_C (never random).
|
||
if ($rebuildShared) {
|
||
$this->buildSharedArtifacts($this->requireEnvChannelC(), false);
|
||
}
|
||
|
||
$weifileSrc = $this->stagedWeifileDir();
|
||
$detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details';
|
||
$channelOut = $this->newChannelDirPath($channelId);
|
||
$this->ensureDirectory(dirname($channelOut), '新版渠道产物目录');
|
||
|
||
$cmd = [
|
||
$this->pythonBinary(self::BUILDER_NEW),
|
||
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
|
||
'--channel-ver',
|
||
$channelId,
|
||
'--state-root',
|
||
$this->stateRoot(self::BUILDER_NEW),
|
||
'--weifile-src',
|
||
$weifileSrc,
|
||
'--details-src',
|
||
$detailsSrc,
|
||
'--channel-out',
|
||
$channelOut,
|
||
'--landing-template',
|
||
$supportTemplate,
|
||
];
|
||
if ($dsDomain !== '') {
|
||
$cmd[] = '--ds-domain';
|
||
$cmd[] = $dsDomain;
|
||
}
|
||
|
||
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
|
||
$seeds = $this->loadNewLabSeeds();
|
||
$landing = (string) ($result['landing_path'] ?? '/channel/'.$channelId.'/weifile/weifile.html');
|
||
$detailsPath = (string) ($result['details_path'] ?? '/channel/'.$channelId.'/details/');
|
||
|
||
return [
|
||
'channel_id' => $channelId,
|
||
'builder_type' => self::BUILDER_NEW,
|
||
'seeds' => [
|
||
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', $seeds['deployment_seed']),
|
||
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', $seeds['reporting_seed']),
|
||
'channel_c' => (string) data_get($result, 'seeds.channel_c', $seeds['channel_c']),
|
||
],
|
||
'domains' => [
|
||
'deployment' => array_values((array) data_get($result, 'domains.deployment', $seeds['domains']['deployment'])),
|
||
'reporting' => array_values((array) data_get($result, 'domains.reporting', $seeds['domains']['reporting'])),
|
||
],
|
||
'seeds_initialized' => false,
|
||
'sync_rebuilt' => false,
|
||
'support_path' => $landing,
|
||
'weifile_path' => null,
|
||
'daily_path' => $detailsPath,
|
||
'channel_dir' => '/channel/'.$channelId,
|
||
'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'),
|
||
'support_template' => (string) ($result['landing_template'] ?? $supportTemplate),
|
||
'ds_domain' => $dsDomain,
|
||
];
|
||
}
|
||
|
||
public function stagedWeifileDir(): string
|
||
{
|
||
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
|
||
}
|
||
|
||
public function newChannelDirPath(string $channelId): string
|
||
{
|
||
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
|
||
|
||
return $this->artifactRoot().DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$code;
|
||
}
|
||
|
||
private function removeDirectory(string $dir): bool
|
||
{
|
||
if (! is_dir($dir)) {
|
||
return true;
|
||
}
|
||
$items = scandir($dir);
|
||
if ($items === false) {
|
||
return false;
|
||
}
|
||
foreach ($items as $item) {
|
||
if ($item === '.' || $item === '..') {
|
||
continue;
|
||
}
|
||
$path = $dir.DIRECTORY_SEPARATOR.$item;
|
||
if (is_dir($path)) {
|
||
if (! $this->removeDirectory($path)) {
|
||
return false;
|
||
}
|
||
} elseif (! unlink($path)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return rmdir($dir);
|
||
}
|
||
|
||
/**
|
||
* @return array{deployment_seed: string, reporting_seed: string, channel_c: string, domains: array{deployment: list<string>, reporting: list<string>}}
|
||
*/
|
||
private function loadNewLabSeeds(): array
|
||
{
|
||
$empty = [
|
||
'deployment_seed' => '',
|
||
'reporting_seed' => '',
|
||
'channel_c' => '',
|
||
'domains' => ['deployment' => [], 'reporting' => []],
|
||
];
|
||
$path = $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'lab_seeds.json';
|
||
if (! is_file($path)) {
|
||
return $empty;
|
||
}
|
||
$decoded = json_decode((string) file_get_contents($path), true);
|
||
if (! is_array($decoded)) {
|
||
return $empty;
|
||
}
|
||
|
||
return [
|
||
'deployment_seed' => (string) ($decoded['deployment_seed'] ?? ''),
|
||
'reporting_seed' => (string) ($decoded['reporting_seed'] ?? ''),
|
||
'channel_c' => (string) ($decoded['channel_c'] ?? ''),
|
||
'domains' => [
|
||
'deployment' => array_values((array) data_get($decoded, 'domains.deployment', [])),
|
||
'reporting' => array_values((array) data_get($decoded, 'domains.reporting', [])),
|
||
],
|
||
];
|
||
}
|
||
|
||
private function requireEnvChannelC(): string
|
||
{
|
||
$c = $this->normalizeSharedChannelC(null);
|
||
if ($c === null) {
|
||
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
|
||
}
|
||
|
||
return $c;
|
||
}
|
||
|
||
private function normalizeSharedChannelC(?string $channelC): ?string
|
||
{
|
||
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
|
||
? $channelC
|
||
: config('coruna.xxbb.channel_c', ''))));
|
||
if ($c === '') {
|
||
return null;
|
||
}
|
||
if (! preg_match('/^[0-9a-f]{32}$/', $c)) {
|
||
throw new RuntimeException('XXBB_CHANNEL_C 必须是 32 位 hex');
|
||
}
|
||
if ($c === '202800cfb1ad3de68e11239dcc26c30b') {
|
||
throw new RuntimeException('XXBB_CHANNEL_C 不能与 7z 密码槽相同');
|
||
}
|
||
|
||
return $c;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $cmd
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function runBuilder(array $cmd, string $errorPrefix, string $cwd): array
|
||
{
|
||
$timeout = (float) config('coruna.channel_builder.timeout', 600);
|
||
$probe = $this->builderProbe($cmd, $cwd);
|
||
Log::info('channel_builder start', $probe + [
|
||
'error_prefix' => $errorPrefix,
|
||
'timeout' => $timeout,
|
||
]);
|
||
|
||
$started = microtime(true);
|
||
$process = Process::timeout((int) max(1, $timeout))
|
||
->path($cwd)
|
||
->run($cmd);
|
||
$ms = (int) ((microtime(true) - $started) * 1000);
|
||
$stdout = trim($process->output());
|
||
$stderr = trim($process->errorOutput());
|
||
|
||
if (! $process->successful()) {
|
||
$hint = trim($this->builderFailHint((int) $process->exitCode(), $cmd, $cwd).' '
|
||
.$this->hardeningHint($stderr."\n".$stdout, $process->exitCode()));
|
||
Log::error('channel_builder failed', $probe + [
|
||
'error_prefix' => $errorPrefix,
|
||
'exit_code' => $process->exitCode(),
|
||
'ms' => $ms,
|
||
'stdout' => mb_substr($stdout, 0, 2000),
|
||
'stderr' => mb_substr($stderr, 0, 2000),
|
||
'hint' => $hint,
|
||
]);
|
||
$detail = $stderr !== '' ? $stderr : $stdout;
|
||
if ($detail === '') {
|
||
$detail = 'builder exited '.$process->exitCode();
|
||
}
|
||
if ($hint !== '') {
|
||
$detail .= ';'.$hint;
|
||
}
|
||
|
||
throw new RuntimeException($errorPrefix.': '.mb_substr($detail, 0, 2500));
|
||
}
|
||
|
||
Log::info('channel_builder ok', [
|
||
'error_prefix' => $errorPrefix,
|
||
'ms' => $ms,
|
||
'cwd' => $cwd,
|
||
'python' => $cmd[0] ?? '',
|
||
]);
|
||
|
||
return $this->parseResultMarker($process->output(), $errorPrefix);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $cmd
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function builderProbe(array $cmd, string $cwd): array
|
||
{
|
||
$python = (string) ($cmd[0] ?? '');
|
||
$script = (string) ($cmd[1] ?? '');
|
||
$uid = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid();
|
||
$user = function_exists('posix_getpwuid')
|
||
? ((posix_getpwuid((int) $uid)['name'] ?? null) ?: (string) $uid)
|
||
: (string) $uid;
|
||
|
||
return [
|
||
'cwd' => $cwd,
|
||
'cwd_exists' => is_dir($cwd),
|
||
'cmd' => $cmd,
|
||
'php_user' => $user,
|
||
'php_uid' => $uid,
|
||
'python' => $python,
|
||
'python_is_abs' => $python !== '' && $python[0] === '/',
|
||
'python_is_link' => $python !== '' && $this->pathIsLink($python),
|
||
'python_is_file' => $python !== '' && @is_file($python),
|
||
'python_link' => $python !== '' ? ($this->symlinkTarget($python) ?: null) : null,
|
||
'script' => $script,
|
||
'script_exists' => $script !== '' && is_file($script),
|
||
'path_env' => (string) (getenv('PATH') ?: ''),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $cmd
|
||
*/
|
||
private function builderFailHint(int $exit, array $cmd, string $cwd): string
|
||
{
|
||
if ($exit !== 127) {
|
||
return '';
|
||
}
|
||
$python = (string) ($cmd[0] ?? '');
|
||
$script = (string) ($cmd[1] ?? '');
|
||
$bits = ['exit 127 = 命令不存在'];
|
||
if ($python === '' || $python === 'python3') {
|
||
$bits[] = '未找到可用 python(.env CORUNA_CHANNEL_BUILDER_NEW_PYTHON 为空且无 .venv)';
|
||
} elseif (! @is_file($python) && ! $this->pathIsLink($python)) {
|
||
$bits[] = '解释器路径不存在: '.$python;
|
||
} else {
|
||
$target = $this->symlinkTarget($python);
|
||
if ($target !== '') {
|
||
$bits[] = 'venv python 软链指向 '.$target.'(目标机上可能没有这个 python)';
|
||
}
|
||
}
|
||
if ($script !== '' && ! is_file($script)) {
|
||
$bits[] = '脚本不存在: '.$script;
|
||
}
|
||
if (! is_dir($cwd)) {
|
||
$bits[] = '工作目录不存在: '.$cwd;
|
||
}
|
||
|
||
return implode(';', $bits);
|
||
}
|
||
|
||
private function hardeningHint(string $output, ?int $exit = null): string
|
||
{
|
||
$hay = strtolower($output);
|
||
if (str_contains($hay, 'tips from bt security')
|
||
|| str_contains($hay, 'your request has been recorded')
|
||
|| $exit === 9 || $exit === 137) {
|
||
return '堡塔防入侵拦截了 www 执行 python。软件商店 → 堡塔防入侵 → 看 www 拦截日志,把 venv python 与 /usr/bin/python3.10 加白后再建渠道';
|
||
}
|
||
if (str_contains($hay, 'permission denied')) {
|
||
return '属主不对(root 跑过构建后 www 删不掉旧文件)。执行: '
|
||
.'chown -R www:www /www/wwwroot/coruna-lab/storage /www/wwwroot/coruna-lab/public/details /www/wwwroot/coruna-lab/public/channel';
|
||
}
|
||
if (! str_contains($hay, 'py7zr') && ! str_contains($hay, 'cannot open shared object')) {
|
||
return '';
|
||
}
|
||
|
||
return 'venv 缺包或 .so 不可用。用 .venv/bin/pip install -r requirements.txt,'
|
||
.'并 chmod 755 /usr/bin/python3.10 与 venv 下 *.so';
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function parseResultMarker(string $output, string $errorPrefix): array
|
||
{
|
||
$lines = preg_split("/\r\n|\n|\r/", $output) ?: [];
|
||
for ($i = count($lines) - 1; $i >= 0; $i--) {
|
||
$line = $lines[$i];
|
||
if (! str_starts_with($line, self::RESULT_MARKER)) {
|
||
continue;
|
||
}
|
||
$json = substr($line, strlen(self::RESULT_MARKER));
|
||
$decoded = json_decode($json, true);
|
||
if (! is_array($decoded)) {
|
||
throw new RuntimeException("{$errorPrefix}: invalid builder JSON");
|
||
}
|
||
|
||
return $decoded;
|
||
}
|
||
|
||
throw new RuntimeException("{$errorPrefix}: missing builder result marker");
|
||
}
|
||
|
||
private function pythonBinary(string $builderType): string
|
||
{
|
||
$configKey = $builderType === self::BUILDER_NEW
|
||
? 'coruna.channel_builder_new.python'
|
||
: 'coruna.channel_builder.python';
|
||
$configured = trim((string) config($configKey, ''));
|
||
if ($configured !== '') {
|
||
// Trust .env: do not is_file() — venv python is usually a symlink to
|
||
// /usr/bin/python*, which fatals under panel open_basedir.
|
||
return $configured;
|
||
}
|
||
|
||
$venv = $this->builderCwd($builderType).'/.venv/bin/python';
|
||
if ($this->venvPythonExists($venv)) {
|
||
return $venv;
|
||
}
|
||
|
||
if ($builderType === self::BUILDER_NEW) {
|
||
$fallback = base_path('channel-builder/.venv/bin/python');
|
||
if ($this->venvPythonExists($fallback)) {
|
||
return $fallback;
|
||
}
|
||
}
|
||
|
||
return 'python3';
|
||
}
|
||
|
||
/**
|
||
* Detect .venv/bin/python without following the symlink into /usr/bin
|
||
* (open_basedir typically allows the project root only).
|
||
*/
|
||
private function venvPythonExists(string $path): bool
|
||
{
|
||
clearstatcache(true, $path);
|
||
|
||
// is_link() checks the link inode inside the project; does not resolve target.
|
||
if ($this->pathIsLink($path)) {
|
||
return true;
|
||
}
|
||
|
||
return @is_file($path);
|
||
}
|
||
|
||
private function pathIsLink(string $path): bool
|
||
{
|
||
return function_exists('is_link') && @is_link($path);
|
||
}
|
||
|
||
private function symlinkTarget(string $path): string
|
||
{
|
||
if (! function_exists('readlink') || ! $this->pathIsLink($path)) {
|
||
return '';
|
||
}
|
||
$target = @\readlink($path);
|
||
|
||
return is_string($target) ? $target : '';
|
||
}
|
||
|
||
private function builderScript(string $name, string $builderType): string
|
||
{
|
||
$path = $this->builderCwd($builderType).'/tools/'.$name;
|
||
if (! is_file($path)) {
|
||
throw new RuntimeException("渠道构建脚本不存在: {$path}");
|
||
}
|
||
|
||
return $path;
|
||
}
|
||
|
||
private function builderCwd(string $builderType): string
|
||
{
|
||
return $builderType === self::BUILDER_NEW
|
||
? base_path('channel-builder-new')
|
||
: base_path('channel-builder');
|
||
}
|
||
|
||
private function artifactRoot(): string
|
||
{
|
||
$root = trim((string) config('coruna.channel_builder.artifact_root', ''));
|
||
if ($root === '') {
|
||
$root = public_path();
|
||
}
|
||
|
||
return $this->ensureDirectory($root, '产物目录');
|
||
}
|
||
|
||
private function stateRoot(string $builderType): string
|
||
{
|
||
$key = $builderType === self::BUILDER_NEW
|
||
? 'coruna.channel_builder_new.state_root'
|
||
: 'coruna.channel_builder.state_root';
|
||
$root = trim((string) config($key, ''));
|
||
if ($root === '') {
|
||
$root = $builderType === self::BUILDER_NEW
|
||
? storage_path('app/channel-builder-new')
|
||
: storage_path('app/channel-builder');
|
||
}
|
||
|
||
return $this->ensureDirectory($root, '构建状态目录');
|
||
}
|
||
|
||
private function ensureDirectory(string $root, string $label): string
|
||
{
|
||
if (! is_dir($root) && ! mkdir($root, 0775, true) && ! is_dir($root)) {
|
||
throw new RuntimeException("无法创建{$label}: {$root}");
|
||
}
|
||
|
||
return $root;
|
||
}
|
||
|
||
private function normalizeBuilderType(string $builderType): string
|
||
{
|
||
$builderType = strtolower(trim($builderType));
|
||
if ($builderType === '') {
|
||
return self::BUILDER_OLD;
|
||
}
|
||
if (! in_array($builderType, [self::BUILDER_OLD, self::BUILDER_NEW], true)) {
|
||
throw new RuntimeException('无效的渠道类型(支持: old, new)');
|
||
}
|
||
|
||
return $builderType;
|
||
}
|
||
|
||
private function normalizeChannelId(string $channelId): string
|
||
{
|
||
$channelId = strtolower(trim($channelId));
|
||
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
|
||
throw new RuntimeException('Invalid channel id');
|
||
}
|
||
|
||
return $channelId;
|
||
}
|
||
|
||
private function normalizeSupportTemplate(string $supportTemplate): string
|
||
{
|
||
$supportTemplate = strtolower(trim($supportTemplate));
|
||
if ($supportTemplate === '') {
|
||
return self::DEFAULT_SUPPORT_TEMPLATE;
|
||
}
|
||
if (! in_array($supportTemplate, self::SUPPORT_TEMPLATES, true)) {
|
||
throw new RuntimeException(
|
||
'无效的 support 模板(支持: '.implode(', ', self::SUPPORT_TEMPLATES).')'
|
||
);
|
||
}
|
||
|
||
return $supportTemplate;
|
||
}
|
||
|
||
/**
|
||
* @return array{0: ?string, 1: ?string}
|
||
*/
|
||
private function normalizeOptionalSeeds(
|
||
?string $deploymentSeed,
|
||
?string $reportingSeed,
|
||
): array {
|
||
$deploymentSeed = $deploymentSeed !== null ? trim($deploymentSeed) : null;
|
||
$reportingSeed = $reportingSeed !== null ? trim($reportingSeed) : null;
|
||
if (($deploymentSeed === null || $deploymentSeed === '') && ($reportingSeed === null || $reportingSeed === '')) {
|
||
return [null, null];
|
||
}
|
||
if ($deploymentSeed === null || $deploymentSeed === '' || $reportingSeed === null || $reportingSeed === '') {
|
||
throw new RuntimeException('deployment_seed 与 reporting_seed 必须同时提供');
|
||
}
|
||
foreach (['deployment_seed' => $deploymentSeed, 'reporting_seed' => $reportingSeed] as $name => $value) {
|
||
if (! preg_match('/^[ -~]{1,32}$/', $value)) {
|
||
throw new RuntimeException("无效的 {$name}(需 1–32 位 ASCII)");
|
||
}
|
||
}
|
||
if ($deploymentSeed !== $reportingSeed) {
|
||
throw new RuntimeException('deployment_seed 与 reporting_seed 必须相同');
|
||
}
|
||
|
||
return [$deploymentSeed, $reportingSeed];
|
||
}
|
||
}
|