microsoft / microsoft/aspire

Go AppHost: AddManifest is unusable — Configure option panics on publish, and WithField can't express nested/dotted CRD fields

Open
#18,080 3 comments 0 reactions 0 assignees View on GitHub
area-app-model triage:bot-seen
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.