Add EC2 Launch Template strain genesis for AWS horizontal scale.
Ship POST /api/v1/forge/launch-template with cloud-init user-data embedding genesis snapshot hash and strain card ID; first template-tagged auth pins SpreadGeneration=0 and ParentAgentID=template.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { LAUNCH_TEMPLATE_FILES, downloadLaunchTemplateFile, type LaunchTemplateExportResponse } from '../../help/launchTemplateExport';
|
||||
|
||||
export default function LaunchTemplateExportPanel({ serverBase, buildId = '', campaign = '', strainCardId = '' }: {
|
||||
serverBase: string; buildId?: string; campaign?: string; strainCardId?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [last, setLast] = useState<LaunchTemplateExportResponse | null>(null);
|
||||
|
||||
const onGenerate = async () => {
|
||||
setErr('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const resp = await api.forgeLaunchTemplate({
|
||||
server_url: serverBase,
|
||||
build_id: buildId.trim() || undefined,
|
||||
campaign: campaign.trim() || undefined,
|
||||
strain_card_id: strainCardId.trim() || undefined,
|
||||
});
|
||||
if (!resp.success) throw new Error(resp.error ?? 'launch template export failed');
|
||||
setLast(resp);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crucible-launch-template" style={{ marginTop: '0.75rem' }}>
|
||||
<p className="crucible-seek-blurb" style={{ marginBottom: '0.5rem' }}>
|
||||
EC2 Launch Template strain genesis — cloud-init embeds genesis snapshot hash, strain card ID, and server URL.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button type="button" className="button crucible-op-btn" disabled={busy || !serverBase.trim()} onClick={() => void onGenerate()}>
|
||||
{busy ? 'Generating…' : 'Generate launch template'}
|
||||
</button>
|
||||
{last ? LAUNCH_TEMPLATE_FILES.map((f) => (
|
||||
<button key={f.id} type="button" className="button crucible-op-btn" onClick={() => downloadLaunchTemplateFile(last, f.field, f.label)}>
|
||||
{f.label}
|
||||
</button>
|
||||
)) : null}
|
||||
</div>
|
||||
{err ? <p className="form-error" style={{ marginTop: '0.35rem' }}>{err}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
server/web/src/help/launchTemplateExport.test.ts
Normal file
15
server/web/src/help/launchTemplateExport.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LAUNCH_TEMPLATE_FILES, launchTemplateZipName } from './launchTemplateExport';
|
||||
|
||||
describe('launchTemplateExport', () => {
|
||||
it('lists standalone AWS artifact files', () => {
|
||||
expect(LAUNCH_TEMPLATE_FILES.map((f) => f.label)).toEqual([
|
||||
'launch-template.json', 'user-data.sh', 'asg-example.json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps campaign slug to zip filename', () => {
|
||||
expect(launchTemplateZipName('AWS Wave 1')).toBe('aetherforge-launch-template-aws-wave-1.zip');
|
||||
expect(launchTemplateZipName()).toBe('aetherforge-launch-template-genesis.zip');
|
||||
});
|
||||
});
|
||||
46
server/web/src/help/launchTemplateExport.ts
Normal file
46
server/web/src/help/launchTemplateExport.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface LaunchTemplateExportRequest {
|
||||
server_url: string;
|
||||
build_id?: string;
|
||||
strain_card_id?: string;
|
||||
campaign?: string;
|
||||
ami_id?: string;
|
||||
instance_type?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface LaunchTemplateExportResponse {
|
||||
success: boolean;
|
||||
genesis_snapshot_hash: string;
|
||||
strain_card_id: string;
|
||||
launch_template_json: string;
|
||||
user_data_sh: string;
|
||||
asg_example_json: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const LAUNCH_TEMPLATE_FILES = [
|
||||
{ id: 'launch-template', label: 'launch-template.json', field: 'launch_template_json' as const },
|
||||
{ id: 'user-data', label: 'user-data.sh', field: 'user_data_sh' as const },
|
||||
{ id: 'asg-example', label: 'asg-example.json', field: 'asg_example_json' as const },
|
||||
] as const;
|
||||
|
||||
export function launchTemplateZipName(campaign?: string): string {
|
||||
const slug = (campaign ?? '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug ? `aetherforge-launch-template-${slug}.zip` : 'aetherforge-launch-template-genesis.zip';
|
||||
}
|
||||
|
||||
export function downloadLaunchTemplateFile(
|
||||
resp: LaunchTemplateExportResponse,
|
||||
file: (typeof LAUNCH_TEMPLATE_FILES)[number]['field'],
|
||||
filename: string,
|
||||
): void {
|
||||
const content = resp[file];
|
||||
if (!content) return;
|
||||
const blob = new Blob([content], { type: file === 'user_data_sh' ? 'text/x-shellscript' : 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user