feat(deploy): 增加三节点 K3s 高可用迁移能力
新增 WireGuard 全互联、三 server embedded-etcd K3s、CloudNativePG 双实例、Barman OSS 备份、双 NGINX、Kubernetes Secret/RBAC 与本地旧文件按严格 24 小时清理。 新增维护窗口数据迁移、digest 固定滚动发布、应用回滚、跨节点文件 E2E、节点和数据库故障演练、CNPG 恢复与 etcd 快照验收脚本;洛杉矶仅作为带 NoSchedule 污点的仲裁节点。 所有生产 Secret 只在执行时从 0600 本地环境和旧生产容器导入,仓库不保存凭据;公网入口保持人工 DNS 故障切换边界。 验证:bash -n、ShellCheck、kubectl kustomize、Node 语法检查、Secret 扫描、OSS put/head/delete 实测。
This commit is contained in:
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHmac, createHash } from 'node:crypto'
|
||||
import { createReadStream, createWriteStream, statSync } from 'node:fs'
|
||||
import { request } from 'node:https'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
|
||||
function fail(message) {
|
||||
console.error(`oss_object=FAIL ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function requiredEnv(name, pattern) {
|
||||
const value = process.env[name] || ''
|
||||
if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
function encodeObjectKey(value) {
|
||||
if (!value || value.startsWith('/') || value.includes('..')) fail('invalid object key')
|
||||
return value.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
const [operation, objectKeyValue, filePath] = process.argv.slice(2)
|
||||
if (!['put', 'get', 'head', 'delete'].includes(operation || '')) {
|
||||
fail('usage: oss-object.mjs {put|get|head|delete} <object-key> [file]')
|
||||
}
|
||||
if ((operation === 'put' || operation === 'get') && !filePath) fail(`${operation} requires a file`)
|
||||
|
||||
const accessKey = requiredEnv('ALI_KEY')
|
||||
const secretKey = requiredEnv('ALI_SECRET')
|
||||
const region = requiredEnv('ALI_REGION', /^[a-z0-9-]+$/)
|
||||
const bucket = requiredEnv('ALI_BUCKET', /^[A-Za-z0-9.-]+$/)
|
||||
const objectKey = encodeObjectKey(objectKeyValue)
|
||||
const hostname = `${bucket}.${region}.aliyuncs.com`
|
||||
const canonicalResource = `/${bucket}/${objectKey}`
|
||||
const date = new Date().toUTCString()
|
||||
const method = operation.toUpperCase()
|
||||
const contentType = operation === 'put' ? 'application/octet-stream' : ''
|
||||
const stringToSign = `${method}\n\n${contentType}\n${date}\n${canonicalResource}`
|
||||
const signature = createHmac('sha1', secretKey).update(stringToSign).digest('base64')
|
||||
const headers = {
|
||||
Authorization: `OSS ${accessKey}:${signature}`,
|
||||
Date: date,
|
||||
}
|
||||
|
||||
let expectedDigest = ''
|
||||
if (operation === 'put') {
|
||||
let details
|
||||
try {
|
||||
details = statSync(filePath)
|
||||
} catch (error) {
|
||||
fail(`cannot stat upload file: ${error.message}`)
|
||||
}
|
||||
if (!details.isFile()) fail('upload source must be a regular file')
|
||||
headers['Content-Type'] = contentType
|
||||
headers['Content-Length'] = String(details.size)
|
||||
expectedDigest = createHash('sha256')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const outgoing = request({
|
||||
method,
|
||||
hostname,
|
||||
path: `/${objectKey}`,
|
||||
headers,
|
||||
timeout: 120_000,
|
||||
}, resolve)
|
||||
outgoing.once('timeout', () => outgoing.destroy(new Error('request timed out')))
|
||||
outgoing.once('error', reject)
|
||||
if (operation === 'put') {
|
||||
const source = createReadStream(filePath)
|
||||
source.on('data', (chunk) => expectedDigest.update(chunk))
|
||||
source.once('error', reject)
|
||||
source.pipe(outgoing)
|
||||
} else {
|
||||
outgoing.end()
|
||||
}
|
||||
}).catch((error) => fail(error.message))
|
||||
|
||||
const successStatus = operation === 'get' || operation === 'head' ? 200 : [200, 204].includes(response.statusCode)
|
||||
if (!successStatus) {
|
||||
let responseBody = ''
|
||||
for await (const chunk of response) responseBody += chunk
|
||||
fail(`HTTP ${response.statusCode} ${responseBody.slice(0, 240).replaceAll(/\s+/g, ' ')}`)
|
||||
}
|
||||
|
||||
if (operation === 'get') {
|
||||
const destination = createWriteStream(filePath, { flags: 'wx', mode: 0o600 })
|
||||
await pipeline(response, destination).catch((error) => fail(`download failed: ${error.message}`))
|
||||
}
|
||||
|
||||
if (operation === 'put') {
|
||||
for await (const _chunk of response) {
|
||||
// Drain the response so the request completes before reporting success.
|
||||
}
|
||||
console.log(`oss_object=PASS operation=put sha256=${expectedDigest.digest('hex')}`)
|
||||
} else {
|
||||
for await (const _chunk of response) {
|
||||
// GET is consumed by pipeline; other successful response bodies are empty.
|
||||
}
|
||||
console.log(`oss_object=PASS operation=${operation}`)
|
||||
}
|
||||
Reference in New Issue
Block a user