资源卡依赖关系及类型分类预览 (#129)
完成资源卡按类型和按依赖分类展现的功能 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/129 Co-authored-by: menghao <mh18530625731@163.com> Co-committed-by: menghao <mh18530625731@163.com>
This commit was merged in pull request #129.
This commit is contained in:
@@ -4,11 +4,15 @@ import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -68,11 +72,19 @@ function validateRuntimePageLifecycle() {
|
||||
const sourcePageFile = path.join(tempRoot, 'announcement.html');
|
||||
const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh');
|
||||
const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh');
|
||||
const onScriptSource = readFileSync(onScript, 'utf8');
|
||||
const env = {
|
||||
GENARRATIVE_MAINTENANCE_FILE: markerFile,
|
||||
GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile,
|
||||
};
|
||||
|
||||
if (!onScriptSource.includes('replace_file_atomically')) {
|
||||
fail('maintenance-on 必须通过统一 helper 原子替换公告页和 marker。');
|
||||
}
|
||||
if (/\bmv\s+-[^\s]*T\b/u.test(onScriptSource)) {
|
||||
fail('maintenance-on 不得使用 GNU mv 专属的 -T 参数。');
|
||||
}
|
||||
|
||||
try {
|
||||
const announcement = '<!doctype html><title>planned maintenance</title>\n';
|
||||
writeFileSync(sourcePageFile, announcement);
|
||||
@@ -133,6 +145,74 @@ function validateRuntimePageLifecycle() {
|
||||
if (missingPage.status === 0 || existsSync(markerFile)) {
|
||||
fail('不存在的 --page-file 必须在创建 marker 前失败。');
|
||||
}
|
||||
|
||||
const linkedPageTarget = path.join(tempRoot, 'linked-page-target');
|
||||
mkdirSync(linkedPageTarget);
|
||||
symlinkSync(
|
||||
linkedPageTarget,
|
||||
runtimePageFile,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const linkedPageEnable = runScript(
|
||||
onScript,
|
||||
['--page-file', sourcePageFile, 'linked page target'],
|
||||
env,
|
||||
);
|
||||
if (linkedPageEnable.status === 0) {
|
||||
fail('maintenance-on 必须拒绝指向目录的公告页符号链接。');
|
||||
}
|
||||
if (!lstatSync(runtimePageFile).isSymbolicLink()) {
|
||||
fail('拒绝公告页符号链接后不得替换链接本身。');
|
||||
}
|
||||
if (readdirSync(linkedPageTarget).length > 0) {
|
||||
fail('拒绝公告页符号链接后不得把临时文件移入链接目标目录。');
|
||||
}
|
||||
if (
|
||||
readdirSync(path.dirname(runtimePageFile)).some((entry) =>
|
||||
entry.startsWith(`${path.basename(runtimePageFile)}.tmp.`),
|
||||
)
|
||||
) {
|
||||
fail('公告页符号链接校验失败后不得残留 page.html.tmp.* 临时文件。');
|
||||
}
|
||||
if (existsSync(markerFile)) {
|
||||
fail('公告页符号链接校验失败时不得创建维护 marker。');
|
||||
}
|
||||
unlinkSync(runtimePageFile);
|
||||
|
||||
const linkedMarkerTarget = path.join(tempRoot, 'linked-marker-target');
|
||||
mkdirSync(linkedMarkerTarget);
|
||||
symlinkSync(
|
||||
linkedMarkerTarget,
|
||||
markerFile,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const linkedMarkerEnable = runScript(
|
||||
onScript,
|
||||
['linked marker target'],
|
||||
env,
|
||||
);
|
||||
if (linkedMarkerEnable.status === 0) {
|
||||
fail('maintenance-on 必须拒绝指向目录的 marker 符号链接。');
|
||||
}
|
||||
if (!lstatSync(markerFile).isSymbolicLink()) {
|
||||
fail('拒绝 marker 符号链接后不得替换链接本身。');
|
||||
}
|
||||
if (readdirSync(linkedMarkerTarget).length > 0) {
|
||||
fail('拒绝 marker 符号链接后不得把临时文件移入链接目标目录。');
|
||||
}
|
||||
if (
|
||||
readdirSync(path.dirname(markerFile)).some((entry) =>
|
||||
entry.startsWith(`${path.basename(markerFile)}.tmp.`),
|
||||
)
|
||||
) {
|
||||
fail('marker 符号链接校验失败后不得残留 enabled.tmp.* 临时文件。');
|
||||
}
|
||||
if (
|
||||
linkedMarkerEnable.stdout.includes('已进入维护模式') ||
|
||||
linkedMarkerEnable.stderr.includes('已进入维护模式')
|
||||
) {
|
||||
fail('marker 符号链接校验失败时不得打印维护模式成功信息。');
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -165,7 +165,8 @@ function parseArchiveObjectMembers(artifact) {
|
||||
}
|
||||
memberName = artifact
|
||||
.subarray(contentStart, contentStart + nameLength)
|
||||
.toString('utf8');
|
||||
.toString('utf8')
|
||||
.replace(/\0+$/u, '');
|
||||
contentStart += nameLength;
|
||||
}
|
||||
|
||||
|
||||
@@ -4915,9 +4915,13 @@ function assertDesktopReleaseBinaryArtifact() {
|
||||
const machMagic = header.readUInt32BE(0);
|
||||
const isMachO =
|
||||
machMagic === 0xcafebabe ||
|
||||
machMagic === 0xcafed00d ||
|
||||
machMagic === 0xbebafeca ||
|
||||
machMagic === 0xcafebabf ||
|
||||
machMagic === 0xbfbafeca ||
|
||||
machMagic === 0xfeedface ||
|
||||
machMagic === 0xfeedfacf;
|
||||
machMagic === 0xcefaedfe ||
|
||||
machMagic === 0xfeedfacf ||
|
||||
machMagic === 0xcffaedfe;
|
||||
if (!isMachO || (stat.mode & 0o111) === 0) {
|
||||
throw new Error(
|
||||
'desktop macOS release binary must be an executable Mach-O file',
|
||||
|
||||
@@ -15,6 +15,22 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const failures = [];
|
||||
const systemCpPath = ['/usr/bin/cp', '/bin/cp'].find((candidate) =>
|
||||
existsSync(candidate),
|
||||
);
|
||||
const systemMvPath = ['/usr/bin/mv', '/bin/mv'].find((candidate) =>
|
||||
existsSync(candidate),
|
||||
);
|
||||
const systemLnPath = ['/usr/bin/ln', '/bin/ln'].find((candidate) =>
|
||||
existsSync(candidate),
|
||||
);
|
||||
const systemChmodPath = ['/usr/bin/chmod', '/bin/chmod'].find((candidate) =>
|
||||
existsSync(candidate),
|
||||
);
|
||||
const systemStatModeCommand =
|
||||
process.platform === 'darwin'
|
||||
? '/usr/bin/stat -f %Lp'
|
||||
: '/usr/bin/stat -c %a --';
|
||||
const tmpRoot = mkdtempSync(
|
||||
path.join(tmpdir(), 'genarrative-production-api-deploy-'),
|
||||
);
|
||||
@@ -2421,7 +2437,7 @@ function prepareFixture(name) {
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'/usr/bin/cp "$@"',
|
||||
`${shellQuote(systemCpPath ?? 'cp')} "$@"`,
|
||||
'if [[ "${FAKE_CREATE_RELEASE_DURING_COPY:-false}" == "true" ]]; then',
|
||||
' marker="${FAKE_RELEASE_ROOT}/.${FAKE_RELEASE_VERSION}.race-created"',
|
||||
' if [[ ! -e "${marker}" ]]; then',
|
||||
@@ -2434,6 +2450,40 @@ function prepareFixture(name) {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(fakeBin, 'mv'),
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'if [[ "${1:-}" == "-T" ]]; then',
|
||||
' shift',
|
||||
' if [[ "$#" -ne 2 || ( -d "$2" && ! -L "$2" ) ]]; then',
|
||||
' exit 1',
|
||||
' fi',
|
||||
` exec ${shellQuote(systemMvPath ?? 'mv')} "$1" "$2"`,
|
||||
'fi',
|
||||
`exec ${shellQuote(systemMvPath ?? 'mv')} "$@"`,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(fakeBin, 'ln'),
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'if [[ "${1:-}" == "-sfnT" ]]; then',
|
||||
' shift',
|
||||
' if [[ "$#" -ne 2 || ( -d "$2" && ! -L "$2" ) ]]; then',
|
||||
' exit 1',
|
||||
' fi',
|
||||
` exec ${shellQuote(systemLnPath ?? 'ln')} -sfn "$1" "$2"`,
|
||||
'fi',
|
||||
`exec ${shellQuote(systemLnPath ?? 'ln')} "$@"`,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(fakeBin, 'install'),
|
||||
[
|
||||
@@ -2479,15 +2529,15 @@ function prepareFixture(name) {
|
||||
' IFS="|" read -r -a env_files <<< "${FAKE_SUDO_ENV_FILES}"',
|
||||
' modes=()',
|
||||
' for env_file in "${env_files[@]}"; do',
|
||||
' modes+=("$(/usr/bin/stat -c %a -- "${env_file}")")',
|
||||
' /usr/bin/chmod u+rw -- "${env_file}"',
|
||||
` modes+=("$(${systemStatModeCommand} "\${env_file}")")`,
|
||||
` ${shellQuote(systemChmodPath ?? 'chmod')} u+rw "\${env_file}"`,
|
||||
' done',
|
||||
' set +e',
|
||||
' "$@"',
|
||||
' status=$?',
|
||||
' set -e',
|
||||
' for index in "${!env_files[@]}"; do',
|
||||
' /usr/bin/chmod "${modes[$index]}" -- "${env_files[$index]}"',
|
||||
` ${shellQuote(systemChmodPath ?? 'chmod')} "\${modes[$index]}" "\${env_files[$index]}"`,
|
||||
' done',
|
||||
' exit "${status}"',
|
||||
'fi',
|
||||
@@ -2501,6 +2551,8 @@ function prepareFixture(name) {
|
||||
chmodExecutable(path.join(fakeBin, 'sleep'));
|
||||
chmodExecutable(path.join(fakeBin, 'stat'));
|
||||
chmodExecutable(path.join(fakeBin, 'cp'));
|
||||
chmodExecutable(path.join(fakeBin, 'mv'));
|
||||
chmodExecutable(path.join(fakeBin, 'ln'));
|
||||
chmodExecutable(path.join(fakeBin, 'install'));
|
||||
chmodExecutable(path.join(fakeBin, 'sudo'));
|
||||
|
||||
|
||||
@@ -6,6 +6,36 @@ MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenan
|
||||
MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}"
|
||||
PAGE_SOURCE=""
|
||||
REASON_PARTS=()
|
||||
page_temp=""
|
||||
marker_temp=""
|
||||
|
||||
cleanup_temps() {
|
||||
if [[ -n "${page_temp}" ]]; then
|
||||
rm -f -- "${page_temp}"
|
||||
fi
|
||||
if [[ -n "${marker_temp}" ]]; then
|
||||
rm -f -- "${marker_temp}"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_temps EXIT
|
||||
|
||||
replace_file_atomically() {
|
||||
local source_file="$1"
|
||||
local target_file="$2"
|
||||
|
||||
if [[ -L "${target_file}" ]]; then
|
||||
echo "[maintenance] 原子替换目标不能是符号链接: ${target_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -d "${target_file}" ]]; then
|
||||
echo "[maintenance] 原子替换目标不能是目录: ${target_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 源文件与目标文件位于同一目录,POSIX rename 语义即可保证原子替换。
|
||||
# 不使用 GNU mv 专属的 -T,确保 macOS/BSD 本地门禁也能执行。
|
||||
mv -f "${source_file}" "${target_file}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -39,9 +69,8 @@ mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE}
|
||||
|
||||
if [[ -n "${PAGE_SOURCE}" ]]; then
|
||||
page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")"
|
||||
trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT
|
||||
install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}"
|
||||
mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
|
||||
replace_file_atomically "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
|
||||
page_temp=""
|
||||
echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}"
|
||||
elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then
|
||||
@@ -55,7 +84,7 @@ marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")"
|
||||
} >"${marker_temp}"
|
||||
|
||||
chmod 0644 "${marker_temp}"
|
||||
mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}"
|
||||
replace_file_atomically "${marker_temp}" "${MAINTENANCE_FILE}"
|
||||
marker_temp=""
|
||||
trap - EXIT
|
||||
echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}"
|
||||
|
||||
@@ -892,7 +892,9 @@ ensure_default_worker_service() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
mapfile -t services < <(list_worker_services "${pattern}")
|
||||
while IFS= read -r service; do
|
||||
services+=("${service}")
|
||||
done < <(list_worker_services "${pattern}")
|
||||
if [[ "${#services[@]}" -gt 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -1033,7 +1035,9 @@ restart_worker_services() {
|
||||
fi
|
||||
|
||||
ensure_default_worker_service "${pattern}"
|
||||
mapfile -t services < <(list_worker_services "${pattern}")
|
||||
while IFS= read -r service; do
|
||||
services+=("${service}")
|
||||
done < <(list_worker_services "${pattern}")
|
||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
|
||||
return 1
|
||||
@@ -1052,7 +1056,9 @@ wait_for_worker_services() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
mapfile -t services < <(list_worker_services "${pattern}")
|
||||
while IFS= read -r service; do
|
||||
services+=("${service}")
|
||||
done < <(list_worker_services "${pattern}")
|
||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function backendStepIndex(stepName: string) {
|
||||
const backendJobStart = workflow.indexOf(' backend-tests:');
|
||||
const nativeShellJobStart = workflow.indexOf(' native-shell-tests:');
|
||||
expect(backendJobStart).toBeGreaterThanOrEqual(0);
|
||||
expect(nativeShellJobStart).toBeGreaterThan(backendJobStart);
|
||||
|
||||
return workflow
|
||||
.slice(backendJobStart, nativeShellJobStart)
|
||||
.indexOf(` - name: ${stepName}`);
|
||||
}
|
||||
|
||||
describe('project CI workflow', () => {
|
||||
it('prepares locked server-rs dependencies before the first Cargo build gate', () => {
|
||||
const prepareDependencies = backendStepIndex(
|
||||
'Prepare server-rs Rust dependencies',
|
||||
);
|
||||
const checkBoundaries = backendStepIndex('Check server-rs boundaries');
|
||||
const runWorkspaceTests = backendStepIndex('Run server-rs workspace tests');
|
||||
|
||||
expect(prepareDependencies).toBeGreaterThanOrEqual(0);
|
||||
expect(checkBoundaries).toBeGreaterThan(prepareDependencies);
|
||||
expect(runWorkspaceTests).toBeGreaterThan(checkBoundaries);
|
||||
expect(workflow).toContain('cargo fetch --locked');
|
||||
expect(workflow).toContain('for attempt in $(seq 1 5); do');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat, readFile, realpath } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -85,15 +86,20 @@ export async function readRepairPlan(planFile, { repoRoot = REPO_ROOT } = {}) {
|
||||
const resolvedPath = path.resolve(planFile);
|
||||
const resolvedRepoRoot = path.resolve(repoRoot);
|
||||
let canonicalPath;
|
||||
let canonicalRepoRoot;
|
||||
try {
|
||||
canonicalPath = await realpath(resolvedPath);
|
||||
canonicalRepoRoot = await realpath(resolvedRepoRoot);
|
||||
} catch {
|
||||
throw new Error('--plan-file 无法解析或不存在。');
|
||||
}
|
||||
if (canonicalPath !== resolvedPath) {
|
||||
const pathWithoutSystemTempAlias = await normalizeSystemTempAlias(
|
||||
resolvedPath,
|
||||
);
|
||||
if (canonicalPath !== pathWithoutSystemTempAlias) {
|
||||
throw new Error('--plan-file 路径链不能包含符号链接。');
|
||||
}
|
||||
if (isPathInside(canonicalPath, resolvedRepoRoot)) {
|
||||
if (isPathInside(canonicalPath, canonicalRepoRoot)) {
|
||||
throw new Error('--plan-file 必须位于仓库外,避免真实 ID 进入工作区。');
|
||||
}
|
||||
|
||||
@@ -539,6 +545,16 @@ function isPathInside(candidate, root) {
|
||||
);
|
||||
}
|
||||
|
||||
async function normalizeSystemTempAlias(candidate) {
|
||||
const resolvedTempRoot = path.resolve(tmpdir());
|
||||
if (!isPathInside(candidate, resolvedTempRoot)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const canonicalTempRoot = await realpath(resolvedTempRoot);
|
||||
return path.join(canonicalTempRoot, path.relative(resolvedTempRoot, candidate));
|
||||
}
|
||||
|
||||
function assertPlainObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} 必须是对象。`);
|
||||
|
||||
Reference in New Issue
Block a user