From 88c7fcaca9f564bfc15cc77bfdeae59afde566fa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 20:17:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/test/README.md | 2 + .../publish-nacos-config-contract.test.mjs | 93 +++++++++++++++++++ scripts/publish-nacos-config.ps1 | 15 ++- 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 scripts/publish-nacos-config-contract.test.mjs diff --git a/config/nacos/test/README.md b/config/nacos/test/README.md index 5680b52c3..09a6d2b79 100644 --- a/config/nacos/test/README.md +++ b/config/nacos/test/README.md @@ -7,3 +7,5 @@ 离线预检:`./scripts/publish-nacos-config.ps1 -ServerAddr '' -Namespace '' -DryRun`(从后端根运行,不联网、不登录)。正式发布去掉 DryRun,会修改指定 Nacos,需授权;认证从进程环境 NACOS_USERNAME/NACOS_PASSWORD 取得。失败立即停止,已发布项不自动撤销,保存本轮配置版本并在停写窗口重新发布完整匹配集合。 切换顺序、发布与恢复约束见 `../../../deploy/TEST单机部署执行清单.md`;文件存在不代表环境已发布。 + +发布脚本仅使用 Nacos v3:`POST /nacos/v3/auth/user/login` 取得 accessToken,`GET /nacos/v3/admin/core/namespace/list` 精确核对实际 Namespace ID,再逐项 `POST /nacos/v3/admin/cs/config`(`namespaceId`、`groupName`、`dataId`、`type=yaml`、UTF-8 表单内容)。发布响应必须为 `code=0` 且布尔值 `data=true`;旧 v1 路径、tenant/group 参数和裸 true 响应不再支持。离线合同:`node --test scripts/publish-nacos-config-contract.test.mjs`,全部 HTTP 由测试替身拦截。协议依据:[Admin API](https://nacos.io/en/docs/latest/manual/admin/admin-api/)、[鉴权](https://nacos.io/en/docs/latest/manual/admin/auth/)。 diff --git a/scripts/publish-nacos-config-contract.test.mjs b/scripts/publish-nacos-config-contract.test.mjs new file mode 100644 index 000000000..176d28e2f --- /dev/null +++ b/scripts/publish-nacos-config-contract.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const publisher = fileURLToPath(new URL('./publish-nacos-config.ps1', import.meta.url)) +const source = readFileSync(publisher, 'utf8') +const quote = value => `'${value.replaceAll("'", "''")}'` + +function run(mode) { + // Override the HTTP cmdlet before invoking the actual publisher: no network is possible. + const command = ` +$ErrorActionPreference = 'Stop' +$global:mode = ${quote(mode)} +$global:calls = 0 +$global:published = 0 +function Invoke-RestMethod { + param($Method, $Uri, $Body, $TimeoutSec, $ContentType) + $global:calls++ + if ($global:mode -eq 'dry') { throw 'DryRun attempted HTTP' } + if ($Uri -eq 'http://offline.invalid/nacos/v3/auth/user/login') { + if ($Method -ne 'Post' -or $Body.username -ne 'fixture-user' -or $Body.password -ne 'fixture-password') { throw 'Bad login request' } + if ($global:mode -eq 'login-failure') { return @{} } + return @{accessToken='fixture-token'} + } + if ($Uri -eq 'http://offline.invalid/nacos/v3/admin/core/namespace/list') { + if ($Method -ne 'Get' -or $Body.accessToken -ne 'fixture-token') { throw 'Bad namespace request' } + if ($global:mode -eq 'missing-namespace') { return @{code=0;data=@(@{namespace='other';namespaceShowName='fixture-id'})} } + if ($global:mode -eq 'namespace-failure') { return @{code=500;data=@(@{namespace='fixture-id'})} } + return @{code=0;data=@(@{namespace='fixture-id';namespaceShowName='TEST'})} + } + if ($Uri -ne 'http://offline.invalid/nacos/v3/admin/cs/config' -or $Method -ne 'Post') { throw 'Unexpected endpoint' } + $form = @{} + foreach ($pair in $Body.Split('&')) { + $parts = $pair.Split('=',2) + $form[[uri]::UnescapeDataString($parts[0])] = [uri]::UnescapeDataString($parts[1]) + } + if ($ContentType -ne 'application/x-www-form-urlencoded; charset=UTF-8' -or + $form.namespaceId -ne 'fixture-id' -or $form.groupName -ne 'BAIYUE' -or + $form.accessToken -ne 'fixture-token' -or $form.type -ne 'yaml' -or + $form.ContainsKey('tenant') -or $form.ContainsKey('group')) { throw 'Bad config form' } + $expected = Get-Content -LiteralPath (Join-Path ${quote(fileURLToPath(new URL('../config/nacos/test/', import.meta.url)))} $form.dataId) -Raw -Encoding UTF8 + if ($form.content -cne $expected) { throw 'YAML changed during encoding' } + if ($global:published -eq 0 -and $form.dataId -ne 'baiyue-common.yml') { throw 'Common config must publish first' } + $global:published++ + switch ($global:mode) { + 'false-data' { return @{code=0;data=$false} } + 'error-code' { return @{code=500;data=$true} } + 'missing-code' { return @{data=$true} } + 'string-data' { return @{code=0;data='true'} } + 'legacy-response' { return $true } + default { return @{code=0;data=$true} } + } +} +$failed = $false +try { + & ${quote(publisher)} -ServerAddr offline.invalid -Namespace fixture-id -Username fixture-user -Password fixture-password -DryRun:($global:mode -eq 'dry') +} catch { $failed = $true } +[ordered]@{failed=$failed; calls=$global:calls; published=$global:published} | ConvertTo-Json -Compress +` + const result = spawnSync(process.platform === 'win32' ? 'powershell.exe' : 'pwsh', + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')], + { encoding: 'utf8', timeout: 30_000 }) + assert.equal(result.status, 0, result.stderr || result.error?.message) + return JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1)) +} + +test('publisher uses v3 only and keeps TEST namespace validation', () => { + assert.doesNotMatch(source, /\/v1\/|\/v2\/|tenant=|\bgroup=/) + assert.match(source, /ValidateSet\('test'\)/) + assert.match(source, /Namespace -eq 'public'/) +}) + +test('DryRun makes zero HTTP calls without publishing', () => { + assert.deepEqual(run('dry'), { failed: false, calls: 0, published: 0 }) +}) + +test('v3 publishes all 14 original YAML documents with common first', () => { + assert.deepEqual(run('success'), { failed: false, calls: 16, published: 14 }) +}) + +for (const mode of ['login-failure', 'missing-namespace', 'namespace-failure']) { + test(`${mode} prevents all configuration writes`, () => { + assert.deepEqual(run(mode), { failed: true, calls: mode === 'login-failure' ? 1 : 2, published: 0 }) + }) +} + +for (const mode of ['false-data', 'error-code', 'missing-code', 'string-data', 'legacy-response']) { + test(`${mode} stops at the first failed publish`, () => { + assert.deepEqual(run(mode), { failed: true, calls: 3, published: 1 }) + }) +} diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 index 6005bb2e6..f20627105 100644 --- a/scripts/publish-nacos-config.ps1 +++ b/scripts/publish-nacos-config.ps1 @@ -16,12 +16,17 @@ if ($files.Count -ne 14) { throw 'Expected common configuration and 13 retained if ($DryRun) { $files | ForEach-Object { Write-Host "Would publish $($_.Name) -> $Namespace / $Group" }; return } if (-not $Username -or -not $Password) { throw 'NACOS_USERNAME and NACOS_PASSWORD are required' } $base = "http://$ServerAddr/nacos" -$login = Invoke-RestMethod -Method Post -Uri "$base/v1/auth/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 -if (-not $login.accessToken) { throw 'Nacos authentication failed' } +$login = Invoke-RestMethod -Method Post -Uri "$base/v3/auth/user/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 +if ([string]::IsNullOrWhiteSpace($login.accessToken)) { throw 'Nacos authentication failed' } +$namespaces = Invoke-RestMethod -Method Get -Uri "$base/v3/admin/core/namespace/list" -Body @{accessToken=$login.accessToken} -TimeoutSec 10 +if ($null -eq $namespaces.code -or $namespaces.code -ne 0 -or + -not @($namespaces.data | Where-Object { $_.namespace -ceq $Namespace }).Count) { + throw 'Nacos TEST namespace ID was not found or namespace query failed' +} foreach ($file in $files) { - $body = @{dataId=$file.Name; group=$Group; tenant=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} + $body = @{dataId=$file.Name; groupName=$Group; namespaceId=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} $pairs = foreach ($entry in $body.GetEnumerator()) { [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) } - $result = Invoke-RestMethod -Method Post -Uri "$base/v1/cs/configs" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 - if ([string]$result -ne 'true') { throw "Publish failed: $($file.Name)" } + $result = Invoke-RestMethod -Method Post -Uri "$base/v3/admin/cs/config" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 + if ($null -eq $result.code -or $result.code -ne 0 -or $result.data -isnot [bool] -or $result.data -ne $true) { throw "Publish failed: $($file.Name)" } Write-Host "Published $($file.Name) -> $Namespace / $Group" }