Go AppHost: AddManifest is unusable — Configure option panics on publish, and WithField can't express nested/dotted CRD fields
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
## Summary
In the **Go AppHost** polyglot binding, the `AddManifest` API for attaching custom Kubernetes manifests (CRDs) is effectively unusable for any manifest with a non-trivial spec. There are two distinct problems:
1. Passing `AddManifestOptions{Configure: ...}` **panics** during `aspire publish`.
2. Even with the panic worked around, `KubernetesManifestResource.WithField` only accepts scalar values and dot-notation paths, so nested specs and label keys containing dots (e.g. `app.kubernetes.io/component`) cannot be expressed.
Together these make it impossible to author a `CiliumNetworkPolicy` (or `NetworkPolicy`, or most label-selector CRDs) from a Go AppHost.
## Environment
- Aspire CLI: `13.4.3+4f218933552e18ff2874d1b6d5dc3fe671e3b6d9`
- AppHost language: **Go** (`apphost.go`)
- Generated module: `apphost/modules/aspire` (`go 1.23`)
- Go toolchain: `go1.26.4 darwin/arm64`
- OS: macOS (darwin/arm64)
## Bug 1 — `AddManifestOptions{Configure}` panics on publish
### Repro
```go
api.PublishAsKubernetesService(func(obj aspire.KubernetesResource) {
obj.AddManifest("cilium.io/v2", "CiliumNetworkPolicy", "api-netpol", &aspire.AddManifestOptions{
Configure: func(m aspire.KubernetesManifestResource) {
m.WithField("spec.endpointSelector", "x")
},
})
})
```
Run `aspire publish -o aspire-output`.
### Result
```
(publish-k8s) i [INF] Generating Kubernetes output
panic: aspire: merge error: unexpected end of JSON input
goroutine 24 [running]:
apphost/modules/aspire.deepUpdate[...](...)
.aspire/modules/base.go:862 +0x310
apphost/modules/aspire.(*kubernetesResource).AddManifest(...)
.aspire/modules/aspire.go:21940 +0x7a8
main.main.func2(...)
apphost.go:...
apphost/modules/aspire.(*redisResource).PublishAsKubernetesService.func1(...)
...
exit status 2
❌ The connection to the AppHost was lost
```
### Root cause
`AddManifest` merges its options via `deepUpdate(merged, opt)` (aspire.go ~L21940):
```go
if len(options) > 0 {
merged := &AddManifestOptions{}
for _, opt := range options {
if opt != nil { merged = deepUpdate(merged, opt) } // <-- panics here
}
...
}
```
`deepUpdate` round-trips through `toMap` + `json.Marshal`/`json.Unmarshal` (base.go ~L848-863):
```go
case reflect.Map, reflect.Struct:
dstMap, _ := toMap(dst)
srcMap, _ := toMap(src)
mergedMap := merge(dstMap, srcMap, 0)
bytes, _ := json.Marshal(mergedMap) // <-- errors, returns nil; error ignored
...
if err := json.Unmarshal(bytes, &result); err != nil {
panic(fmt.Sprintf("aspire: merge error: %v", err)) // unexpected end of JSON input
}
```
The problem is `toMap`'s struct branch (base.go), which includes **every exported field by name, ignoring json tags**:
```go
case reflect.Struct:
m := make(map[string]any)
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
if field.PkgPath == "" { // exported only — but ignores `json:"-"`
m[field.Name] = v.Field(i).Interface()
}
}
return m, true
```
`AddManifestOptions.Configure` is `func(obj KubernetesManifestResource)` (tagged `json:"-"`), but `toMap` includes it anyway. The merged map then contains a func value, `json.Marshal` fails, `bytes` is nil (the error is discarded with `_`), and `json.Unmarshal(nil, ...)` panics with *"unexpected end of JSON input"*.
### Suggested fixes (any one resolves it)
- `toMap` should honor the `json:"-"` tag (and ideally `json:"name,..."` renames) when reflecting over structs, **or** skip `reflect.Func`/`reflect.Chan` fields.
- `deepUpdate` should not discard the `json.Marshal` error.
- `AddManifest` could skip `deepUpdate` entirely and merge options field-by-field (it already special-cases `Configure` and calls `merged.ToMap()` separately right after).
## Bug 2 — `WithField` cannot express nested specs or dotted label keys
Working around Bug 1 by configuring via the returned handle instead:
```go
m := obj.AddManifest("cilium.io/v2", "CiliumNetworkPolicy", "api-netpol") // no options -> no panic
m.WithField("spec.endpointSelector", map[string]any{ /* ... */ })
```
`WithField` rejects non-scalar values (aspire.go):
```go
func (s *kubernetesManifestResource) WithField(path string, value any) KubernetesManifestResource {
if s.err != nil { return s }
switch value.(type) {
case string, float64, bool:
default:
err := fmt.Errorf("aspire: WithField: parameter %q must be one of [string, float64, bool], got %T", "value", value)
s.setErr(err); return s
}
...
}
```
This blocks setting arrays/objects such as `spec.ingress`, `spec.egress`, or `endpointSelector.matchLabels`. The only way to build them would be many scalar leaf paths — but that runs into a second wall: **Kubernetes label keys contain dots**, e.g.:
```
spec.endpointSelector.matchLabels.app.kubernetes.io/component = "api"
```
The dots in `app.kubernetes.io/component` are interpreted as nested path segments, so the key cannot be represented.
Note this is **inconsistent with the documented TypeScript `addManifest`**, whose example passes arrays directly:
```ts
await manifest.withField('spec.dnsNames', ['service.example.com']);
```
### Impact
CRDs whose specs are nested and/or use label selectors — `CiliumNetworkPolicy`, core `NetworkPolicy`, cert-manager `Certificate` with multiple DNS names, etc. — cannot be authored from a Go AppHost via `AddManifest`/`WithField`.
### Suggested fixes
- Allow `WithField` to accept `map`/slice values (serialize the whole subtree), matching the TS binding.
- Support an escape/bracket syntax for path segments containing `.` and `/` (e.g. `matchLabels['app.kubernetes.io/component']`), or accept a raw JSON/object body for the manifest.
## Current workaround
Hand-author the CRD as a Helm template under the generated chart's `templates/` directory. `aspire publish` preserves files it didn't generate, so the policy survives regeneration and is rendered by `helm`. This works but bypasses the AppHost model (selectors no longer track topology automatically).
Contributor guide
Assessment
This issue has not been assessed yet.