Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4059afb
fix(security): isolate native API credentials and enforce game admission
Baylem Nov 11, 2026
bb66546
docs: preserve the shared Pod Security catalog entry
Baylem Nov 11, 2026
2c03307
test(api): wait for the redacted GameServer event before closing its …
Baylem Nov 11, 2026
3ca6dce
docs: explain credential namespace and admission test behavior
Baylem Nov 11, 2026
11d4af6
fix(operator): bind private module credentials to approved source URLs
Baylem Nov 11, 2026
d0cef81
fix(chart): protect operator-only installs and validate credential mi…
Baylem Nov 11, 2026
7595288
test(web): exercise registry key conflicts in the credentials namespace
Baylem Nov 11, 2026
a1f3b8e
fix(helm): enforce the Kubernetes floor for credential isolation
Baylem Nov 11, 2026
d2315d1
fix(security): isolate native API credentials and enforce game admission
Baylem Nov 11, 2026
f67e459
docs: preserve the shared Pod Security catalog entry
Baylem Nov 11, 2026
7c0bbfc
test(api): wait for the redacted GameServer event before closing its …
Baylem Nov 11, 2026
3def98a
docs: explain credential namespace and admission test behavior
Baylem Nov 11, 2026
f675596
fix(operator): bind private module credentials to approved source URLs
Baylem Nov 11, 2026
23eca93
fix(chart): protect operator-only installs and validate credential mi…
Baylem Nov 11, 2026
6be826f
test(web): exercise registry key conflicts in the credentials namespace
Baylem Nov 11, 2026
9d4b344
fix(helm): retain reviewed admission guards across the upstream rebase
Baylem Nov 12, 2026
933e835
chore(security): integrate merged worker placement checks
Baylem Nov 12, 2026
5e44edf
test(e2e): render worker policy from the control-plane namespace
Baylem Nov 12, 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
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ See [docs/roadmap.md](docs/roadmap.md) for what is planned and in progress.
| Ship audit events to syslog or S3-compatible storage | beta | off | The bundled audit-syslog-bridge turns events into RFC 5424 records sent over TCP, TCP with TLS or UDP, with TCP recommended, and api.audit.s3 batches NDJSON objects flushed at 100 events, 1 MiB or 5 seconds. | [audit-syslog-bridge](audit-syslog-bridge/README.md#transport-prefer-tcp) |
| Prune audit events past a retention age | beta | off | api.audit.retentionDays removes events older than N days, and the default of 0 keeps events forever. | [install](docs/install.md#audit-log) |
| Resolve client IPs through trusted proxies | beta | on | The API reads X-Forwarded-For only from peers inside api.trustedProxies, so audit records and per-IP limits use the real client address. | [security](docs/security.md#client-ip-extraction-from-forwarded-headers) |
| Run Gameplane pods hardened and optionally enforce restricted Pod Security | beta | on | Operator, API, agent and optional bridge pods run as uid 65532 with read-only root filesystems, RuntimeDefault seccomp and all capabilities dropped, and podSecurity.enforceRestricted, off by default, applies the restricted Pod Security Standard to the games namespace. | [security](docs/security.md#pod-security) |
| Run Gameplane pods hardened and optionally enforce restricted Pod Security | beta | on | Operator, API, agent and optional bridge pods run as uid 65532 with read-only root filesystems, RuntimeDefault seccomp and all capabilities dropped, and new installations enforce the Restricted Pod Security Standard in managed game namespaces by default. | [security](docs/security.md#pod-security) |
| Redact password values and confine credentials to labelled Secrets | beta | on | API responses replace non-empty GameServer password values with a redaction marker and fail closed if the template cannot be read, and the API reads and deletes only Secrets carrying its own labels for auth providers and notification sinks. | [security](docs/security.md#gameserver-config-passwords) |
| Encrypt agent and capture control traffic with mTLS | stable | on | Operator-managed agent and capture-sidecar control connections use mutual TLS 1.2 or newer with a verified client certificate. | [security](docs/security.md#authentication) |
| Isolate pods with NetworkPolicies | stable | on | Applies NetworkPolicies that restrict ingress to game servers and give the API pod an egress policy that allows private ranges. | [security](docs/security.md#networkpolicies) |
Expand Down
41 changes: 27 additions & 14 deletions api/cmd/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"cmp"
"context"
"errors"
"flag"
Expand Down Expand Up @@ -223,8 +224,9 @@ func main() {
if err != nil && cfg.oidcIssuer != "" {
logger.Warn("oidc disabled", "err", err)
}
credentialsNS, moduleConfigMapNS := cfg.managementNamespaces()
authRegistry := auth.NewRegistry(store,
auth.NewK8sSecretReader(k8s, cfg.namespace), oidcAuth, cfg.oidcDisplayName, helmPolicy)
auth.NewK8sSecretReader(k8s, credentialsNS), oidcAuth, cfg.oidcDisplayName, helmPolicy)

var auditOpts []audit.Option
var webhookDone <-chan struct{}
Expand Down Expand Up @@ -276,14 +278,14 @@ func main() {
// backup/restore outcomes) and push matching events to the sinks
// configured in Admin Settings. Best-effort mirror — never gates
// reconciliation or requests.
notifier := notify.New(store, k8s, cfg.namespace)
notifier := notify.New(store, k8s, credentialsNS)
go notifier.RunWithRegistry(ctx, reg)

// Cluster registry: watch Cluster CRDs and populate the client registry
// with remote cluster connections. Single-cluster installs only have the
// "local" cluster (the control plane); this watcher loads remote clusters
// from kubeconfigs stored as Secrets.
go kube.WatchClusters(ctx, k8s, reg, cfg.namespace)
go kube.WatchClusters(ctx, k8s, reg, credentialsNS)

// Optional Steam display-name lookup for player lists. Off unless the
// key is set; the resolver is then the only holder of the key and of
Expand Down Expand Up @@ -388,18 +390,18 @@ func main() {
telemetrySettings := handlers.TelemetrySettings{Dest: telemetryDest, Interval: cfg.telemetryInterval, Deps: telemetryDeps}
handlers.MountConfigWithTelemetry(p, store, auditor, oidcAuth != nil, cfg.gameDataStorageClass, helmPolicy, telemetrySettings)
handlers.MountTelemetry(p, store, telemetrySettings)
handlers.MountNotifications(p, notifier, k8s, cfg.namespace)
handlers.MountAuthProviderSecrets(p, k8s, cfg.namespace)
handlers.MountNotifications(p, notifier, k8s, credentialsNS)
handlers.MountAuthProviderSecrets(p, k8s, credentialsNS)
handlers.MountCluster(p, reg, store, Version, cfg.clusterOps, cfg.updateChannel)
handlers.MountClusterActions(p, k8s, cfg.clusterOps, cfg.clusterExternalAddress)
handlers.MountClusters(p, reg, k8s, cfg.namespace)
handlers.MountClusters(p, reg, k8s, credentialsNS)
handlers.MountInstallation(p, cfg.standalone, Version)
handlers.MountEvents(p, reg)
handlers.MountDestinations(p, reg)
handlers.MountTunnelCredentials(p, reg)
handlers.MountSystemLogs(p, k8s, cfg.namespace)
handlers.MountModulesWithRegistry(p, reg, cfg.namespace)
handlers.MountRegistrySecrets(p, k8s, cfg.namespace)
handlers.MountModulesWithRegistry(p, reg, moduleConfigMapNS)
handlers.MountRegistrySecrets(p, k8s, credentialsNS)
// Precedence: a runtime, admin-configured key (DB row + labelled
// Secret, resolved lazily per registry.DBKeyFunc) always wins over
// the startup --curseforge-api-key flag, so adding/rotating a key
Expand All @@ -408,29 +410,29 @@ func main() {
// air-gapped/GitOps installs that never touch admin config keep
// working exactly as before.
regSet := registry.NewSet(Version, registry.FallbackKeys(
registry.DBKeyFunc(store, registry.NewK8sSecretReader(k8s, cfg.namespace)),
registry.DBKeyFunc(store, registry.NewK8sSecretReader(k8s, credentialsNS)),
registry.StaticKeys(map[string]string{"curseforge": cfg.curseforgeAPIKey}),
))
handlers.MountRegistryWithRegistry(p, reg, regSet)
// Internal reads use the same selected cluster and credentials as
// browser-facing agent routes, including remote-only management installs.
agentLister := ws.NewClusterAgentClient(reg, cfg.namespace, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey)
agentLister := ws.NewClusterAgentClient(reg, credentialsNS, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey)
handlers.MountModUpdatesWithRegistry(p, reg, regSet, agentLister)
// ID-managed mods (ARK CurseForge ids, Project Zomboid MOD_IDS,
// Steam Workshop lists): the API only writes GameServer.spec.mods.ids;
// the operator projects it into the game's env (rule 10).
handlers.MountModIDsWithRegistry(p, reg)
captureConfig := handlers.CaptureConfig{
FeatureEnabled: cfg.captureFeatureEnabled,
GatewayNamespace: cfg.namespace,
GatewayNamespace: credentialsNS,
DefaultRetentionSeconds: cfg.captureDefaultRetentionSecs,
MaxRetentionSeconds: cfg.captureMaxRetentionSecs,
DefaultMaxDurationSecs: cfg.captureDefaultMaxDurationS,
DefaultMaxSizeBytes: cfg.captureDefaultMaxSizeBytes,
}
handlers.MountServerCapabilities(p, reg, ws.NewCaptureGatewayClient(reg, cfg.namespace), captureConfig)
handlers.MountServerCapabilities(p, reg, ws.NewCaptureGatewayClient(reg, credentialsNS), captureConfig)
handlers.MountCapture(p, reg, auditor, captureConfig, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey)
ws.MountWithPlayerNames(p, reg, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey, playerNames, ws.AgentGatewayOptions{Namespace: cfg.namespace})
ws.MountWithPlayerNames(p, reg, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey, playerNames, ws.AgentGatewayOptions{Namespace: credentialsNS})
})

// Usage telemetry. Run returns at once when the resolved destination is
Expand Down Expand Up @@ -581,6 +583,17 @@ type config struct {
trustedProxies []string
}

// managementNamespaces keeps native module ConfigMaps with API-managed Secrets.
// In standalone mode the first name scopes encrypted records on the panel, while
// --namespace locates module ConfigMaps on the selected remote cluster.
func (c config) managementNamespaces() (credentials, moduleConfigMaps string) {
credentials = cmp.Or(os.Getenv("GAMEPLANE_CREDENTIALS_NAMESPACE"), c.namespace)
if c.standalone {
return credentials, c.namespace
}
return credentials, credentials
}

func (c *config) bindFlags(fs *flag.FlagSet) {
fs.StringVar(&c.addr, "addr", ":8000", "HTTP listen address")
fs.StringVar(&c.metricsAddr, "metrics-addr", envOr("GAMEPLANE_METRICS_ADDR", ":9090"),
Expand Down Expand Up @@ -664,7 +677,7 @@ func (c *config) bindFlags(fs *flag.FlagSet) {
fs.BoolVar(&c.panelKeyProvisioned, "panel-key-provisioned", envOr("GAMEPLANE_PANEL_KEY_PROVISIONED", "") == "true", "require an existing read-only standalone credential key; never generate it")
fs.StringVar(&c.remoteAllowedCIDRs, "remote-allowed-cidrs", envOr("GAMEPLANE_REMOTE_ALLOWED_CIDRS", ""), "optional comma-separated CIDRs allowed for standalone Kubernetes and gateway connections")
fs.StringVar(&c.namespace, "namespace", envOr("GAMEPLANE_NAMESPACE", "gameplane-system"),
"namespace the control plane runs in (module upload ConfigMaps are stored here)")
"control-plane namespace (in standalone mode, the remote operator's module ConfigMap namespace)")
fs.StringVar(&c.gameDataStorageClass, "game-data-storage-class", envOr("GAMEPLANE_GAME_DATA_STORAGE_CLASS", ""),
"StorageClass name for game data PVCs (empty = cluster default)")

Expand Down
30 changes: 30 additions & 0 deletions api/cmd/main_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
package main

import (
"flag"
"log/slog"
"net/netip"
"testing"
)

// TestManagementNamespaces pins the standalone lookup namespace used in existing
// credential records when an installation has no explicit credentials namespace.
func TestManagementNamespaces(t *testing.T) {
t.Setenv("GAMEPLANE_NAMESPACE", "panel")
for _, tc := range []struct {
name, credentials, wantCredentials, wantConfigMaps string
args []string
}{
{name: "existing environment", wantCredentials: "panel", wantConfigMaps: "panel"},
{name: "existing standalone store", args: []string{"--standalone", "--namespace=legacy-panel"}, wantCredentials: "legacy-panel", wantConfigMaps: "legacy-panel"},
{name: "standalone remote operator", args: []string{"--standalone", "--namespace=remote-modules"}, credentials: "panel-records", wantCredentials: "panel-records", wantConfigMaps: "remote-modules"},
{name: "isolated native credentials and uploads", args: []string{"--namespace=release"}, credentials: "isolated", wantCredentials: "isolated", wantConfigMaps: "isolated"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("GAMEPLANE_CREDENTIALS_NAMESPACE", tc.credentials)
var cfg config
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
cfg.bindFlags(fs)
if err := fs.Parse(tc.args); err != nil {
t.Fatal(err)
}
credentials, configMaps := cfg.managementNamespaces()
if credentials != tc.wantCredentials || configMaps != tc.wantConfigMaps {
t.Errorf("namespaces = %q, %q; want %q, %q", credentials, configMaps, tc.wantCredentials, tc.wantConfigMaps)
}
})
}
}

func TestParseLogLevel(t *testing.T) {
cases := map[string]slog.Level{
"debug": slog.LevelDebug,
Expand Down
2 changes: 1 addition & 1 deletion api/internal/auth/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func (p Provider) Label() string {
}

// SecretReader fetches a named credential Secret's data. The production
// implementation reads labelled Secrets from the control-plane namespace;
// implementation reads labelled Secrets from the credentials namespace;
// tests substitute a map.
type SecretReader func(ctx context.Context, name string) (map[string][]byte, error)

Expand Down
2 changes: 1 addition & 1 deletion api/internal/handlers/clusters.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) {
return
}

// Create the kubeconfig Secret in the control-plane namespace. The
// Create the kubeconfig Secret in the credentials namespace. The
// managed-by label marks it as created by the API, which is what lets
// DELETE /clusters/{name} remove it again.
secretName := clusterKubeconfigSecretName(in.Name)
Expand Down
47 changes: 33 additions & 14 deletions api/internal/handlers/config_redact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ func TestRedact_EventsStreamHonoursTemplateChangeBetweenEvents(t *testing.T) {
map[string]any{"name": "TOKEN", "type": "string"},
}}
k := fleetTestClient(tmpl)
fw := watch.NewFake()
fw := watch.NewFakeWithOptions(watch.FakeOptions{ChannelSize: 1})
k.Dynamic.(*dynamicfake.FakeDynamicClient).PrependWatchReactor("gameservers",
func(_ clienttesting.Action) (bool, watch.Interface, error) { return true, fw, nil })
reg := kube.NewRegistry(scope.DefaultCluster)
Expand All @@ -467,20 +467,36 @@ func TestRedact_EventsStreamHonoursTemplateChangeBetweenEvents(t *testing.T) {
eventsHandler(reg)(w, req)
}()

waitFrames := func(n int) {
waitServerEvent := func(eventType watch.EventType) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for strings.Count(w.String(), "data:") < n {
if time.Now().After(deadline) {
cancel()
t.Fatalf("want %d SSE frames, got: %s", n, w.String())
for time.Now().Before(deadline) {
for line := range strings.SplitSeq(w.String(), "\n") {
data, ok := strings.CutPrefix(line, "data: ")
if !ok {
continue
}
var event struct {
Kind string `json:"kind"`
EventType watch.EventType `json:"eventType"`
}
if err := json.Unmarshal([]byte(data), &event); err != nil {
t.Fatalf("decode SSE frame: %v", err)
}
if event.Kind == "servers" && event.EventType == eventType {
return data
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("no %s server event in stream: %s", eventType, w.String())
return ""
}

go fw.Add(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-first"}))
waitFrames(1)
fw.Add(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-first"}))
if first := waitServerEvent(watch.Added); !strings.Contains(first, "tok-first") {
t.Fatalf("first server event lost the plain value: %s", first)
}

updated := tmpl.DeepCopy()
updated.Object["spec"] = map[string]any{"configSchema": []any{
Expand All @@ -491,14 +507,17 @@ func TestRedact_EventsStreamHonoursTemplateChangeBetweenEvents(t *testing.T) {
t.Fatalf("update template: %v", err)
}

go fw.Modify(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-second"}))
waitFrames(2)
fw.Modify(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-second"}))
out := waitServerEvent(watch.Modified)
cancel()
<-done
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("events handler did not stop after cancellation")
}

out := w.String()
if strings.Contains(out, "tok-second") {
t.Fatalf("second event leaked a value that became a password: %s", out)
if strings.Contains(w.String(), "tok-second") {
t.Fatalf("stream leaked a value that became a password: %s", w.String())
}
if !strings.Contains(out, configRedactedMarker) {
t.Fatalf("second event has no redaction marker: %s", out)
Expand Down
2 changes: 1 addition & 1 deletion api/internal/handlers/module_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// The body is a tar.gz or zip holding one module directory (module.yaml
// + template.yaml [+ README.md, icon.png, locales/<tag>.json]). After validation the files
// are written into a ConfigMap labeled gameplane.local/module-upload=true in
// the operator namespace — the exact shape an upload-type ModuleSource
// the module ConfigMap namespace — the exact shape an upload-type ModuleSource
// indexes, and the same thing `kubectl apply` of such a ConfigMap
// produces, so the operator stays authoritative. ?dryRun=true validates
// and returns the parsed metadata without storing anything.
Expand Down
4 changes: 2 additions & 2 deletions api/internal/handlers/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ import (
"github.com/GameplanePanel/gameplane/api/internal/kube"
)

// MountModules wires the /modules surface onto r. namespace is the
// operator namespace, where uploaded bundle ConfigMaps live.
// MountModules wires the /modules surface onto r. namespace holds the
// uploaded bundle and translation ConfigMaps.
func MountModules(r chi.Router, k *kube.Client, namespace string) {
h := modulesHandler{k: k, namespace: namespace}
r.Route("/modules", func(r chi.Router) {
Expand Down
4 changes: 2 additions & 2 deletions api/internal/handlers/modules_locales.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
)

// localesConfigMapSuffix names the ConfigMap the operator writes a module's
// validated translations to: <module name> + this suffix, in the operator
// namespace. Keys are "<tag>.json" holding an app.json style entry map.
// validated translations to: <module name> + this suffix, in the module
// ConfigMap namespace. Keys are "<tag>.json" holding an app.json style entry map.
const localesConfigMapSuffix = "-locales"

// getLocales serves GET /modules/{name}/locales/{tag}: the module's
Expand Down
2 changes: 1 addition & 1 deletion api/internal/handlers/secrets_managed.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// API-managed credential Secrets. Admin Settings lets an admin type a
// sink's (and later a provider's) secret material directly into the
// dashboard; the API persists it as a labelled Secret in the
// control-plane namespace so the consuming subsystem (notify, auth) can
// credentials namespace so the consuming subsystem (notify, auth) can
// keep its existing "read only labelled Secrets" contract.
//
// Two labels bound what this surface may touch:
Expand Down
2 changes: 1 addition & 1 deletion api/internal/notify/sinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func sinkMatches(s Sink, t EventType) bool {
return false
}

// sinkSecret fetches the sink's credential Secret from the control-plane
// sinkSecret fetches the sink's credential Secret from the credentials
// namespace, refusing any Secret not labelled SinkSecretLabel=true.
func (n *Notifier) sinkSecret(ctx context.Context, name string) (map[string][]byte, error) {
sec, err := n.k.Secrets(n.controlNS).Get(ctx, name, metav1.GetOptions{})
Expand Down
2 changes: 1 addition & 1 deletion api/internal/ws/gateway_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const gatewayCredentialsLabel = "gameplane.local/agent-gateway-credentials"
var errGatewayUnavailable = errors.New("agent gateway not configured")

// AgentGatewayOptions enables registered-cluster agent routes. Secrets are read
// only from the namespace where the central API runs.
// only from the central API's configured credentials namespace.
type AgentGatewayOptions struct{ Namespace string }

type agentGatewayResolver struct {
Expand Down
11 changes: 10 additions & 1 deletion api/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,17 @@ headers retain the existing allowlist, so browser cookies, authorization and CSR
material do not reach agents. Proxy body limits and JSON response limits remain at
the caller boundary.

The API reads and writes dashboard-managed OIDC, registry and notification
credentials, remote kubeconfigs and gateway TLS Secrets in
`GAMEPLANE_CREDENTIALS_NAMESPACE`. When unset or empty, this defaults to the
resolved `--namespace` value, preserving existing standalone database keys.
Native installs store module uploads and translations in that same namespace.
System logs use `--namespace`. In standalone mode `--namespace` selects the
remote operator's module ConfigMap namespace independently of the panel's
credential storage.

Registered clusters may additionally configure `spec.agentGateway.url` and a
labeled `tlsSecretRef` in the central API namespace. Each remote request resolves
labeled `tlsSecretRef` in the credentials namespace. Each remote request resolves
its Kubernetes client, GameServer UID and gateway mTLS credentials independently;
unknown/missing routes never fall back locally. HTTP/WebSocket agent operations,
RCON module actions and internal mod-update reads use the versioned gateway
Expand Down
1 change: 1 addition & 0 deletions changelog/unreleased/886-native-credential-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Gameplane chart now requires Kubernetes 1.30 or later. The native API stores its credentials and uploaded module ConfigMaps in a dedicated namespace. Before upgrading, migrate existing credentials, uploads and module locale ConfigMaps, remove legacy service-account token Secrets, and bind each private HTTP or Git module credential to its exact source URL. Follow the [native credential upgrade instructions](../../docs/install.md#upgrading-native-api-credentials).
1 change: 1 addition & 0 deletions charts/gameplane/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ description: Kubernetes-native game server control panel (open-source alternativ
type: application
version: 0.4.0
appVersion: "0.4.0"
kubeVersion: ">=1.30.0-0"
home: https://github.com/GameplanePanel/gameplane
sources:
- https://github.com/GameplanePanel/gameplane
Expand Down
Loading
Loading