wulf-pulse/lib/services/pipeline-steps/fetch-b2-result.ts

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);