Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
607f226
feat: record backup consistency and captured volume coverage
Baylem Oct 21, 2026
90f75f1
merge: integrate stopped-source backup consistency
Baylem Oct 21, 2026
adb37ab
merge: include CSI restore routing correction
Baylem Oct 21, 2026
afb4eaa
merge: include shared web build prerequisite
Baylem Oct 21, 2026
4ef2b27
feat(operator): stage immutable template revisions per server
Baylem Oct 21, 2026
34e8a25
merge: preserve backup preparation and pinned coverage metadata
Baylem Oct 21, 2026
e0d67c6
merge: validate staged revisions against workload security
Baylem Oct 21, 2026
7ee7966
merge: retain configurable resources in staged workload inputs
Baylem Oct 21, 2026
8434483
fix: include capture output confinement follow-up
Baylem Oct 21, 2026
2c16b6e
fix: include backup agent interface CI corrections
Baylem Oct 21, 2026
2cffa4d
ci: require and report every audit E2E bucket
Baylem Oct 21, 2026
7ff8955
test: model backup status writes in revision fixtures
Baylem Oct 21, 2026
95e38d0
fix: carry capture verification fixes into staged rollouts
Baylem Oct 21, 2026
69c73eb
fix: carry operator upgrade migration into staged rollouts
Baylem Oct 21, 2026
3ab227e
fix(e2e): isolate rollout fixtures and observe workload stops
Baylem Oct 22, 2026
0e7282d
fix(operator): preserve condition changes before status comparison
Baylem Oct 22, 2026
4d2bfc6
fix: include restricted upgrade corrections in staged rollouts
Baylem Oct 22, 2026
db5db7c
fix(operator): retain failed restore ownership until startup drains
Baylem Oct 23, 2026
f118220
fix(ci): retain backup and rollout coverage with upstream selection
Baylem Oct 23, 2026
0e0d5d9
test: preserve controller updates in lifecycle fixtures
Baylem Oct 23, 2026
2081831
test: synchronize closed RCON response fixtures
Baylem Oct 23, 2026
575a912
test: verify shared PID survives restricted capture
Baylem Oct 24, 2026
32addfe
fix: allow guarded wipe recovery to drain workloads
Baylem Oct 24, 2026
dd8c9bb
fix: carry reviewed security and chart prerequisites
Baylem Oct 25, 2026
38c69aa
chore: reconcile staged rollouts with upstream and CI lint
Baylem Oct 25, 2026
22494f1
chore: reconcile staged rollouts and refresh CI inputs
Baylem Oct 27, 2026
ecee255
chore: reconcile rollout CRDs with upstream
Baylem Oct 28, 2026
52aae3f
Merge upstream and address backup and capture recovery feedback
Baylem Oct 31, 2026
7cf80d3
merge: stack staged rollouts on current security and backup branches
Baylem Nov 4, 2026
32c8f2b
fix(api): redact config with verified effective template schemas
Baylem Nov 4, 2026
0da2fa6
fix(web): synchronize backup coverage Pencil designs
Baylem Nov 4, 2026
27ba4d9
merge: carry diagnostics namespace isolation into staged rollouts
Baylem Nov 4, 2026
d1eb0c9
merge: protect pinned server configuration across API operations
Baylem Nov 4, 2026
a2a7381
fix(security): retain password protection through cloning and rollout
Baylem Nov 5, 2026
2753835
fix(web): align backup drawer with Pencil references
Baylem Nov 5, 2026
53c9caa
merge: protect passwords through cloning and rollout
Baylem Nov 5, 2026
5199813
merge: carry diagnostics authorization and CI fixes
Baylem Nov 5, 2026
1d4c132
fix(design): center backup drawer action labels
Baylem Nov 5, 2026
e64c075
fix(api): protect passwords before initial template admission
Baylem Nov 5, 2026
87f19a7
fix(ci): preserve secret handling and initialize regression config
Baylem Nov 6, 2026
a44005b
Merge security stack dependency and backup review updates
Baylem Nov 6, 2026
aed41f5
Merge workspace checksum for compress security update
Baylem Nov 6, 2026
40706ea
fix(backup): recover template failures and wait for foreign workers
Baylem Nov 6, 2026
6910942
fix: recover unpinned protected ordinary config with fresh values
Baylem Nov 6, 2026
f2440ca
fix: persist restore cleanup before template copy side effects
Baylem Nov 6, 2026
f396a2b
fix: allow token-scoped cancellation of held template rollouts
Baylem Nov 6, 2026
13b2d3e
merge: carry bounded backup source admission into template rollouts
Baylem Nov 6, 2026
4128a3b
fix(backup): release captures after foreign worker replacement
Baylem Nov 6, 2026
0239ece
test(operator): assert restore binding before transient copy retries
Baylem Nov 6, 2026
99fdefa
merge: carry patched networking dependencies into template rollouts
Baylem Nov 6, 2026
3153efe
merge: normalize networking dependency checksums
Baylem Nov 6, 2026
724aeb9
merge: retain required workspace graph checksums
Baylem Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions api/internal/handlers/config_clone_race_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package handlers

import (
"net/http"
"reflect"
"testing"

"github.com/go-chi/chi/v5"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
dynamicfake "k8s.io/client-go/dynamic/fake"
clienttesting "k8s.io/client-go/testing"

"github.com/GameplanePanel/gameplane/api/internal/kube"
"github.com/GameplanePanel/gameplane/api/internal/scope"
)

func TestClonePasswordProtectionSurvivesCatalogRaceAndMutation(t *testing.T) {
for _, cluster := range []string{scope.DefaultCluster, "remote"} {
t.Run(cluster, func(t *testing.T) {
k := fakeKubeClient(redactTemplate())
src := serverWithConfig("alpha", plainConfig())
pinRedactionTemplate(t, k, src, redactTemplate())
if _, err := k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(redactNS).Create(t.Context(), src, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
fake := k.Dynamic.(*dynamicfake.FakeDynamicClient)
fake.PrependReactor("create", "gameservers", func(action clienttesting.Action) (bool, runtime.Object, error) {
obj := action.(clienttesting.CreateAction).GetObject().(*unstructured.Unstructured)
obj.SetUID(types.UID("clone-" + obj.GetName())) // Force the real API server's post-Create cache miss.
return false, nil, fake.Tracker().Update(kube.GVRs["templates"], catalogWithoutPasswords(), "")
})
reg := kube.NewRegistry(scope.DefaultCluster)
reg.Set(scope.DefaultCluster, fakeKubeClient())
reg.Set(cluster, k)
router := chi.NewRouter()
MountLifecycle(router, reg)
MountResources(router, reg, nil)
store := newTestStore(t)
MountOwnership(router, reg, store)
user := testAdminUser()
user.Perms[cluster] = map[string]map[string]struct{}{"*": {"*": {}}}
query := "?cluster=" + cluster
rr := doWithUser(t, router, http.MethodPost, "/servers/alpha:clone"+query, map[string]any{"newName": "beta"}, user)
if rr.Code != http.StatusOK {
t.Fatalf("clone: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
clone, err := k.GetServer(t.Context(), redactNS, "beta")
if err != nil {
t.Fatal(err)
}
keys, err := kube.ProtectedConfigKeys(clone.GetAnnotations())
if err != nil || !reflect.DeepEqual(keys, []string{"ADMIN_PASSWORD", "RCON_PASSWORD"}) {
t.Fatalf("clone lost inherited password classification: %v %v", keys, err)
}
for _, path := range []string{"/servers/beta", "/servers/"} {
rr = doWithUser(t, router, http.MethodGet, path+query, nil, user)
if rr.Code != http.StatusOK {
t.Fatalf("read: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
}
body := updateBody(map[string]any{"MOTD": "hello", "RCON_PASSWORD": configRedactedMarker, "ADMIN_PASSWORD": configRedactedMarker})
body["metadata"].(map[string]any)["uid"] = string(clone.GetUID())
body["metadata"].(map[string]any)["annotations"] = map[string]any{kube.ProtectedConfigKeysAnnotation: "[]"}
rr = doWithUser(t, router, http.MethodPut, "/servers/beta"+query, body, user)
if rr.Code != http.StatusOK {
t.Fatalf("PUT: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
if !reflect.DeepEqual(storedConfig(t, k, "beta"), plainConfig()) {
t.Fatal("PUT lost stored secrets")
}
bob := seedUser(t, store, "bob", "viewer", "")
rr = doWithUser(t, router, http.MethodPost, "/servers/beta:transfer"+query, map[string]any{"userId": bob}, user)
if rr.Code != http.StatusNoContent {
t.Fatalf("transfer: %d %s", rr.Code, rr.Body)
}
rr = doWithUser(t, router, http.MethodGet, "/servers/beta"+query, nil, user)
assertNoSecretLeak(t, rr.Body.String())
// A second-generation clone must inherit the source classification,
// even though that source has no pin yet.
if err := fake.Tracker().Update(kube.GVRs["templates"], redactTemplate(), ""); err != nil {
t.Fatal(err)
}
rr = doWithUser(t, router, http.MethodPost, "/servers/beta:clone"+query, map[string]any{"newName": "gamma"}, user)
if rr.Code != http.StatusOK {
t.Fatalf("second clone: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
grandchild, err := k.GetServer(t.Context(), redactNS, "gamma")
if err != nil {
t.Fatal(err)
}
if grandchild.GetAnnotations()[kube.ProtectedConfigKeysAnnotation] != clone.GetAnnotations()[kube.ProtectedConfigKeysAnnotation] {
t.Fatal("clone of clone lost password classification")
}
})
}
}

func TestProtectedConfigMetadataFailsClosedAndCannotBeForged(t *testing.T) {
for _, raw := range []string{"", "null", `"PASSWORD"`, `[1]`, `[""]`} {
gs := serverWithConfig("alpha", plainConfig())
gs.SetAnnotations(map[string]string{kube.ProtectedConfigKeysAnnotation: raw})
newConfigRuleCache(fakeKubeClient(catalogWithoutPasswords())).redact(t.Context(), gs)
for key, value := range specConfig(t, gs.Object) {
if value != configRedactedMarker {
t.Fatalf("malformed metadata exposed %s", key)
}
}
}
gs := serverWithConfig("alpha", plainConfig())
gs.SetAnnotations(map[string]string{kube.ProtectedConfigKeysAnnotation: `["MOTD"]`})
k := fakeKubeClient(redactTemplate())
rr := do(t, mountResourcesRouter(k), http.MethodPost, "/servers/", gs.Object)
if rr.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rr.Code, rr.Body)
}
stored, err := k.GetServer(t.Context(), redactNS, "alpha")
if err != nil {
t.Fatal(err)
}
keys, err := kube.ProtectedConfigKeys(stored.GetAnnotations())
if err != nil || !reflect.DeepEqual(keys, []string{"ADMIN_PASSWORD", "RCON_PASSWORD"}) {
t.Fatalf("generic create did not replace client metadata with verified password keys: %v %v", keys, err)
}
}
248 changes: 248 additions & 0 deletions api/internal/handlers/config_create_race_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package handlers

import (
"net/http"
"reflect"
"testing"

"github.com/go-chi/chi/v5"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
dynamicfake "k8s.io/client-go/dynamic/fake"
clienttesting "k8s.io/client-go/testing"

"github.com/GameplanePanel/gameplane/api/internal/kube"
"github.com/GameplanePanel/gameplane/api/internal/scope"
)

func TestCreatePasswordProtectionSurvivesCatalogRace(t *testing.T) {
for _, cluster := range []string{scope.DefaultCluster, "remote"} {
for _, schema := range []string{"available", "missing", "malformed"} {
t.Run(cluster+"/"+schema, func(t *testing.T) {
tmpl := redactTemplate()
if schema == "malformed" {
tmpl.Object["spec"].(map[string]any)["configSchema"] = "invalid"
}
k := fakeKubeClient(tmpl)
if schema == "missing" {
k = fakeKubeClient()
}
fake := k.Dynamic.(*dynamicfake.FakeDynamicClient)
fake.PrependReactor("create", "gameservers", func(action clienttesting.Action) (bool, runtime.Object, error) {
obj := action.(clienttesting.CreateAction).GetObject().(*unstructured.Unstructured)
obj.SetUID(types.UID("created-" + obj.GetName()))
// Change the catalog after classification but before persistence,
// then force redaction to resolve rules using the new server UID.
if schema == "missing" {
return false, nil, fake.Tracker().Create(kube.GVRs["templates"], catalogWithoutPasswords(), "")
}
return false, nil, fake.Tracker().Update(kube.GVRs["templates"], catalogWithoutPasswords(), "")
})
reg := kube.NewRegistry(scope.DefaultCluster)
reg.Set(scope.DefaultCluster, fakeKubeClient())
reg.Set(cluster, k)
router := chi.NewRouter()
MountResources(router, reg, nil)
user := testAdminUser()
user.Perms[cluster] = map[string]map[string]struct{}{"*": {"*": {}}}
query := "?cluster=" + cluster
gs := serverWithConfig("alpha", plainConfig())
gs.SetAnnotations(map[string]string{kube.ProtectedConfigKeysAnnotation: "[]"})
rr := doWithUser(t, router, http.MethodPost, "/servers/"+query, gs.Object, user)
if rr.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
if schema == "available" && configFromBody(t, rr.Body.Bytes())["MOTD"] != "hello" {
t.Fatal("verified ordinary field was needlessly redacted")
}
created, err := k.GetServer(t.Context(), redactNS, "alpha")
if err != nil {
t.Fatal(err)
}
keys, err := kube.ProtectedConfigKeys(created.GetAnnotations())
want := []string{"ADMIN_PASSWORD", "RCON_PASSWORD"}
if schema != "available" {
want = []string{"ADMIN_PASSWORD", "MOTD", "RCON_PASSWORD"}
}
if err != nil || !reflect.DeepEqual(keys, want) {
t.Fatalf("creation lost password classification: %v %v", keys, err)
}
if !reflect.DeepEqual(storedConfig(t, k, "alpha"), plainConfig()) {
t.Fatal("creation changed stored config")
}
for _, path := range []string{"/servers/alpha", "/servers/"} {
rr = doWithUser(t, router, http.MethodGet, path+query, nil, user)
if rr.Code != http.StatusOK {
t.Fatalf("read: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
}
})
}
}
}

func TestCreateDoesNotProtectEmptyOrOrdinaryValues(t *testing.T) {
k := fakeKubeClient(redactTemplate())
gs := serverWithConfig("alpha", map[string]any{"MOTD": "hello", "RCON_PASSWORD": "", "ADMIN_PASSWORD": ""})
rr := do(t, mountResourcesRouter(k), http.MethodPost, "/servers/", gs.Object)
if rr.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rr.Code, rr.Body)
}
created, err := k.GetServer(t.Context(), redactNS, "alpha")
if err != nil {
t.Fatal(err)
}
if _, found := created.GetAnnotations()[kube.ProtectedConfigKeysAnnotation]; found {
t.Fatal("empty or ordinary value acquired persistent password protection")
}
}

func TestUnpinnedUpdateProtectsNewPasswordsUsingLiveRules(t *testing.T) {
for _, cluster := range []string{scope.DefaultCluster, "remote"} {
for _, failedLookup := range []bool{false, true} {
name := cluster + "/available"
if failedLookup {
name = cluster + "/unavailable"
}
t.Run(name, func(t *testing.T) {
gs := serverWithConfig("alpha", map[string]any{"MOTD": "hello", "RCON_PASSWORD": "", "ADMIN_PASSWORD": ""})
gs.SetUID("uid-alpha")
// Empty protection is retired only at an authoritative pin update.
gs.SetAnnotations(map[string]string{kube.ProtectedConfigKeysAnnotation: `["OLD_PASSWORD"]`})
k := fakeKubeClient(redactTemplate(), gs)
fake := k.Dynamic.(*dynamicfake.FakeDynamicClient)
firstRead := true
fake.PrependReactor("get", "gametemplates", func(clienttesting.Action) (bool, runtime.Object, error) {
if !firstRead {
return false, nil, nil
}
firstRead = false
// The preservation lookup sees password rules, but later lookups
// in this same request already see the changed catalog.
if err := fake.Tracker().Update(kube.GVRs["templates"], catalogWithoutPasswords(), ""); err != nil {
return true, nil, err
}
if failedLookup {
return true, nil, apierrors.NewServiceUnavailable("transient catalog error")
}
return true, redactTemplate(), nil
})
reg := kube.NewRegistry(scope.DefaultCluster)
reg.Set(scope.DefaultCluster, fakeKubeClient())
reg.Set(cluster, k)
router := chi.NewRouter()
MountResources(router, reg, nil)
user := testAdminUser()
user.Perms[cluster] = map[string]map[string]struct{}{"*": {"*": {}}}
body := updateBody(plainConfig())
delete(body["metadata"].(map[string]any), "namespace")
// As with a real API server, updates preserve the live UID even
// when the PUT body omits it.
fake.PrependReactor("update", "gameservers", func(action clienttesting.Action) (bool, runtime.Object, error) {
action.(clienttesting.UpdateAction).GetObject().(*unstructured.Unstructured).SetUID(gs.GetUID())
return false, nil, nil
})
query := "?cluster=" + cluster
rr := doWithUser(t, router, http.MethodPut, "/servers/alpha"+query, body, user)
if rr.Code != http.StatusOK {
t.Fatalf("update: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
if !failedLookup && configFromBody(t, rr.Body.Bytes())["MOTD"] != "hello" {
t.Fatal("verified ordinary field was needlessly redacted")
}
stored, err := k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(redactNS).Get(t.Context(), "alpha", metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
keys, err := kube.ProtectedConfigKeys(stored.GetAnnotations())
want := []string{"ADMIN_PASSWORD", "OLD_PASSWORD", "RCON_PASSWORD"}
if failedLookup {
want = []string{"ADMIN_PASSWORD", "MOTD", "OLD_PASSWORD", "RCON_PASSWORD"}
}
if err != nil || !reflect.DeepEqual(keys, want) {
t.Fatalf("update lost existing or newly populated password classification: %v %v", keys, err)
}
if !reflect.DeepEqual(storedConfig(t, k, "alpha"), plainConfig()) {
t.Fatal("update changed stored config")
}
rr = doWithUser(t, router, http.MethodGet, "/servers/alpha"+query, nil, user)
if rr.Code != http.StatusOK {
t.Fatalf("read: %d %s", rr.Code, rr.Body)
}
assertNoSecretLeak(t, rr.Body.String())
})
}
}
}

func TestUnpinnedUpdateKeepsMalformedProtectionFailClosed(t *testing.T) {
gs := serverWithConfig("alpha", plainConfig())
gs.SetAnnotations(map[string]string{kube.ProtectedConfigKeysAnnotation: "invalid"})
k := fakeKubeClient(redactTemplate(), gs)
rr := do(t, mountResourcesRouter(k), http.MethodPut, "/servers/alpha", updateBody(plainConfig()))
if rr.Code != http.StatusOK {
t.Fatalf("update: %d %s", rr.Code, rr.Body)
}
for key, value := range configFromBody(t, rr.Body.Bytes()) {
if value != configRedactedMarker {
t.Fatalf("malformed protection exposed %s", key)
}
}
stored, err := k.GetServer(t.Context(), redactNS, "alpha")
if err != nil {
t.Fatal(err)
}
if stored.GetAnnotations()[kube.ProtectedConfigKeysAnnotation] != "invalid" {
t.Fatal("PUT repaired malformed protection without authoritative admission")
}
}

func TestUnpinnedProtectedOrdinaryValueRequiresFreshReplacement(t *testing.T) {
for _, value := range []string{"hello", configRedactedMarker, "fresh ordinary value"} {
t.Run(value, func(t *testing.T) {
gs := serverWithConfig("alpha", plainConfig())
gs.SetAnnotations(map[string]string{kube.ProtectedConfigKeysAnnotation: `["MOTD","ADMIN_PASSWORD"]`})
tmpl := redactTemplate()
fields, _, _ := unstructured.NestedSlice(tmpl.Object, "spec", "configSchema")
fields[0].(map[string]any)["required"] = true
_ = unstructured.SetNestedSlice(tmpl.Object, fields, "spec", "configSchema")
k := fakeKubeClient(tmpl, gs)
cfg := plainConfig()
cfg["MOTD"] = value
rr := do(t, mountResourcesRouter(k), http.MethodPut, "/servers/alpha", updateBody(cfg))
if rr.Code != http.StatusOK {
t.Fatalf("update: %d %s", rr.Code, rr.Body)
}
stored, err := k.GetServer(t.Context(), redactNS, "alpha")
if err != nil {
t.Fatal(err)
}
keys, err := kube.ProtectedConfigKeys(stored.GetAnnotations())
if err != nil {
t.Fatal(err)
}
protected := false
for _, key := range keys {
protected = protected || key == "MOTD"
}
fresh := value == "fresh ordinary value"
if protected == fresh {
t.Fatalf("fresh=%v, protected=%v", fresh, protected)
}
want := any(configRedactedMarker)
if fresh {
want = value
}
if configFromBody(t, rr.Body.Bytes())["MOTD"] != want {
t.Fatal("unsafe response classification")
}
assertNoSecretLeak(t, rr.Body.String())
})
}
}
Loading
Loading