Skip to content

Commit 0b01c5a

Browse files
authored
fix(deploy): match pulumi struct tags to json tags in deploy descriptors (#196)
1 parent 2786706 commit 0b01c5a

5 files changed

Lines changed: 29 additions & 16 deletions

File tree

internal/app/app.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,13 @@ const PlaceholderIndexHTML = `<!doctype html>
133133
// and the account inforge connects as over SSH (the ingress host's deploy user).
134134
type DeployTarget struct {
135135
App string `yaml:"app" json:"app" pulumi:"app"`
136-
IngressHostDNS string `yaml:"ingress_host_dns" json:"ingress_host_dns" pulumi:"ingressHostDns"`
137-
DeployPath string `yaml:"deploy_path" json:"deploy_path" pulumi:"deployPath"`
136+
IngressHostDNS string `yaml:"ingress_host_dns" json:"ingress_host_dns" pulumi:"ingress_host_dns"`
137+
DeployPath string `yaml:"deploy_path" json:"deploy_path" pulumi:"deploy_path"`
138138
FQDN string `yaml:"fqdn" json:"fqdn" pulumi:"fqdn"`
139139
Spa bool `yaml:"spa" json:"spa" pulumi:"spa"`
140140
// SSHUser is the account inforge connects as over SSH to deliver the bundle —
141141
// the ingress host's deploy_user. Falls back to the historical "deploy".
142-
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"sshUser"`
142+
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"ssh_user"`
143143
// Scope is the app's mesh scope: its region name, or pki.ScopeGlobal for a
144144
// global app. Mirrors service.DeployTarget.Scope / meshplan.DeployTarget.Scope.
145145
Scope string `yaml:"scope" json:"scope" pulumi:"scope"`

internal/dbbackup/deploy.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@ type DeployTarget struct {
2020
Database string `yaml:"database" json:"database" pulumi:"database"`
2121
// HostDNS is the host's SSH/cloud-init DNS name; SSHUser is the account inforge
2222
// connects as.
23-
HostDNS string `yaml:"host_dns" json:"host_dns" pulumi:"hostDns"`
24-
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"sshUser"`
23+
HostDNS string `yaml:"host_dns" json:"host_dns" pulumi:"host_dns"`
24+
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"ssh_user"`
2525
// Port is the cluster's TCP port on its host (postgres.ClusterPort), needed for
2626
// the on-host pg_restore. RegionSlug is the R2 key's region segment (the region
2727
// slug, or "global" for the global scope).
2828
Port int `yaml:"port" json:"port" pulumi:"port"`
29-
RegionSlug string `yaml:"region_slug" json:"region_slug" pulumi:"regionSlug"`
29+
RegionSlug string `yaml:"region_slug" json:"region_slug" pulumi:"region_slug"`
3030
// BackupEnabled reports whether this database has a backup timer (its
3131
// `backup.enabled` is not explicitly false). `db backup` operates only on
3232
// enabled targets; restore/list-backups ignore it.
33-
BackupEnabled bool `yaml:"backup_enabled" json:"backup_enabled" pulumi:"backupEnabled"`
33+
BackupEnabled bool `yaml:"backup_enabled" json:"backup_enabled" pulumi:"backup_enabled"`
3434
}
3535

3636
// DeployDescriptor is the `dbDeployDescriptor` stack output (the database sibling

internal/meshplan/deploy.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ type DeployTarget struct {
2222
// Host is the canonical compute key (e.g. "bridge-01") — the same key the
2323
// per-host provider path (/<hostKey>) and ServicesByHost grouping use.
2424
Host string `yaml:"host" json:"host" pulumi:"host"`
25-
HostDNS string `yaml:"host_dns" json:"host_dns" pulumi:"hostDns"`
26-
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"sshUser"`
25+
HostDNS string `yaml:"host_dns" json:"host_dns" pulumi:"host_dns"`
26+
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"ssh_user"`
2727
// Scope is the host's mesh scope: its region name, or pki.ScopeGlobal.
2828
Scope string `yaml:"scope" json:"scope" pulumi:"scope"`
2929
}

internal/service/service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func RuntimeDir(name string) string {
132132
// restart, and the optional no-login system user the service runs as.
133133
type DeployTarget struct {
134134
Service string `yaml:"service" json:"service" pulumi:"service"`
135-
HostDNS string `yaml:"host_dns" json:"host_dns" pulumi:"hostDns"`
135+
HostDNS string `yaml:"host_dns" json:"host_dns" pulumi:"host_dns"`
136136
Folder string `yaml:"folder" json:"folder" pulumi:"folder"`
137137
Unit string `yaml:"unit" json:"unit" pulumi:"unit"`
138138
// User is the no-login system user the service runs as. Empty when the
@@ -144,7 +144,7 @@ type DeployTarget struct {
144144
// the service process runs as): they coincide only when the deploy user is
145145
// literally named the same. Falls back to "deploy" when the host declares no
146146
// deploy_user.
147-
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"sshUser"`
147+
SSHUser string `yaml:"ssh_user" json:"ssh_user" pulumi:"ssh_user"`
148148
// Scope is the service's mesh scope: its region name, or pki.ScopeGlobal for
149149
// a global service. Mirrors meshplan.DeployTarget.Scope; a consumer resolving
150150
// targets by bare service name can use it to detect a same-named service

program/pulumitags_test.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package program
22

33
import (
44
"reflect"
5+
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
@@ -18,9 +19,16 @@ import (
1819
// reflection-based struct marshaler (marshalInputOptionsImpl, go/pulumi/rpc.go)
1920
// silently DROPS any exported struct field that has no `pulumi:"..."` tag —
2021
// yaml/json tags alone are not enough. Every field of every type reachable from
21-
// an Export call in program.Run must therefore carry a non-empty `pulumi` tag;
22-
// this test asserts that mechanically so a new field (or a new descriptor type)
23-
// can't reintroduce the silent-empty-export failure mode.
22+
// an Export call in program.Run must therefore carry a non-empty `pulumi` tag.
23+
//
24+
// The tag's NAME matters too, and is asserted here as a second invariant: the
25+
// read side (cmd/inforge's decodeTargets) unmarshals the exported JSON via
26+
// encoding/json, which matches on the `json:` tag — so if `pulumi:"hostDns"`
27+
// (idiomatic Pulumi camelCase) diverges from `json:"host_dns"`, the export
28+
// itself succeeds but every read decodes that field back to its zero value.
29+
// This exact mismatch shipped once (HostDNS et al. exported under "hostDns"
30+
// but decoded looking for "host_dns"), and broke `inforge releases deploy`
31+
// with an empty SSH hostname even after the empty-export bug was fixed.
2432
func TestDeployDescriptorsCarryPulumiTags(t *testing.T) {
2533
types := []any{
2634
service.DeployDescriptor{}, service.DeployTarget{},
@@ -32,8 +40,13 @@ func TestDeployDescriptorsCarryPulumiTags(t *testing.T) {
3240
typ := reflect.TypeOf(v)
3341
for i := 0; i < typ.NumField(); i++ {
3442
f := typ.Field(i)
35-
tag := f.Tag.Get("pulumi")
36-
assert.NotEmpty(t, tag, "%s.%s has no `pulumi:\"...\"` struct tag — pulumi.Any() silently drops it from the export", typ.Name(), f.Name)
43+
pulumiTag := f.Tag.Get("pulumi")
44+
assert.NotEmpty(t, pulumiTag, "%s.%s has no `pulumi:\"...\"` struct tag — pulumi.Any() silently drops it from the export", typ.Name(), f.Name)
45+
46+
jsonTag, _, _ := strings.Cut(f.Tag.Get("json"), ",")
47+
assert.Equal(t, jsonTag, pulumiTag,
48+
"%s.%s: pulumi tag %q must match json tag %q — decodeTargets unmarshals via json, so a mismatch silently zeroes this field on read even though the export itself succeeds",
49+
typ.Name(), f.Name, pulumiTag, jsonTag)
3750
}
3851
}
3952
}

0 commit comments

Comments
 (0)