A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

add missing config keys on provision

evan.jarrett.net fbe73384 bc034e34

verified
+126 -1
+105
deploy/upcloud/cloudinit.go
··· 6 6 "fmt" 7 7 "strings" 8 8 "text/template" 9 + 10 + "go.yaml.in/yaml/v3" 9 11 ) 10 12 11 13 //go:embed systemd/appview.service.tmpl ··· 189 191 } 190 192 return buf.String(), nil 191 193 } 194 + 195 + // syncConfigKeys fetches the existing config from a server and merges in any 196 + // missing keys from the rendered template. Existing values are never overwritten. 197 + func syncConfigKeys(name, ip, configPath, templateYAML string) error { 198 + remote, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", configPath), false) 199 + if err != nil { 200 + fmt.Printf(" config sync: could not reach %s (%v)\n", name, err) 201 + return nil 202 + } 203 + remote = strings.TrimSpace(remote) 204 + 205 + if remote == "__MISSING__" { 206 + fmt.Printf(" config sync: %s not yet created (cloud-init will handle it)\n", name) 207 + return nil 208 + } 209 + 210 + // Parse both into yaml.Node trees 211 + var templateDoc yaml.Node 212 + if err := yaml.Unmarshal([]byte(templateYAML), &templateDoc); err != nil { 213 + return fmt.Errorf("parse template yaml: %w", err) 214 + } 215 + var existingDoc yaml.Node 216 + if err := yaml.Unmarshal([]byte(remote), &existingDoc); err != nil { 217 + return fmt.Errorf("parse remote yaml: %w", err) 218 + } 219 + 220 + // Unwrap document nodes to get the root mapping 221 + templateRoot := unwrapDocNode(&templateDoc) 222 + existingRoot := unwrapDocNode(&existingDoc) 223 + if templateRoot == nil || existingRoot == nil { 224 + fmt.Printf(" config sync: %s skipped (unexpected YAML structure)\n", name) 225 + return nil 226 + } 227 + 228 + added := mergeYAMLNodes(templateRoot, existingRoot) 229 + if !added { 230 + fmt.Printf(" config sync: %s up to date\n", name) 231 + return nil 232 + } 233 + 234 + // Marshal the modified tree back 235 + merged, err := yaml.Marshal(&existingDoc) 236 + if err != nil { 237 + return fmt.Errorf("marshal merged yaml: %w", err) 238 + } 239 + 240 + // Write back to server 241 + script := fmt.Sprintf("cat > %s << 'CFGEOF'\n%sCFGEOF", configPath, string(merged)) 242 + if _, err := runSSH(ip, script, false); err != nil { 243 + return fmt.Errorf("write merged config: %w", err) 244 + } 245 + fmt.Printf(" config sync: %s updated with new keys\n", name) 246 + return nil 247 + } 248 + 249 + // unwrapDocNode returns the root mapping node, unwrapping a DocumentNode wrapper if present. 250 + func unwrapDocNode(n *yaml.Node) *yaml.Node { 251 + if n.Kind == yaml.DocumentNode && len(n.Content) > 0 { 252 + return n.Content[0] 253 + } 254 + if n.Kind == yaml.MappingNode { 255 + return n 256 + } 257 + return nil 258 + } 259 + 260 + // mergeYAMLNodes recursively adds keys from base into existing that are not 261 + // already present. Existing values are never overwritten. Returns true if any 262 + // new keys were added. 263 + func mergeYAMLNodes(base, existing *yaml.Node) bool { 264 + if base.Kind != yaml.MappingNode || existing.Kind != yaml.MappingNode { 265 + return false 266 + } 267 + 268 + added := false 269 + for i := 0; i+1 < len(base.Content); i += 2 { 270 + baseKey := base.Content[i] 271 + baseVal := base.Content[i+1] 272 + 273 + // Look for this key in existing 274 + found := false 275 + for j := 0; j+1 < len(existing.Content); j += 2 { 276 + if existing.Content[j].Value == baseKey.Value { 277 + found = true 278 + // If both are mappings, recurse to merge sub-keys 279 + if baseVal.Kind == yaml.MappingNode && existing.Content[j+1].Kind == yaml.MappingNode { 280 + if mergeYAMLNodes(baseVal, existing.Content[j+1]) { 281 + added = true 282 + } 283 + } 284 + break 285 + } 286 + } 287 + 288 + if !found { 289 + // Append the missing key+value pair 290 + existing.Content = append(existing.Content, baseKey, baseVal) 291 + added = true 292 + } 293 + } 294 + 295 + return added 296 + }
+1 -1
deploy/upcloud/configs/cloudinit.sh.tmpl
··· 58 58 {{.ConfigYAML}} 59 59 CFGEOF 60 60 else 61 - echo "Config {{.ConfigPath}} already exists, skipping" 61 + echo "Config {{.ConfigPath}} already exists, skipping overwrite (missing keys merged separately)" 62 62 fi 63 63 64 64 # Systemd service
+14
deploy/upcloud/provision.go
··· 189 189 if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil { 190 190 return err 191 191 } 192 + appviewConfigYAML, err := renderConfig(appviewConfigTmpl, vals) 193 + if err != nil { 194 + return fmt.Errorf("render appview config: %w", err) 195 + } 196 + if err := syncConfigKeys("appview", state.Appview.PublicIP, naming.AppviewConfigPath(), appviewConfigYAML); err != nil { 197 + return fmt.Errorf("appview config sync: %w", err) 198 + } 192 199 } else { 193 200 fmt.Println("Creating appview server...") 194 201 appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion) ··· 213 220 } 214 221 if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil { 215 222 return err 223 + } 224 + holdConfigYAML, err := renderConfig(holdConfigTmpl, vals) 225 + if err != nil { 226 + return fmt.Errorf("render hold config: %w", err) 227 + } 228 + if err := syncConfigKeys("hold", state.Hold.PublicIP, naming.HoldConfigPath(), holdConfigYAML); err != nil { 229 + return fmt.Errorf("hold config sync: %w", err) 216 230 } 217 231 } else { 218 232 fmt.Println("Creating hold server...")
+6
go.sum
··· 1 1 github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= 2 + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 2 3 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 4 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 4 5 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= ··· 78 79 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 79 80 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 80 81 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 82 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 81 83 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 82 84 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 83 85 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= ··· 95 97 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 96 98 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 97 99 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 100 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 98 101 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 99 102 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 100 103 github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= ··· 161 164 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 162 165 github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 163 166 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 167 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 164 168 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= 165 169 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= 166 170 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= ··· 291 295 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= 292 296 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= 293 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 298 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 294 299 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 295 300 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 296 301 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= ··· 345 350 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 346 351 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 347 352 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 353 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 348 354 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 349 355 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 350 356 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=