diff --git a/deploy/upcloud/cloudinit.go b/deploy/upcloud/cloudinit.go index c30da05..1331dfb 100644 --- a/deploy/upcloud/cloudinit.go +++ b/deploy/upcloud/cloudinit.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "text/template" + + "go.yaml.in/yaml/v3" ) //go:embed systemd/appview.service.tmpl @@ -189,3 +191,106 @@ func generateCloudInit(p cloudInitParams) (string, error) { } 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 +} diff --git a/deploy/upcloud/configs/cloudinit.sh.tmpl b/deploy/upcloud/configs/cloudinit.sh.tmpl index 058aef8..b069022 100644 --- a/deploy/upcloud/configs/cloudinit.sh.tmpl +++ b/deploy/upcloud/configs/cloudinit.sh.tmpl @@ -58,7 +58,7 @@ cat > {{.ConfigPath}} << 'CFGEOF' {{.ConfigYAML}} CFGEOF else - echo "Config {{.ConfigPath}} already exists, skipping" + echo "Config {{.ConfigPath}} already exists, skipping overwrite (missing keys merged separately)" fi # Systemd service diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index 9f204ef..da5145e 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -189,6 +189,13 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil { return err } + appviewConfigYAML, err := renderConfig(appviewConfigTmpl, vals) + if err != nil { + return fmt.Errorf("render appview config: %w", err) + } + if err := syncConfigKeys("appview", state.Appview.PublicIP, naming.AppviewConfigPath(), appviewConfigYAML); err != nil { + return fmt.Errorf("appview config sync: %w", err) + } } else { fmt.Println("Creating appview server...") appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion) @@ -214,6 +221,13 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil { return err } + holdConfigYAML, err := renderConfig(holdConfigTmpl, vals) + if err != nil { + return fmt.Errorf("render hold config: %w", err) + } + if err := syncConfigKeys("hold", state.Hold.PublicIP, naming.HoldConfigPath(), holdConfigYAML); err != nil { + return fmt.Errorf("hold config sync: %w", err) + } } else { fmt.Println("Creating hold server...") holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion) diff --git a/go.sum b/go.sum index 4fb715e..7acc5be 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= @@ -78,6 +79,7 @@ github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TI github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -95,6 +97,7 @@ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHz github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= @@ -161,6 +164,7 @@ github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkM github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -291,6 +295,7 @@ github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR9 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -345,6 +350,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=