147 lines
4.2 KiB
TypeScript
147 lines
4.2 KiB
TypeScript
/**
|
|
* Fetch B2 Result Step — download a JSON result from Backblaze B2 via S3-compatible presigned URL.
|
|
* Config: {
|
|
* object_key: "{{context.diagnostic_object_key}}", // key in the bucket
|
|
* output_key: "diagnostic_results", // context key for parsed JSON
|
|
* bucket?: "wulf-audits", // defaults to B2_BUCKET env
|
|
* }
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import { registerStepExecutor } from '../pipeline-engine';
|
|
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
|
|
|
const B2_DEFAULTS = {
|
|
bucket: process.env.B2_BUCKET || 'wulf-audits',
|
|
region: process.env.B2_REGION || 'us-west-002',
|
|
endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com',
|
|
keyId: process.env.B2_KEY_ID || '',
|
|
appKey: process.env.B2_APP_KEY || '',
|
|
};
|
|
|
|
function hmacSha256(key: string | Buffer, data: string): Buffer {
|
|
return crypto.createHmac('sha256', key).update(data).digest();
|
|
}
|
|
|
|
function generatePresignedUrl(objectKey: string, bucket?: string): string {
|
|
const { region, endpoint, keyId, appKey } = B2_DEFAULTS;
|
|
const bkt = bucket || B2_DEFAULTS.bucket;
|
|
|
|
if (!keyId || !appKey) {
|
|
throw new Error('B2_KEY_ID and B2_APP_KEY environment variables are required');
|
|
}
|
|
|
|
const expiresIn = 600; // 10 minutes
|
|
const method = 'GET';
|
|
const host = endpoint;
|
|
const canonicalUri = `/${bkt}/${objectKey}`;
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
|
|
const now = new Date();
|
|
const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
const dateStamp = amzDate.slice(0, 8);
|
|
|
|
const credentialScope = `${dateStamp}/${region}/s3/aws4_request`;
|
|
const canonicalHeaders = `host:${host}\n`;
|
|
const signedHeaders = 'host';
|
|
|
|
const qs: Record<string, string> = {
|
|
'X-Amz-Algorithm': algorithm,
|
|
'X-Amz-Credential': encodeURIComponent(`${keyId}/${credentialScope}`),
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Expires': expiresIn.toString(),
|
|
'X-Amz-SignedHeaders': signedHeaders,
|
|
};
|
|
|
|
const canonicalQueryString = Object.keys(qs)
|
|
.sort()
|
|
.map((k) => `${k}=${qs[k]}`)
|
|
.join('&');
|
|
|
|
const payloadHash = 'UNSIGNED-PAYLOAD';
|
|
|
|
const canonicalRequest = [
|
|
method,
|
|
canonicalUri,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash,
|
|
].join('\n');
|
|
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
|
|
].join('\n');
|
|
|
|
// Derive signing key
|
|
const kDate = hmacSha256('AWS4' + appKey, dateStamp);
|
|
const kRegion = hmacSha256(kDate, region);
|
|
const kService = hmacSha256(kRegion, 's3');
|
|
const kSigning = hmacSha256(kService, 'aws4_request');
|
|
|
|
const signature = crypto
|
|
.createHmac('sha256', kSigning)
|
|
.update(stringToSign)
|
|
.digest('hex');
|
|
|
|
return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
|
}
|
|
|
|
async function executeFetchB2Result(
|
|
step: PipelineStep,
|
|
_context: PipelineContext,
|
|
_executionId: number
|
|
): Promise<StepExecutorResult> {
|
|
const objectKey = step.config.object_key;
|
|
const outputKey = step.config.output_key || 'b2_result';
|
|
const bucket = step.config.bucket;
|
|
|
|
if (!objectKey) {
|
|
return { success: false, error: 'Missing object_key in fetch_b2_result config' };
|
|
}
|
|
|
|
console.log(`[PIPELINE:fetch_b2_result] Fetching ${objectKey} from B2`);
|
|
|
|
try {
|
|
const url = generatePresignedUrl(objectKey, bucket);
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
success: false,
|
|
error: `B2 download failed: ${response.status} ${response.statusText}`,
|
|
};
|
|
}
|
|
|
|
const text = await response.text();
|
|
let parsed: any;
|
|
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
// Not JSON — store as raw text
|
|
parsed = text;
|
|
}
|
|
|
|
console.log(`[PIPELINE:fetch_b2_result] Downloaded ${text.length} bytes, parsed as ${typeof parsed}`);
|
|
|
|
return {
|
|
success: true,
|
|
output: {
|
|
[outputKey]: parsed,
|
|
[`${outputKey}_raw_length`]: text.length,
|
|
b2_object_key: objectKey,
|
|
},
|
|
};
|
|
} catch (err: any) {
|
|
return {
|
|
success: false,
|
|
error: `B2 fetch error: ${err.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
registerStepExecutor('fetch_b2_result', executeFetchB2Result);
|