Implements complete project archival workflow: - Migration 024: Add archival fields to projects table - New project-archive.ts tool coordinating: * Tarball creation via shell * S3 upload with vault credentials * Database metadata tracking * Optional local deletion * Cleanup of temp files - Registered in tool definitions and handlers Replaces manual archival process used for Fireberries/CyprusPulse. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
218 lines
5.7 KiB
TypeScript
218 lines
5.7 KiB
TypeScript
// Project archival operations - Complete project archival to S3
|
|
// Coordinates workflow: tar + S3 upload + database tracking + optional local deletion
|
|
|
|
import { execute, queryOne } from '../db.js';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { existsSync, statSync } from 'fs';
|
|
import { basename, dirname } from 'path';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
interface ProjectArchiveArgs {
|
|
project_key: string;
|
|
project_path: string;
|
|
delete_local?: boolean;
|
|
session_id?: string;
|
|
}
|
|
|
|
interface ProjectArchiveResult {
|
|
success: boolean;
|
|
message: string;
|
|
archive_location?: string;
|
|
archive_size?: number;
|
|
}
|
|
|
|
/**
|
|
* Verify project exists in database
|
|
*/
|
|
async function verifyProject(projectKey: string): Promise<boolean> {
|
|
const result = await queryOne<{ key: string }>(
|
|
'SELECT key FROM projects WHERE key = $1',
|
|
[projectKey]
|
|
);
|
|
return !!result;
|
|
}
|
|
|
|
/**
|
|
* Get vault credentials for S3 access
|
|
*/
|
|
async function getS3Credentials(): Promise<{
|
|
accessKey: string;
|
|
secretKey: string;
|
|
endpoint: string;
|
|
}> {
|
|
try {
|
|
const { stdout: accessKey } = await execAsync('vault get hetzner.s3_access_key');
|
|
const { stdout: secretKey } = await execAsync('vault get hetzner.s3_secret_key');
|
|
const { stdout: endpoint } = await execAsync('vault get hetzner.s3_endpoint');
|
|
|
|
return {
|
|
accessKey: accessKey.trim(),
|
|
secretKey: secretKey.trim(),
|
|
endpoint: endpoint.trim()
|
|
};
|
|
} catch (error) {
|
|
throw new Error(`Failed to get S3 credentials from vault: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create tarball of project directory
|
|
*/
|
|
async function createTarball(
|
|
projectPath: string,
|
|
projectKey: string
|
|
): Promise<{ tarballPath: string; size: number }> {
|
|
if (!existsSync(projectPath)) {
|
|
throw new Error(`Project path not found: ${projectPath}`);
|
|
}
|
|
|
|
const parentDir = dirname(projectPath);
|
|
const projectDir = basename(projectPath);
|
|
const date = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
|
const tarballName = `${projectKey}-${date}.tar.gz`;
|
|
const tarballPath = `/tmp/${tarballName}`;
|
|
|
|
console.log(`Creating tarball: ${tarballPath}`);
|
|
await execAsync(`cd "${parentDir}" && tar -czf "${tarballPath}" "${projectDir}"`);
|
|
|
|
const stats = statSync(tarballPath);
|
|
return { tarballPath, size: stats.size };
|
|
}
|
|
|
|
/**
|
|
* Upload tarball to S3
|
|
*/
|
|
async function uploadToS3(
|
|
tarballPath: string,
|
|
projectKey: string,
|
|
credentials: { accessKey: string; secretKey: string; endpoint: string }
|
|
): Promise<string> {
|
|
const s3Path = `s3://agiliton-archive/projects/${basename(tarballPath)}`;
|
|
|
|
console.log(`Uploading to S3: ${s3Path}`);
|
|
|
|
const env = {
|
|
...process.env,
|
|
AWS_ACCESS_KEY_ID: credentials.accessKey,
|
|
AWS_SECRET_ACCESS_KEY: credentials.secretKey
|
|
};
|
|
|
|
try {
|
|
await execAsync(
|
|
`aws --endpoint-url ${credentials.endpoint} s3 cp "${tarballPath}" "${s3Path}"`,
|
|
{ env }
|
|
);
|
|
|
|
// Verify upload
|
|
await execAsync(
|
|
`aws --endpoint-url ${credentials.endpoint} s3 ls "${s3Path}"`,
|
|
{ env }
|
|
);
|
|
|
|
return s3Path;
|
|
} catch (error) {
|
|
throw new Error(`Failed to upload to S3: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update database with archive metadata
|
|
*/
|
|
async function updateProjectArchive(
|
|
projectKey: string,
|
|
archiveLocation: string,
|
|
archiveSize: number,
|
|
sessionId?: string
|
|
): Promise<void> {
|
|
await execute(
|
|
`UPDATE projects
|
|
SET archived_at = NOW(),
|
|
archive_location = $1,
|
|
archive_size = $2,
|
|
archived_by_session = $3,
|
|
active = false
|
|
WHERE key = $4`,
|
|
[archiveLocation, archiveSize, sessionId || null, projectKey]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete local project directory
|
|
*/
|
|
async function deleteLocalProject(projectPath: string): Promise<void> {
|
|
console.log(`Deleting local project: ${projectPath}`);
|
|
await execAsync(`rm -rf "${projectPath}"`);
|
|
}
|
|
|
|
/**
|
|
* Clean up temporary tarball
|
|
*/
|
|
async function cleanupTarball(tarballPath: string): Promise<void> {
|
|
try {
|
|
await execAsync(`rm -f "${tarballPath}"`);
|
|
} catch (error) {
|
|
console.warn(`Failed to clean up tarball: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Archive a complete project to S3
|
|
*
|
|
* Workflow:
|
|
* 1. Verify project exists
|
|
* 2. Create tarball of project directory
|
|
* 3. Upload tarball to S3 (s3://agiliton-archive/projects/)
|
|
* 4. Update database with archive metadata
|
|
* 5. Optional: Delete local project directory
|
|
* 6. Clean up temporary tarball
|
|
*
|
|
* @param args - Archive parameters
|
|
* @returns Archive result with status and metadata
|
|
*/
|
|
export async function projectArchive(
|
|
args: ProjectArchiveArgs
|
|
): Promise<string> {
|
|
const { project_key, project_path, delete_local = false, session_id } = args;
|
|
|
|
try {
|
|
// 1. Verify project exists
|
|
const exists = await verifyProject(project_key);
|
|
if (!exists) {
|
|
return `Error: Project not found in database: ${project_key}`;
|
|
}
|
|
|
|
// 2. Get S3 credentials
|
|
const credentials = await getS3Credentials();
|
|
|
|
// 3. Create tarball
|
|
const { tarballPath, size } = await createTarball(project_path, project_key);
|
|
|
|
// 4. Upload to S3
|
|
const archiveLocation = await uploadToS3(tarballPath, project_key, credentials);
|
|
|
|
// 5. Update database
|
|
await updateProjectArchive(project_key, archiveLocation, size, session_id);
|
|
|
|
// 6. Optional: Delete local project
|
|
if (delete_local) {
|
|
await deleteLocalProject(project_path);
|
|
}
|
|
|
|
// 7. Clean up tarball
|
|
await cleanupTarball(tarballPath);
|
|
|
|
const sizeStr = `${(size / (1024 * 1024)).toFixed(1)}MB`;
|
|
const deletedStr = delete_local ? ' (local copy deleted)' : '';
|
|
|
|
return `✅ Project archived successfully\n` +
|
|
`Project: ${project_key}\n` +
|
|
`Location: ${archiveLocation}\n` +
|
|
`Size: ${sizeStr}${deletedStr}`;
|
|
|
|
} catch (error) {
|
|
return `❌ Archive failed: ${error}`;
|
|
}
|
|
}
|