92 lines
5.5 KiB
JavaScript
92 lines
5.5 KiB
JavaScript
|
|
// G1 真实 OSS 冒烟(独立脚本,凭证经运行时 env 注入,不写入任何被跟踪文件)
|
|||
|
|
// 覆盖:连通/签名方言 → presign PUT 直传 → head 核验 → presign GET 取回
|
|||
|
|
// → copyIfAbsent 禁止覆盖语义 → 幂等重放 → 清理 → 生命周期规则查询
|
|||
|
|
// 用法:OSS_KEY=... OSS_SECRET=... OSS_ENDPOINT=... OSS_BUCKET=... node scripts/g1-oss-smoke.mjs
|
|||
|
|
import { createRequire } from 'node:module';
|
|||
|
|
const require = createRequire(import.meta.url);
|
|||
|
|
const { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand, GetBucketLifecycleConfigurationCommand, HeadBucketCommand } = require('../packages/storage/node_modules/@aws-sdk/client-s3');
|
|||
|
|
const { getSignedUrl } = require('../packages/storage/node_modules/@aws-sdk/s3-request-presigner');
|
|||
|
|
|
|||
|
|
const KEY = process.env.OSS_KEY, SECRET = process.env.OSS_SECRET, ENDPOINT = process.env.OSS_ENDPOINT, BUCKET = process.env.OSS_BUCKET;
|
|||
|
|
const REGION = process.env.OSS_REGION || 'oss-cn-beijing';
|
|||
|
|
const PREFIX = 'photo-live/g1-smoke/';
|
|||
|
|
const results = [];
|
|||
|
|
const note = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); };
|
|||
|
|
|
|||
|
|
// 寻址风格:endpoint 已含 bucket(如 MinIO 按桶端点)→ path-style;
|
|||
|
|
// 基础 endpoint(阿里云 OSS/AWS 形态)→ virtual-host。可用 OSS_FORCE_PATH 覆盖。
|
|||
|
|
const endpointHasBucket = ENDPOINT.includes(`://${BUCKET}.`);
|
|||
|
|
const forcePathStyle = process.env.OSS_FORCE_PATH
|
|||
|
|
? process.env.OSS_FORCE_PATH === 'true'
|
|||
|
|
: endpointHasBucket;
|
|||
|
|
const client = new S3Client({
|
|||
|
|
region: REGION,
|
|||
|
|
endpoint: ENDPOINT,
|
|||
|
|
credentials: { accessKeyId: KEY, secretAccessKey: SECRET },
|
|||
|
|
forcePathStyle,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const stamp = Date.now().toString(36);
|
|||
|
|
const stagingKey = `${PREFIX}staging-${stamp}.bin`;
|
|||
|
|
const originalKey = `${PREFIX}originals-${stamp}.jpg`;
|
|||
|
|
const payload = Buffer.from(`g1-smoke-${stamp}-` + 'x'.repeat(1024));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 0. 连通 + 寻址风格
|
|||
|
|
await client.send(new HeadBucketCommand({ Bucket: BUCKET }));
|
|||
|
|
note('连通/HeadBucket', true, `${forcePathStyle ? 'path-style' : 'virtual-host'} 寻址, region=${REGION}`);
|
|||
|
|
|
|||
|
|
// 1. presign PUT 直传
|
|||
|
|
const putUrl = await getSignedUrl(client, new PutObjectCommand({ Bucket: BUCKET, Key: stagingKey, ContentType: 'image/jpeg' }), { expiresIn: 900 });
|
|||
|
|
const putRes = await fetch(putUrl, { method: 'PUT', headers: { 'Content-Type': 'image/jpeg' }, body: payload });
|
|||
|
|
note('presign PUT 直传', putRes.ok, putRes.ok ? `${payload.length}B` : `HTTP ${putRes.status}: ${(await putRes.text()).slice(0, 120)}`);
|
|||
|
|
|
|||
|
|
// 2. head 核验
|
|||
|
|
const head1 = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: stagingKey }));
|
|||
|
|
note('head 核验(字节+类型)', head1.ContentLength === payload.length && String(head1.ContentType).includes('image/jpeg'), `${head1.ContentLength}B ${head1.ContentType}`);
|
|||
|
|
|
|||
|
|
// 3. presign GET 取回
|
|||
|
|
const getUrl = await getSignedUrl(client, new GetObjectCommand({ Bucket: BUCKET, Key: stagingKey }), { expiresIn: 900 });
|
|||
|
|
const getRes = await fetch(getUrl);
|
|||
|
|
const back = Buffer.from(await getRes.arrayBuffer());
|
|||
|
|
note('presign GET 取回一致', getRes.ok && back.equals(payload), getRes.ok ? `${back.length}B` : `HTTP ${getRes.status}`);
|
|||
|
|
|
|||
|
|
// 4. copyIfAbsent 禁止覆盖语义(head-check + copy + 复核)
|
|||
|
|
let destHead = null;
|
|||
|
|
try { destHead = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: originalKey })); } catch { /* 不存在=正常 */ }
|
|||
|
|
if (destHead) throw new Error('目标键已存在(不应发生,时间戳唯一)');
|
|||
|
|
await client.send(new CopyObjectCommand({ Bucket: BUCKET, Key: originalKey, CopySource: `/${BUCKET}/${stagingKey}` }));
|
|||
|
|
const head2 = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: originalKey }));
|
|||
|
|
note('受控复制 staging→originals', head2.ContentLength === payload.length, `${head2.ContentLength}B etag=${head2.ETag}`);
|
|||
|
|
|
|||
|
|
// 5. 幂等重放:第二次 copy 路径(head 命中后跳过)
|
|||
|
|
const head3 = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: originalKey }));
|
|||
|
|
note('幂等重放(head 命中不重拷)', head3.ContentLength === payload.length);
|
|||
|
|
|
|||
|
|
// 6. 清理
|
|||
|
|
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: stagingKey }));
|
|||
|
|
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: originalKey }));
|
|||
|
|
note('清理测试对象(DeleteObject 权限)', true);
|
|||
|
|
|
|||
|
|
// 7. 生命周期规则查询(S3 兼容接口可能不支持,仅探测)
|
|||
|
|
try {
|
|||
|
|
const lc = await client.send(new GetBucketLifecycleConfigurationCommand({ Bucket: BUCKET }));
|
|||
|
|
const rules = lc.Rules || [];
|
|||
|
|
const risky = rules.filter(r => !r.Filter || JSON.stringify(r.Filter).includes('photo-live') === false);
|
|||
|
|
note('生命周期规则', true, rules.length ? `${rules.length} 条规则(需人工核对是否影响 photo-live/ 前缀;受影响规则 ${risky.length} 条待核)` : '无规则');
|
|||
|
|
} catch (e) {
|
|||
|
|
note('生命周期规则', true, 'S3 兼容口不支持查询——请控制台人工确认(需求 §5 红线)');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pass = results.every(r => r.ok);
|
|||
|
|
console.log(`\nG1 冒烟 ${pass ? '全部通过 ✅' : '存在失败 ❌'}`);
|
|||
|
|
process.exit(pass ? 0 : 1);
|
|||
|
|
} catch (e) {
|
|||
|
|
note('执行中断', false,
|
|||
|
|
`code=${e.Code || e.name} http=${e.$metadata?.httpStatusCode} ` +
|
|||
|
|
`reqId=${e.$metadata?.requestId || e.RequestId || '-'} ` +
|
|||
|
|
`msg=${(e.Message || e.message || '').slice(0, 160)}`);
|
|||
|
|
console.log('\nG1 冒烟失败 ❌');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|