From 3969c29fa5be89e5f5bd3f524c278bfc06b030c2 Mon Sep 17 00:00:00 2001 From: zhc Date: Mon, 26 Oct 2026 18:00:09 +0800 Subject: [PATCH] refactor(desktop): simplify EdgeOne purge to submission only --- apps/desktop/.env.macos.example | 4 +- apps/desktop/.env.windows.example | 4 +- apps/desktop/publishing/README.i18n.yaml | 8 +- apps/desktop/publishing/README.md | 8 +- apps/desktop/publishing/README.zh.md | 8 +- apps/desktop/scripts/desktop-edgeone.ts | 199 +++------------ .../scripts/desktop-package-environment.mjs | 2 +- apps/desktop/scripts/desktop-upload-run.ts | 2 +- apps/desktop/scripts/upload-target.ts | 23 +- .../scripts/upload-with-credentials.ps1 | 16 +- apps/desktop/tests/desktop-edgeone.spec.ts | 227 ++++-------------- apps/desktop/tests/purge-command.spec.ts | 9 +- apps/desktop/tests/upload-target.spec.ts | 23 +- .../tests/upload-with-credentials.spec.ts | 10 +- 14 files changed, 125 insertions(+), 418 deletions(-) diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index 672071d922..7d2336d546 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -57,8 +57,6 @@ APPLE_API_ISSUER= # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= -# Production fixed downloads require EO purge and task-query permissions on the COS identity. +# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity. # Required for upload:latest:* and purge:latest; configure the download domain's EO zone. # DOWNLOAD_PROD_EO_ZONE_ID= -# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000 -# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000 diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index efd679b245..fd45f0cb32 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -42,8 +42,6 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= -# Production fixed downloads require EO purge and task-query permissions on the COS identity. +# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity. # Required for upload:latest:* and purge:latest; configure the download domain's EO zone. # DOWNLOAD_PROD_EO_ZONE_ID= -# DOWNLOAD_PROD_EO_TIMEOUT_MS=300000 -# DOWNLOAD_PROD_EO_POLL_INTERVAL_MS=5000 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 4cbef48dc5..99b4b44aef 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -24,11 +24,11 @@ en: 3c3e3a09573c4386 zh: c851ae6a94f2284a /desktop-publication/fixed-installer-downloads: - en: fc2d126835d00f12 - zh: fceda915f9ce5558 + en: ce8296f8f1ee881e + zh: 749ab63bd6ed4248 /desktop-publication/upload-credentials: - en: 3a14a53c7eca80f7 - zh: bb122276f6648f74 + en: 47442fb912fa4d5d + zh: a21a0d67cd01724b /desktop-publication/upload-audit-and-recovery: en: ec85f43ece09a5e3 zh: 2de5bf801c9c9e39 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 5559cd0177..b9bb98af2b 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -69,17 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the ## Fixed installer downloads -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. -Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. Existing key permissions are not verified by this script change; before enabling production fixed uploads, the release operator must confirm both EO actions for the selected identity. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading. +Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically. -An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production--purge-*` directories under the upload-record root, including versioned plans/results, script fingerprints, URL, task ID and status events. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest --job-id `, or submit a new URL purge with `pnpm run purge:latest ` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; connection loss, timeout or access denial does not imply EO canceled the task. Transient query failures retry within the deadline; permission and parameter errors stop immediately. A task never found by the deadline is reported separately from an unfinished task. DPAPI recovery must reuse the original credential launcher with the same credential file, deployment, bucket and target, adding `-PurgeOnly` and the accepted `-JobId`. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh. +The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest ` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh. ## Upload credentials -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For cache-only recovery, add `-PurgeOnly` to `-Upload -Latest -Environment production`; optionally add `-JobId ` to resume. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For a cache-only submission, add `-PurgeOnly` to `-Upload -Latest -Environment production`. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 6dd23e1cda..0f64fb31ee 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -69,17 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo ## 固定安装包下载 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 -生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。本次脚本修改未验证现有密钥权限;启用生产固定上传前,发布操作人员必须确认所选身份具备这两个 EO 操作的权限。无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。 +生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。 -缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production--purge-*` 目录,包含带版本号的计划和结果、脚本指纹、URL、任务 ID 和状态事件。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest --job-id ` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest ` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接中断、超时或权限拒绝不代表 EO 已取消任务。临时查询错误在截止时间内重试;权限和参数错误立即停止。截止时间前始终未找到任务时,单独报告该情况,与任务尚未完成区分。DPAPI 恢复必须复用原凭据启动器以及相同的凭据文件、部署环境、bucket 和目标,添加 `-PurgeOnly` 和已接受的 `-JobId`。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。 +命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest ` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。 ## 上传凭据 -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅恢复缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`;可选的 `-JobId ` 用于继续等待。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅提交缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 diff --git a/apps/desktop/scripts/desktop-edgeone.ts b/apps/desktop/scripts/desktop-edgeone.ts index 30b530d854..e2faf122a0 100644 --- a/apps/desktop/scripts/desktop-edgeone.ts +++ b/apps/desktop/scripts/desktop-edgeone.ts @@ -1,76 +1,43 @@ -/** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { setTimeout as delay } from 'node:timers/promises' +/** Submit one production fixed-installer cache purge to EdgeOne. */ import type { teo } from 'tencentcloud-sdk-nodejs-teo' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' import { desktopTargetPlatform } from './desktop-build-paths.mjs' -import { recordPackagingEvent } from './packaging-run.mjs' /** Validated production destination and credentials; never serialize this value. */ export interface DesktopPurgeConfig { - readonly target: DesktopPackageTargetName readonly zoneId: string readonly publicUrl: string readonly secretId: string readonly secretKey: string - readonly timeoutMs: number - readonly pollIntervalMs: number } -/** SDK operations used by one purge; callers may supply an isolated client. */ -export type DesktopPurgeClient = Pick, 'profile' | 'CreatePurgeTask' | 'DescribePurgeTasks'> - -/** Optional existing task, credential-launcher context, and caller-owned SDK client. */ -export interface DesktopPurgeOptions { - readonly jobId?: string - readonly credentialLauncher?: boolean - readonly client?: DesktopPurgeClient -} - -function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number { - if (env[name] === undefined) return fallback - const value = Number(env[name]) - if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647) { - throw new DesktopPurgeError(`desktop purge: ${name} must be a positive timer duration in milliseconds`) - } - return value -} +/** The single SDK operation needed to submit a purge. */ +export type DesktopPurgeClient = Pick, 'CreatePurgeTask'> /** - * Resolve the production URL, EO zone and existing COS credentials before any upload. - * @param env Target dotenv settings, with any credential-launcher replacement already applied. + * Resolve production purge settings before uploading. + * @param env Target dotenv settings, including any credential-launcher replacement. * @param target Fixed installer selected by the operator. - * @param publicUrl Uploaded object URL; omitted derives the fixed URL for cache-only recovery. - * @returns Required EO settings; test deployments and invalid settings throw before network writes. + * @param publicUrl Uploaded object URL; omitted derives the URL for a cache-only submission. + * @returns Validated destination and credentials. */ -export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, - publicUrl?: string, +export function resolveDesktopPurgeConfig( + env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string, ): DesktopPurgeConfig { if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required') const { platform, arch } = desktopTargetPlatform(target) const update = resolveDesktopUploadConfig(env, platform, arch) const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID') if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone') - const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000) - const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000) - if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout') return { - target, zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, + zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName), - timeoutMs, pollIntervalMs, } } -/** A failed or incomplete purge, with only safe diagnostics available to CLI callers. */ -export class DesktopPurgeError extends Error { - constructor(message: string) { - super(message) - this.name = 'DesktopPurgeError' - } -} +/** A purge submission failure containing only safe CLI diagnostics. */ +export class DesktopPurgeError extends Error {} function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined { if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value) @@ -78,137 +45,29 @@ function safeField(value: unknown, config: DesktopPurgeConfig): string | undefin return value } -function errorCode(error: unknown, config: DesktopPurgeConfig): string | undefined { - return safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) -} - -function transientQueryError(error: unknown, config: DesktopPurgeConfig): boolean { - const code = errorCode(error, config) - // The pinned SDK wraps transport failures without preserving their original code. - if (code === undefined && error instanceof Error && 'getRequestId' in error && 'getTraceId' in error) { - return !('httpCode' in error) || (typeof error.httpCode === 'number' && (error.httpCode === 429 || error.httpCode >= 500)) - } - return code !== undefined && (['RequestLimitExceeded', 'InternalError'].includes(code.split('.')[0]!) - || ['ClientNetworkError', 'ClientNetworkSocketTimeoutError', 'ClientNetworkConnectTimeoutError', - 'RequestTimeout', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code)) -} - /** - * Submit one URL purge, or wait for an existing task without submitting again. + * Submit once and report the accepted task ID, without querying cache completion. * @param config Validated production destination and credentials. - * @param recordsRoot Parent directory for separate purge records; upload records remain intact. - * @param options Existing task, launcher context, and optional SDK client; submission is never retried. - * @returns Resolves only after EO reports success for the exact target URL and task ID. - * @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged. + * @param client Optional caller-owned SDK client; omitted lazily creates one with a 30-second request timeout. + * @returns Resolves when EO accepts the submission; this does not confirm cache completion. + * @throws DesktopPurgeError on rejection or an unconfirmed submission; never retries or changes uploaded bytes. */ -export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, - options: DesktopPurgeOptions = {}, -): Promise { - const existingJobId = options.jobId - if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) { - throw new DesktopPurgeError('desktop purge: invalid --job-id') - } - let directory: string | undefined - let jobId = existingJobId - let stage = 'prepare' - let success = false - let observedTask = false - let lastQueryFailed = false - let failure: string | undefined - let rejected = false - let report: DesktopPurgeError | undefined +export async function purgeDesktopInstaller(config: DesktopPurgeConfig, client?: DesktopPurgeClient): Promise { + let response: Awaited> try { - await mkdir(recordsRoot, { recursive: true }) - directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`)) - process.stdout.write(`desktop purge: record ${directory}\n`) - await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) - const sourceSha256 = { 'desktop-edgeone.ts': createHash('sha256').update(await readFile(import.meta.filename)).digest('hex') } - await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, sourceSha256, zoneId: config.zoneId, publicUrl: config.publicUrl, - target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`, - { flag: 'wx', mode: 0o600, flush: true }) - const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) } - const client = options.client ?? new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ + client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '', - profile: { httpProfile }, + profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } }, }) - const deadline = performance.now() + config.timeoutMs - if (jobId === undefined) { - stage = 'CreatePurgeTask' - recordPackagingEvent(directory, { type: 'purge-intent', url: config.publicUrl }) - const response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) - jobId = safeField(response.JobId, config) - if (jobId) process.stdout.write(`desktop purge: task ${jobId}\n`) - recordPackagingEvent(directory, { type: 'purge-submitted', jobId, requestId: safeField(response.RequestId, config) }) - if ((response.FailedList?.length ?? 0) > 0) { - rejected = true - throw new DesktopPurgeError('EO rejected the URL purge') - } - if (!jobId) throw new DesktopPurgeError('EO returned no task ID; submission outcome is unknown') - } - stage = 'DescribePurgeTasks' - while (performance.now() < deadline) { - client.profile.httpProfile!.reqTimeout = Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) - let response: Awaited> - try { - response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000, - Filters: [{ Name: 'job-id', Values: [jobId] }] }) - } catch (error) { - const code = errorCode(error, config) - if (!transientQueryError(error, config)) throw error - lastQueryFailed = true - recordPackagingEvent(directory, { type: 'purge-query-retry', jobId, code: code ?? 'SDKTransportError' }) - const remaining = deadline - performance.now() - if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) - continue - } - lastQueryFailed = false - if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit') - const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? [] - if ((response.Tasks?.length ?? 0) > 0 && tasks.length === 0) { - throw new DesktopPurgeError('EO job does not contain the expected URL purge; check the zone, job ID and target') - } - const task = tasks[0] - observedTask ||= task !== undefined - if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID') - const status = task?.Status - recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) }) - if (status === 'success') { - success = true - stage = 'complete' - break - } - rejected = status === 'failed' || status === 'timeout' || status === 'canceled' - if (task !== undefined && status !== 'processing') throw new DesktopPurgeError(`EO task did not succeed (${safeField(status, config) ?? 'unknown status'})`) - const remaining = deadline - performance.now() - if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining)) - } - if (!success) throw new DesktopPurgeError(observedTask || lastQueryFailed - ? 'timed out waiting for EO; task completion is unknown' - : 'EO task was not found before the deadline; check the zone, job ID and EO task history') + response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) } catch (error) { - const code = errorCode(error, config) - failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})` - const command = options.credentialLauncher - ? `rerun upload-with-credentials.ps1 with the original -CredentialFile, -Environment production, -Target ${config.target} and -Bucket; include -Upload -Latest -PurgeOnly${!rejected && jobId ? ` -JobId ${jobId}` : ''}` - : `pnpm run purge:latest ${config.target}${!rejected && jobId ? ` --job-id ${jobId}` : ''}` - const recovery = rejected - ? `Resolve the EO failure, then ${command}.` - : jobId ? `Resume with ${command}.` - : 'Inspect EO task history before submitting another purge.' - const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation') - ? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : '' - report = new DesktopPurgeError(`desktop purge: ${failure}.${permission} ${recovery}`) - } finally { - if (directory !== undefined) { - try { - await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`, - { flag: 'wx', mode: 0o600, flush: true }) - } catch (error) { - // An unavailable journal cannot establish completion for a subsequent process. - report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`) - } - } + const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) + throw new DesktopPurgeError(`desktop purge: submission not confirmed (${code ?? 'request failed'}); check EO task history and teo:CreatePurgeTask permission before submitting again.`) } - if (report !== undefined) throw report - process.stdout.write(`desktop purge: completed ${config.publicUrl}\n`) + if ((response.FailedList?.length ?? 0) > 0) { + throw new DesktopPurgeError('desktop purge: EO rejected the URL; inspect EO before submitting again.') + } + const jobId = safeField(response.JobId, config) + if (!jobId) throw new DesktopPurgeError('desktop purge: EO returned no task ID; check EO task history before submitting again.') + process.stdout.write(`desktop purge: submitted ${config.publicUrl}; task ${jobId}. Cache completion is not checked.\n`) } diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index fa2bdaf3b8..27a7f92c7f 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -15,7 +15,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_(?:ZONE_ID|TIMEOUT_MS|POLL_INTERVAL_MS)|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_ZONE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts index 6f40c974c7..aa3cd49aa5 100644 --- a/apps/desktop/scripts/desktop-upload-run.ts +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -13,7 +13,7 @@ const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) /** Object keys fingerprinted into every upload record to bind evidence to this uploader. */ -const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts', 'desktop-edgeone.ts'] +const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts'] async function fingerprint(artifact: DesktopUploadArtifact) { const sha512 = createHash('sha512') diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index f95f3cd6e5..a1afdb3241 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -64,15 +64,15 @@ function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTarge } /** - * Upload the selected target, refresh production fixed downloads, or resume only their cache refresh. + * Upload the selected target, refresh production fixed downloads, or submit only their cache purge. * @param args Target and upload options from the command line. - * @returns Resolves after upload, any required EO refresh, and any release-tag attempt complete. - * @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back. + * @returns Resolves after upload, any required EO submission, and any release-tag attempt complete. + * @throws DesktopPurgeError when purge submission fails; a completed upload is not repeated or rolled back. */ export async function uploadDesktopTarget(args: string[]): Promise { const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { latest: { type: 'boolean' }, - 'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' }, + 'purge-only': { type: 'boolean' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] @@ -80,8 +80,8 @@ export async function uploadDesktopTarget(args: string[]): Promise { throw new DesktopUploadUsageError('desktop upload: expected exactly one target') } const name = targetName(target) - if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) { - throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest; --job-id requires --purge-only') + if (values['purge-only'] && !values.latest) { + throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest') } let fileEnvironment: NodeJS.ProcessEnv try { @@ -107,7 +107,7 @@ export async function uploadDesktopTarget(args: string[]): Promise { } const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records') if (values['purge-only']) { - await purgeDesktopInstaller(purgeConfig(environment, name), recordsRoot, { ...values['job-id'] !== undefined ? { jobId: values['job-id'] } : {}, credentialLauncher: launcher }) + await purgeDesktopInstaller(purgeConfig(environment, name)) return } const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) @@ -119,11 +119,8 @@ export async function uploadDesktopTarget(args: string[]): Promise { process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) await uploadDesktopRelease(plan, cos, recordsRoot) if (purge !== undefined) { - try { - await purgeDesktopInstaller(purge, recordsRoot, { credentialLauncher: launcher }) - } catch (error) { - throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`) - } + process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n') + await purgeDesktopInstaller(purge) } if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan) } @@ -166,7 +163,7 @@ export async function runDesktopUploadCommand(args: string[]): Promise { const usage = error instanceof DesktopUploadUsageError || (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_')) const message = error instanceof DesktopPurgeError ? error.message - : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only [--job-id ]]]; targets: mac-arm64, mac-x64, win-x64.` + : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only]]; targets: mac-arm64, mac-x64, win-x64.` : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' // The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details. const output = args.includes('--credential-launcher') ? process.stdout : process.stderr diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index eb94f61bde..e6cde5f27e 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -19,9 +19,7 @@ Publish only the installer at its fixed download URL. Requires Upload. .PARAMETER Upload Authorize the existing target upload entry. Omit to check credentials locally. .PARAMETER PurgeOnly -Refresh the production fixed download without uploading. Requires Upload and Latest. -.PARAMETER JobId -Resume an existing EO task without submitting again. Requires PurgeOnly. +Submit a purge for the production fixed download without uploading. Requires Upload and Latest. #> [CmdletBinding(DefaultParameterSetName = 'Check')] param( @@ -42,10 +40,7 @@ param( [Parameter(ParameterSetName = 'Publish')] [switch]$Latest, [Parameter(ParameterSetName = 'Publish')] - [switch]$PurgeOnly, - [Parameter(ParameterSetName = 'Publish')] - [ValidatePattern('^[a-zA-Z0-9-]{1,128}$')] - [string]$JobId + [switch]$PurgeOnly ) $ErrorActionPreference = 'Stop' @@ -58,8 +53,8 @@ $credentials = $null $startInfo = $null $stage = 'decrypt-file' -if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) { - Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production; JobId requires PurgeOnly.' +if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) { + Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.' exit 1 } @@ -107,9 +102,8 @@ try { $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket" if ($Latest) { $startInfo.Arguments += " --latest" } if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" } - if ($JobId) { $startInfo.Arguments += " --job-id $JobId" } if ($PurgeOnly) { - Write-Output "desktop credentials: refreshing $Target in $Environment; no installer upload." + Write-Output "desktop credentials: submitting a cache purge for $Target in $Environment; no installer upload." } else { Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." } diff --git a/apps/desktop/tests/desktop-edgeone.spec.ts b/apps/desktop/tests/desktop-edgeone.spec.ts index 2578b2327b..cada969378 100644 --- a/apps/desktop/tests/desktop-edgeone.spec.ts +++ b/apps/desktop/tests/desktop-edgeone.spec.ts @@ -1,50 +1,15 @@ -import { createHash } from 'node:crypto' -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { teo } from 'tencentcloud-sdk-nodejs-teo' -import { purgeDesktopInstaller, resolveDesktopPurgeConfig, type DesktopPurgeClient } from '../scripts/desktop-edgeone.ts' +import { purgeDesktopInstaller, resolveDesktopPurgeConfig } from '../scripts/desktop-edgeone.ts' const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key', - DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', DOWNLOAD_PROD_EO_TIMEOUT_MS: '30', DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '10', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', } const config = resolveDesktopPurgeConfig(environment, 'mac-arm64') -const task = { JobId: 'job-1', Target: config.publicUrl, Type: 'purge_url', Status: 'success' } -const roots: string[] = [] -let time = 0 -vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async (ms: number) => { time += ms }) })) - -beforeEach(() => { - time = 0 - vi.spyOn(performance, 'now').mockImplementation(() => time) - vi.spyOn(process.stdout, 'write').mockReturnValue(true) -}) -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) - -async function fixture() { - const root = await mkdtemp(join(tmpdir(), 'desktop-purge-')) - roots.push(root) - // Real SDK initialization, with both network operations replaced before use. - const client = new teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey } }) - const create = vi.spyOn(client, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1', RequestId: 'request-1' }) - const describe = vi.spyOn(client, 'DescribePurgeTasks').mockResolvedValue({ TotalCount: 1, Tasks: [task] }) - const run = (options: { jobId?: string; credentialLauncher?: boolean } = {}) => - purgeDesktopInstaller(config, root, { ...options, client }) - const directory = async () => join(root, (await readdir(root))[0]!) - const result = async (): Promise => JSON.parse(await readFile(join(await directory(), 'result.json'), 'utf8')) - return { root, client, create, describe, run, directory, result } -} - -function sdkError(code: string) { - return Object.assign(new Error('private service details fixture-secret-key'), { code }) -} +afterEach(() => vi.restoreAllMocks()) describe('production EdgeOne configuration', () => { it.each([ @@ -53,8 +18,6 @@ describe('production EdgeOne configuration', () => { [{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u], [{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u], [{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u], - [{ DOWNLOAD_PROD_EO_TIMEOUT_MS: '0' }, /positive timer/u], - [{ DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '31' }, /must not exceed timeout/u], ] as const)('rejects invalid settings %j', (patch, message) => { expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message) }) @@ -67,154 +30,50 @@ describe('production EdgeOne configuration', () => { }) }) -describe('EdgeOne task tracking', () => { - it('preserves SDK HTTP defaults through delayed visibility and processing, and records only safe evidence', async () => { - const f = await fixture() - const profile = f.client.profile.httpProfile - f.describe.mockResolvedValueOnce({ Tasks: [] }).mockResolvedValueOnce({ Tasks: [{ ...task, Status: 'processing' }] }) - await f.run() - expect(f.create).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) - expect(f.describe).toHaveBeenCalledTimes(3) - expect(f.describe).toHaveBeenLastCalledWith({ ZoneId: config.zoneId, Limit: 1000, Filters: [{ Name: 'job-id', Values: ['job-1'] }] }) - expect(f.client.profile.httpProfile).toBe(profile) - expect(profile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 0.01 }) - expect(await f.result()).toMatchObject({ schemaVersion: 1, success: true, stage: 'complete', jobId: 'job-1' }) - const plan = await readFile(join(await f.directory(), 'plan.json'), 'utf8') - const hash = createHash('sha256').update(await readFile(new URL('../scripts/desktop-edgeone.ts', import.meta.url))).digest('hex') - expect(JSON.parse(plan)).toMatchObject({ schemaVersion: 1, sourceSha256: { 'desktop-edgeone.ts': hash } }) - expect(plan).not.toContain(config.secretId) - expect(plan).not.toContain(config.secretKey) +describe('EdgeOne purge submission', () => { + it('submits once with the pinned SDK and reports acceptance without querying completion', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask') + .mockImplementation(async function (this: InstanceType) { + expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 30 }) + return { JobId: 'job-1' } + }) + const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('must not query')) + + await purgeDesktopInstaller(config) + + expect(submit).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + expect(query).not.toHaveBeenCalled() + expect(output).toHaveBeenCalledExactlyOnceWith( + `desktop purge: submitted ${config.publicUrl}; task job-1. Cache completion is not checked.\n`, + ) }) - it('resumes an existing multi-URL job without resubmission', async () => { - const f = await fixture() - f.describe.mockResolvedValue({ Tasks: [{ ...task, Target: 'https://example.com/other' }, task] }) - await f.run({ jobId: 'job-1' }) - expect(f.create).not.toHaveBeenCalled() - expect(await f.result()).toMatchObject({ success: true }) + it('reports a rejected URL without printing the raw response or claiming acceptance', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.fn().mockResolvedValue({ JobId: 'job-1', FailedList: [{ Targets: [config.publicUrl], Reason: config.secretKey }] }) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + 'desktop purge: EO rejected the URL; inspect EO before submitting again.', + ) + expect(submit).toHaveBeenCalledOnce() + expect(output).not.toHaveBeenCalled() }) - it.each(['RequestLimitExceeded', 'InternalError.Backend', 'ECONNRESET'])('retries %s queries within the deadline', async (code) => { - const f = await fixture() - f.describe.mockRejectedValueOnce(sdkError(code)) - await f.run() - expect(f.create).toHaveBeenCalledOnce() - expect(f.describe).toHaveBeenCalledTimes(2) + it.each(['ECONNRESET', 'UnauthorizedOperation', undefined, config.secretKey])('does not retry or expose SDK errors (%s)', async (code) => { + const submit = vi.fn().mockRejectedValue(Object.assign(new Error(config.secretKey), { code })) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + `desktop purge: submission not confirmed (${code === undefined || code === config.secretKey ? 'request failed' : code}); check EO task history and teo:CreatePurgeTask permission before submitting again.`, + ) + expect(submit).toHaveBeenCalledOnce() }) - it.each([undefined, 503])('bounds code-less SDK transport failures with HTTP status %s', async (httpCode) => { - const f = await fixture() - const error = Object.assign(new Error('private network details'), { getRequestId: () => '', getTraceId: () => '', - ...httpCode === undefined ? {} : { httpCode } }) - f.describe.mockRejectedValue(error) - await expect(f.run()).rejects.toThrow(/timed out.*--job-id job-1/u) - expect(f.describe).toHaveBeenCalledTimes(3) - expect(await f.result()).toMatchObject({ success: false }) - }) - - it.each(['AuthFailure', 'UnauthorizedOperation', 'InvalidParameter', 'InvalidParameterValue', 'MissingParameter'])('stops immediately on %s', async (code) => { - const f = await fixture() - f.describe.mockRejectedValue(sdkError(code)) - await expect(f.run()).rejects.toThrow(new RegExp(`${code}.*--job-id job-1`, 'u')) - expect(f.describe).toHaveBeenCalledOnce() - expect(await f.result()).toMatchObject({ success: false }) - const evidence = await readFile(join(await f.directory(), 'result.json'), 'utf8') - expect(evidence).not.toContain('private service details') - expect(evidence).not.toContain(config.secretKey) - }) - - it.each(['failed', 'timeout', 'canceled'])('reports terminal %s with a fresh-purge recovery command', async (status) => { - const f = await fixture() - f.describe.mockResolvedValue({ Tasks: [{ ...task, Status: status }] }) - await expect(f.run()).rejects.toThrow('Resolve the EO failure, then pnpm run purge:latest mac-arm64.') - expect(f.describe).toHaveBeenCalledOnce() - }) - - it('does not poll a rejected submission', async () => { - const f = await fixture() - f.create.mockResolvedValue({ FailedList: [{ Targets: [config.publicUrl], Reason: 'rejected' }] }) - await expect(f.run()).rejects.toThrow(/EO rejected.*Resolve the EO failure/u) - expect(f.describe).not.toHaveBeenCalled() - }) - - it.each([{}, { RequestId: config.secretKey }])('reports unknown submission outcome when no job ID is returned', async (response) => { - const f = await fixture() - f.create.mockResolvedValue(response) - await expect(f.run()).rejects.toThrow(/no task ID.*Inspect EO task history/u) - expect(f.create).toHaveBeenCalledOnce() - expect(f.describe).not.toHaveBeenCalled() - }) - - it('never retries a submission with an unknown outcome', async () => { - const f = await fixture() - f.create.mockRejectedValue(sdkError('ECONNRESET')) - await expect(f.run()).rejects.toThrow(/Inspect EO task history/u) - expect(f.create).toHaveBeenCalledOnce() - expect(f.describe).not.toHaveBeenCalled() - }) - - it.each([ - [{ Tasks: [] }, /task was not found/u], - [{ Tasks: [{ ...task, Status: 'processing' }] }, /task completion is unknown/u], - ])('distinguishes invisible tasks from unfinished tasks: %j', async (response, message) => { - const f = await fixture() - f.describe.mockResolvedValue(response) - await expect(f.run({ jobId: 'job-1' })).rejects.toThrow(message) - expect(f.describe).toHaveBeenCalledTimes(3) - }) - - it.each([ - [{ TotalCount: 1001 }, /page limit/u], - [{ Tasks: [task, task] }, /multiple entries/u], - [{ Tasks: [{ ...task, Target: 'https://example.com/wrong' }] }, /does not contain the expected URL/u], - [{ Tasks: [{ ...task, Status: 'unexpected' }] }, /did not succeed/u], - ])('refuses ambiguous or mismatched task responses: %j', async (response, message) => { - const f = await fixture() - f.describe.mockResolvedValue(response) - await expect(f.run()).rejects.toThrow(message) - }) - - it('retains the DPAPI launcher and original identity in recovery instructions', async () => { - const f = await fixture() - f.describe.mockRejectedValue(sdkError('UnauthorizedOperation')) - await expect(f.run({ credentialLauncher: true })).rejects - .toThrow(/original -CredentialFile.*-Bucket.*-Upload -Latest -PurgeOnly -JobId job-1/u) - }) - - it('does not report completion when the final record cannot be saved', async () => { - const f = await fixture() - f.describe.mockImplementation(async () => { - await writeFile(join(await f.directory(), 'result.json'), 'occupied') - return { Tasks: [task] } - }) - await expect(f.run()).rejects.toThrow(/cannot save result; inspect EO task job-1/u) - const output = vi.spyOn(process.stdout, 'write') - expect(output).not.toHaveBeenCalledWith(expect.stringContaining('desktop purge: completed')) - }) - - it('rejects malformed recovery IDs before allocating a record or contacting EO', async () => { - const f = await fixture() - await expect(f.run({ jobId: 'unsafe value' })).rejects.toThrow(/invalid --job-id/u) - expect(await readdir(f.root)).toEqual([]) - expect(f.create).not.toHaveBeenCalled() - expect(f.describe).not.toHaveBeenCalled() - }) - - it('lazily initializes the pinned SDK when no client is supplied', async () => { - const f = await fixture() - vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' }) - vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockImplementation(async function (this: InstanceType) { - expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://' }) - return { Tasks: [task] } - }) - await purgeDesktopInstaller(config, f.root) - expect(await f.result()).toMatchObject({ success: true }) - }) - - it('accepts the minimal caller-owned client', async () => { - const f = await fixture() - const client: DesktopPurgeClient = { profile: f.client.profile, CreatePurgeTask: f.create, DescribePurgeTasks: f.describe } - await purgeDesktopInstaller(config, f.root, { client }) - expect(await f.result()).toMatchObject({ success: true }) + it.each([undefined, 'unsafe value', config.secretId])('reports an unconfirmed submission without a usable task ID (%s)', async (jobId) => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.fn().mockResolvedValue({ JobId: jobId }) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + 'desktop purge: EO returned no task ID; check EO task history before submitting again.', + ) + expect(submit).toHaveBeenCalledOnce() + expect(output).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/tests/purge-command.spec.ts b/apps/desktop/tests/purge-command.spec.ts index 30a7bbe213..a690db7a35 100644 --- a/apps/desktop/tests/purge-command.spec.ts +++ b/apps/desktop/tests/purge-command.spec.ts @@ -45,20 +45,19 @@ it('forwards the documented pnpm purge command through both package scripts to t import assert from 'node:assert/strict' export class DesktopPurgeError extends Error {} export const resolveDesktopPurgeConfig = (_env, target) => ({ target }) - export const purgeDesktopInstaller = async (config, _root, options) => { + export const purgeDesktopInstaller = async (config) => { assert.equal(config.target, 'mac-arm64') - assert.deepEqual(options, { jobId: 'job-1', credentialLauncher: false }) - console.log('purge-resume: mac-arm64 job-1') + console.log('purge-submit: mac-arm64') } `, } await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents))) const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name))) - const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64', '--job-id', 'job-1'] + const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64'] const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => { expect(error).not.toMatchObject({ killed: true }) throw error }) - expect(result.stdout).toContain('purge-resume: mac-arm64 job-1') + expect(result.stdout).toContain('purge-submit: mac-arm64') }) diff --git a/apps/desktop/tests/upload-target.spec.ts b/apps/desktop/tests/upload-target.spec.ts index ce15d7d54a..94e9aa5b4f 100644 --- a/apps/desktop/tests/upload-target.spec.ts +++ b/apps/desktop/tests/upload-target.spec.ts @@ -52,7 +52,7 @@ describe('desktop upload command', () => { expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan) if (environment === 'production' && latest) { expect(purgeDesktopInstaller).toHaveBeenCalledWith( - expect.objectContaining({ publicUrl: plan.publicUrl }), expect.any(String), { credentialLauncher: false }, + expect.objectContaining({ publicUrl: plan.publicUrl }), ) expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0]) .toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!) @@ -75,33 +75,36 @@ const productionEnvironment = { DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', } -describe('cache-only recovery command', () => { +describe('cache-only submission command', () => { beforeEach(() => { vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment) }) - it.each([undefined, 'job-1'])('refreshes without reading or uploading an installer, job=%s', async (jobId) => { - await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64', ...jobId ? ['--job-id', jobId] : []]) + it('submits a purge without reading or uploading an installer', async () => { + await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64']) expect(createDesktopUploadPlan).not.toHaveBeenCalled() expect(uploadDesktopRelease).not.toHaveBeenCalled() expect(tagDesktopRelease).not.toHaveBeenCalled() - expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ target: 'mac-arm64' }), expect.any(String), { ...jobId !== undefined ? { jobId } : {}, credentialLauncher: false }) + expect(purgeDesktopInstaller).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg', + })) }) - it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { + it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--latest', '--purge-only', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { await expect(uploadDesktopTarget(args)).rejects.toThrow() expect(uploadDesktopRelease).not.toHaveBeenCalled() expect(purgeDesktopInstaller).not.toHaveBeenCalled() }) it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => { - vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) vi.mocked(createDesktopUploadPlan).mockResolvedValue({ environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg', bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [], }) - vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: task incomplete; resume --job-id job-1')) - await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow(/installer uploaded; cache refresh incomplete.*job-1/u) + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: submission not confirmed')) + await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow('desktop purge: submission not confirmed') + expect(output).toHaveBeenCalledWith('desktop upload: installer uploaded; submitting cache purge.\n') expect(uploadDesktopRelease).toHaveBeenCalledOnce() expect(purgeDesktopInstaller).toHaveBeenCalledOnce() expect(tagDesktopRelease).not.toHaveBeenCalled() @@ -121,7 +124,7 @@ describe('cache-only recovery command', () => { expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n') expect(launcher ? stderr : stdout).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) - expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }), expect.any(String), expect.objectContaining({ credentialLauncher: launcher })) + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' })) } finally { process.exitCode = previousExitCode vi.unstubAllEnvs() diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 4346689ff8..4bd2bb1336 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', const roots: string[] = [] const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` -async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge-resume' | 'invalid-purge', deployment = 'production') { +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge' | 'invalid-purge', deployment = 'production') { const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) roots.push(root) const credentialPath = join(root, 'credential.clixml') let entry = launcher let uploadArguments = '' - if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge-resume' || mode === 'invalid-purge') { + if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge' || mode === 'invalid-purge') { const scripts = join(root, 'apps/desktop/scripts') await mkdir(scripts, { recursive: true }) entry = join(scripts, 'upload-with-credentials.ps1') @@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) assert.equal(process.env.NODE_OPTIONS, undefined) assert.equal(process.argv[2], 'win-x64') - assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge-resume' ? ", '--latest', '--purge-only', '--job-id', 'job-1'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge' ? ", '--latest', '--purge-only'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) console.log('fixture-id fixture-secret') console.error('private service details fixture-secret') process.exit(${mode === 'upload-failure' ? 17 : 0}) `) - uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge-resume' ? ' -Latest -PurgeOnly -JobId job-1' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` + uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge' ? ' -Latest -PurgeOnly' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` } const setup = mode === 'missing' ? '' : ` [pscustomobject]@{ @@ -141,7 +141,7 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () = expect(result.output).not.toContain('stage=decrypt-file') }) - it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge-resume'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { const result = await check(mode) expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0) expect(result.output).toContain('[REDACTED] [REDACTED]')