fix(provisioning,catalog): parent-kustomization prefix collision + disable openclaw/stalwart-mail (#1043)

Two bugs surfaced live 2026-05-06 on tenant "test":

1) UpdateParentKustomization used substring match against "  - <slug>",
   which falsely "found" the slug when it was a PREFIX of an existing
   entry. Adding "test" to a file already listing "test11" or "test13"
   silently no-op'd. Result: tenant manifests committed but the
   tenants/kustomization.yaml never registered them, Flux's tenants
   Kustomization couldn't apply the new tenant, vCluster step timed
   out at 10m. Fix: exact line match on the resources entry.

2) openclaw + stalwart-mail were flagged Deployable=true in #941 but
   never had AppSpec entries in core/services/provisioning/gitops/apps.go
   KnownApps. The SME provisioning generator emits a single-Deployment
   template that requires Image + Port; for those two slugs it produced
   invalid manifests:

     Deployment.apps "openclaw" is invalid:
     containers[0].image: Required value
     containers[0].ports[0].containerPort: Required value

   tenant-test11-apps Kustomization rejected the dry-run, no apps ever
   landed inside the vcluster. Re-enabling these requires per-app
   overlay support beyond the single-Deployment template — separate
   work. For now: comment them out of DeployableAppSlugs so the catalog
   seed flips them back to Deployable=false on next pod restart and the
   marketplace UI shows them as COMING SOON.

Adds regression tests for both: prefix-collision in
UpdateParentKustomization, and a stability test on the deployable map
shape.

Co-authored-by: hatiyildiz <hatice@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-06 10:21:39 +04:00 committed by GitHub
parent 68e61eb306
commit a57d05d4dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 101 additions and 21 deletions

View File

@ -607,8 +607,18 @@ func DeployableAppSlugs() map[string]bool {
"invoiceshelf": true,
"formbricks": true,
"listmonk": true, // fixed in #101 — DBEnvStyle:"listmonk" + InitCommand
"openclaw": true, // #941 — bp-openclaw + bp-newapi via SME-tenant overlay
"stalwart-mail": true, // #941 — bp-stalwart-tenant via SME-tenant overlay
// openclaw + stalwart-mail were flagged Deployable in #941 but have
// no entry in core/services/provisioning/gitops/apps.go KnownApps —
// the SME provisioning service generates manifests via a single
// Deployment template that requires Image + Port; both apps need
// HelmRelease-shaped overlays (controller + runtime for openclaw;
// IMAP/SMTP services for stalwart-mail). Live failure 2026-05-06
// on tenant "test11": tenant-test11-apps Kustomization rejected
// `Deployment.apps "openclaw" is invalid: containers[0].image
// Required value`. Re-enabling these requires per-app overlay
// templates beyond the one-Deployment generator.
// "openclaw": true, // #941 — disabled until proper overlay
// "stalwart-mail": true, // #941 — disabled until proper overlay
// Backing services are always deployable — they come bundled with
// whichever business app needs them. Marking them true so the
// catalog UI doesn't draw a 'Coming soon' overlay on them. #112.

View File

@ -2,23 +2,23 @@ package handlers
import "testing"
// TestDeployableAppSlugs_Issue941 asserts that openclaw + stalwart-mail
// are present in the deployable map (issue #941). C5-final hit "27 apps
// COMING SOON" on otech113 because both were missing — gates 4 (LLM)
// and 5 (mail) blocked before alice could click Install.
func TestDeployableAppSlugs_Issue941(t *testing.T) {
// TestDeployableAppSlugs_OpenclawStalwartDisabled asserts that openclaw and
// stalwart-mail are NOT in the deployable map. They were briefly enabled by
// #941 (catalog flag flipped) but the SME provisioning generator at
// core/services/provisioning/gitops/apps.go has no AppSpec for either, so
// the rendered Deployment manifests are invalid (missing image + ports).
// Live failure 2026-05-06 on tenant "test11":
//
// tenant-test11-apps Kustomization rejected:
// Deployment.apps "openclaw" is invalid: containers[0].image: Required value
//
// Re-enabling these requires per-app HelmRelease overlays beyond the single
// Deployment template the generator currently supports.
func TestDeployableAppSlugs_OpenclawStalwartDisabled(t *testing.T) {
d := DeployableAppSlugs()
wantTrue := []string{
"openclaw", // #941 — bp-openclaw via SME-tenant overlay
"stalwart-mail", // #941 — bp-stalwart-tenant via SME-tenant overlay
// Sanity — the canonical alice baseline apps.
"wordpress",
"ghost",
"nextcloud",
}
for _, slug := range wantTrue {
if !d[slug] {
t.Errorf("expected %q to be deployable, got false (or missing)", slug)
for _, slug := range []string{"openclaw", "stalwart-mail"} {
if d[slug] {
t.Errorf("%q must NOT be deployable until per-app overlay exists in the SME provisioning generator", slug)
}
}
}
@ -32,7 +32,6 @@ func TestDeployableAppSlugs_StableShape(t *testing.T) {
"wordpress", "ghost", "nextcloud", "bookstack", "uptime-kuma",
"gitea", "vaultwarden", "umami", "nocodb", "cal-com",
"invoiceshelf", "formbricks", "listmonk",
"openclaw", "stalwart-mail",
"postgres", "mysql", "redis",
}
if got, want := len(d), len(expected); got != want {

View File

@ -975,10 +975,20 @@ func planLimits(slug string) planLimit {
}
// UpdateParentKustomization adds a tenant entry to the parent kustomization.
//
// The "already listed" check used to be a substring match against the literal
// " - <slug>", which falsely triggered when <slug> was a prefix of any
// existing entry (e.g. trying to add "test" when the file already listed
// "test11" or "test13"). Live race observed 2026-05-06: tenant "test"'s
// commit silently no-op'd the parent update, leaving its directory orphan
// and Flux's tenants Kustomization unable to apply it. Fix: exact line
// match on the resources entry.
func UpdateParentKustomization(current, tenantSlug string) string {
entry := fmt.Sprintf(" - %s", tenantSlug)
if strings.Contains(current, entry) {
return current
for _, ln := range strings.Split(current, "\n") {
if strings.TrimRight(ln, " \t") == entry {
return current
}
}
if strings.Contains(current, "resources: []") {
return strings.Replace(current, "resources: []", fmt.Sprintf("resources:\n%s", entry), 1)

View File

@ -0,0 +1,61 @@
package gitops
import (
"strings"
"testing"
)
// TestUpdateParentKustomization_PrefixCollision regression-tests the bug
// observed live 2026-05-06: tenant "test"'s parent update silently no-op'd
// because the file already listed "test11" / "test13", and the substring
// match against " - test" matched " - test11" / " - test13". The fix is
// an exact line match.
func TestUpdateParentKustomization_PrefixCollision(t *testing.T) {
current := `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- test13
- market
- aaa
- bbb
- test11
`
got := UpdateParentKustomization(current, "test")
if !strings.Contains(got, "\n - test\n") {
t.Fatalf("expected ' - test' as a fresh entry; got:\n%s", got)
}
// Existing entries must remain untouched.
for _, want := range []string{" - test13", " - market", " - aaa", " - bbb", " - test11"} {
if !strings.Contains(got, want+"\n") {
t.Fatalf("expected %q to remain; got:\n%s", want, got)
}
}
}
// TestUpdateParentKustomization_AlreadyPresent ensures we don't double-add a
// slug that already has its own line.
func TestUpdateParentKustomization_AlreadyPresent(t *testing.T) {
current := `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- test
- test11
`
got := UpdateParentKustomization(current, "test")
if got != current {
t.Fatalf("expected unchanged when slug already listed; got:\n%s", got)
}
}
// TestUpdateParentKustomization_EmptyResources adds the first entry into
// the explicit "resources: []" form.
func TestUpdateParentKustomization_EmptyResources(t *testing.T) {
current := `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: []
`
got := UpdateParentKustomization(current, "alpha")
if !strings.Contains(got, "resources:\n - alpha\n") {
t.Fatalf("expected 'resources:' block with alpha; got:\n%s", got)
}
}