Files
at-container-registry/deploy/upcloud/cloudinit.go
2026-02-08 21:20:02 -06:00

297 lines
8.6 KiB
Go

package main
import (
"bytes"
_ "embed"
"fmt"
"strings"
"text/template"
"go.yaml.in/yaml/v3"
)
//go:embed systemd/appview.service.tmpl
var appviewServiceTmpl string
//go:embed systemd/hold.service.tmpl
var holdServiceTmpl string
//go:embed configs/appview.yaml.tmpl
var appviewConfigTmpl string
//go:embed configs/hold.yaml.tmpl
var holdConfigTmpl string
//go:embed configs/cloudinit.sh.tmpl
var cloudInitTmpl string
// ConfigValues holds values injected into config YAML templates.
// Only truly dynamic/computed values belong here — deployment-specific
// values like client_name, owner_did, etc. are literal in the templates.
type ConfigValues struct {
// S3 / Object Storage
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
// Infrastructure (computed from zone + config)
Zone string // e.g. "us-chi1"
HoldDomain string // e.g. "us-chi1.cove.seamark.dev"
HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev"
BasePath string // e.g. "/var/lib/seamark"
}
// renderConfig executes a Go template with the given values.
func renderConfig(tmplStr string, vals *ConfigValues) (string, error) {
t, err := template.New("config").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("parse config template: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, vals); err != nil {
return "", fmt.Errorf("render config template: %w", err)
}
return buf.String(), nil
}
// serviceUnitParams holds values for rendering systemd service unit templates.
type serviceUnitParams struct {
DisplayName string // e.g. "Seamark"
User string // e.g. "seamark"
BinaryPath string // e.g. "/opt/seamark/bin/seamark-appview"
ConfigPath string // e.g. "/etc/seamark/appview.yaml"
DataDir string // e.g. "/var/lib/seamark"
ServiceName string // e.g. "seamark-appview"
}
func renderServiceUnit(tmplStr string, p serviceUnitParams) (string, error) {
t, err := template.New("service").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("parse service template: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, p); err != nil {
return "", fmt.Errorf("render service template: %w", err)
}
return buf.String(), nil
}
// generateAppviewCloudInit generates the cloud-init user-data script for the appview server.
func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) {
naming := cfg.Naming()
configYAML, err := renderConfig(appviewConfigTmpl, vals)
if err != nil {
return "", fmt.Errorf("appview config: %w", err)
}
serviceUnit, err := renderServiceUnit(appviewServiceTmpl, serviceUnitParams{
DisplayName: naming.DisplayName(),
User: naming.SystemUser(),
BinaryPath: naming.InstallDir() + "/bin/" + naming.Appview(),
ConfigPath: naming.AppviewConfigPath(),
DataDir: naming.BasePath(),
ServiceName: naming.Appview(),
})
if err != nil {
return "", fmt.Errorf("appview service unit: %w", err)
}
return generateCloudInit(cloudInitParams{
GoVersion: goVersion,
BinaryName: naming.Appview(),
BuildCmd: "appview",
ServiceUnit: serviceUnit,
ConfigYAML: configYAML,
ConfigPath: naming.AppviewConfigPath(),
ServiceName: naming.Appview(),
DataDir: naming.BasePath(),
RepoURL: cfg.RepoURL,
RepoBranch: cfg.RepoBranch,
InstallDir: naming.InstallDir(),
SystemUser: naming.SystemUser(),
ConfigDir: naming.ConfigDir(),
LogFile: naming.LogFile(),
DisplayName: naming.DisplayName(),
})
}
// generateHoldCloudInit generates the cloud-init user-data script for the hold server.
func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) {
naming := cfg.Naming()
configYAML, err := renderConfig(holdConfigTmpl, vals)
if err != nil {
return "", fmt.Errorf("hold config: %w", err)
}
serviceUnit, err := renderServiceUnit(holdServiceTmpl, serviceUnitParams{
DisplayName: naming.DisplayName(),
User: naming.SystemUser(),
BinaryPath: naming.InstallDir() + "/bin/" + naming.Hold(),
ConfigPath: naming.HoldConfigPath(),
DataDir: naming.BasePath(),
ServiceName: naming.Hold(),
})
if err != nil {
return "", fmt.Errorf("hold service unit: %w", err)
}
return generateCloudInit(cloudInitParams{
GoVersion: goVersion,
BinaryName: naming.Hold(),
BuildCmd: "hold",
ServiceUnit: serviceUnit,
ConfigYAML: configYAML,
ConfigPath: naming.HoldConfigPath(),
ServiceName: naming.Hold(),
DataDir: naming.BasePath(),
RepoURL: cfg.RepoURL,
RepoBranch: cfg.RepoBranch,
InstallDir: naming.InstallDir(),
SystemUser: naming.SystemUser(),
ConfigDir: naming.ConfigDir(),
LogFile: naming.LogFile(),
DisplayName: naming.DisplayName(),
})
}
type cloudInitParams struct {
GoVersion string
BinaryName string
BuildCmd string
ServiceUnit string
ConfigYAML string
ConfigPath string
ServiceName string
DataDir string
RepoURL string
RepoBranch string
InstallDir string
SystemUser string
ConfigDir string
LogFile string
DisplayName string
}
func generateCloudInit(p cloudInitParams) (string, error) {
// Escape single quotes in embedded content for heredoc safety
p.ServiceUnit = strings.ReplaceAll(p.ServiceUnit, "'", "'\\''")
p.ConfigYAML = strings.ReplaceAll(p.ConfigYAML, "'", "'\\''")
t, err := template.New("cloudinit").Parse(cloudInitTmpl)
if err != nil {
return "", fmt.Errorf("parse cloudinit template: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, p); err != nil {
return "", fmt.Errorf("render cloudinit template: %w", err)
}
return buf.String(), nil
}
// syncConfigKeys fetches the existing config from a server and merges in any
// missing keys from the rendered template. Existing values are never overwritten.
func syncConfigKeys(name, ip, configPath, templateYAML string) error {
remote, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", configPath), false)
if err != nil {
fmt.Printf(" config sync: could not reach %s (%v)\n", name, err)
return nil
}
remote = strings.TrimSpace(remote)
if remote == "__MISSING__" {
fmt.Printf(" config sync: %s not yet created (cloud-init will handle it)\n", name)
return nil
}
// Parse both into yaml.Node trees
var templateDoc yaml.Node
if err := yaml.Unmarshal([]byte(templateYAML), &templateDoc); err != nil {
return fmt.Errorf("parse template yaml: %w", err)
}
var existingDoc yaml.Node
if err := yaml.Unmarshal([]byte(remote), &existingDoc); err != nil {
return fmt.Errorf("parse remote yaml: %w", err)
}
// Unwrap document nodes to get the root mapping
templateRoot := unwrapDocNode(&templateDoc)
existingRoot := unwrapDocNode(&existingDoc)
if templateRoot == nil || existingRoot == nil {
fmt.Printf(" config sync: %s skipped (unexpected YAML structure)\n", name)
return nil
}
added := mergeYAMLNodes(templateRoot, existingRoot)
if !added {
fmt.Printf(" config sync: %s up to date\n", name)
return nil
}
// Marshal the modified tree back
merged, err := yaml.Marshal(&existingDoc)
if err != nil {
return fmt.Errorf("marshal merged yaml: %w", err)
}
// Write back to server
script := fmt.Sprintf("cat > %s << 'CFGEOF'\n%sCFGEOF", configPath, string(merged))
if _, err := runSSH(ip, script, false); err != nil {
return fmt.Errorf("write merged config: %w", err)
}
fmt.Printf(" config sync: %s updated with new keys\n", name)
return nil
}
// unwrapDocNode returns the root mapping node, unwrapping a DocumentNode wrapper if present.
func unwrapDocNode(n *yaml.Node) *yaml.Node {
if n.Kind == yaml.DocumentNode && len(n.Content) > 0 {
return n.Content[0]
}
if n.Kind == yaml.MappingNode {
return n
}
return nil
}
// mergeYAMLNodes recursively adds keys from base into existing that are not
// already present. Existing values are never overwritten. Returns true if any
// new keys were added.
func mergeYAMLNodes(base, existing *yaml.Node) bool {
if base.Kind != yaml.MappingNode || existing.Kind != yaml.MappingNode {
return false
}
added := false
for i := 0; i+1 < len(base.Content); i += 2 {
baseKey := base.Content[i]
baseVal := base.Content[i+1]
// Look for this key in existing
found := false
for j := 0; j+1 < len(existing.Content); j += 2 {
if existing.Content[j].Value == baseKey.Value {
found = true
// If both are mappings, recurse to merge sub-keys
if baseVal.Kind == yaml.MappingNode && existing.Content[j+1].Kind == yaml.MappingNode {
if mergeYAMLNodes(baseVal, existing.Content[j+1]) {
added = true
}
}
break
}
}
if !found {
// Append the missing key+value pair
existing.Content = append(existing.Content, baseKey, baseVal)
added = true
}
}
return added
}