diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index d573eccce..554a118e1 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,7 +1,15 @@ # ACP Constitution @@ -255,6 +264,54 @@ Each commit MUST be atomic, reviewable, and independently testable: **Rationale**: Large commits hide bugs, slow reviews, complicate bisecting, and create merge conflicts. Specific thresholds provide objective guidance while exceptions handle legitimate cases. Small, focused commits enable faster feedback, easier debugging (git bisect), and safer reverts. +### XI. Feature Flag Discipline + +All new features MUST be gated behind feature flags: + +**Mandatory Requirements**: + +- **Flag Gating**: Every new user-facing feature MUST be controlled by a feature flag +- **Default Off**: Feature flags MUST default to `false` (disabled) in production +- **Gradual Rollout**: Features MUST support percentage-based or tenant-based rollout +- **Kill Switch**: Every feature flag MUST act as an instant kill switch for its feature + +**Flag Naming Convention**: + +- Format: `..` (e.g., `backend.rfe-workflow.enabled`) +- Use lowercase with hyphens for multi-word names +- Include `enabled` suffix for on/off toggles +- Include `percentage` suffix for gradual rollouts + +**Implementation Requirements**: + +- **Backend**: Check flag before executing feature logic; return 404 or appropriate error when disabled +- **Frontend**: Conditionally render UI components; hide disabled features from navigation +- **Operator**: Skip reconciliation for flagged features when disabled +- **API Contracts**: Flagged endpoints MUST return consistent error responses when feature is disabled + +**Flag Lifecycle**: + +1. **Development**: Flag created, defaults to `false` +2. **Testing**: Flag enabled in test/staging environments +3. **Rollout**: Gradual enablement (10% → 50% → 100%) in production +4. **Graduation**: Flag removed after feature is stable (minimum 2 weeks at 100%) +5. **Cleanup**: Dead flag code MUST be removed within 30 days of graduation + +**Flag Storage**: + +- Flags stored in ProjectSettings CR for project-scoped features +- Cluster-wide flags in ConfigMap `ambient-code-feature-flags` +- Frontend flags fetched via `/api/feature-flags` endpoint with caching + +**Exceptions** (requires justification in PR): + +- **Bug Fixes**: Critical bug fixes do not require flags +- **Internal Refactoring**: Non-user-facing changes do not require flags +- **Infrastructure**: Logging, metrics, observability changes do not require flags +- **Security Patches**: Security fixes MUST NOT be gated (immediate deployment required) + +**Rationale**: Feature flags enable safe deployments, instant rollback without redeploy, A/B testing, and gradual rollouts. They decouple deployment from release, reducing blast radius of bugs and enabling faster iteration. Flags that linger become technical debt, hence strict cleanup requirements. + ## Development Standards ### Go Code (Backend & Operator) @@ -274,6 +331,12 @@ Each commit MUST be atomic, reviewable, and independently testable: - Status updates: Use `UpdateStatus` subresource - Watch loops: Reconnect on channel close with backoff +**Feature Flags**: See Principle XI: Feature Flag Discipline + +- Import feature flag package: `featureflags` +- Check flags early in handler: `if !featureflags.IsEnabled(ctx, "feature.name") { return }` +- Include flag name in structured logs when feature is disabled + ### Frontend Code (NextJS) **UI Components**: @@ -295,6 +358,12 @@ Each commit MUST be atomic, reviewable, and independently testable: - All routes MUST have `page.tsx`, `loading.tsx`, `error.tsx` - Components over 200 lines MUST be broken down +**Feature Flags**: See Principle XI: Feature Flag Discipline + +- Use `FeatureFlagProvider` context for flag access +- Conditionally render with `` wrapper +- Hide navigation items for disabled features + ### Python Code (Runner) **Environment**: @@ -368,6 +437,12 @@ npm run build # Must pass with 0 errors, 0 warnings - Drop all capabilities by default - Use non-root users where possible +**Feature Flag Verification**: + +- Verify new features are gated behind flags +- Confirm flags default to `false` in production manifests +- Test feature behavior with flag both enabled and disabled + ### Production Requirements **Security**: Apply Principle II security requirements. Additionally: Scan container images for vulnerabilities before deployment. @@ -382,6 +457,13 @@ npm run build # Must pass with 0 errors, 0 warnings - Design for multi-tenancy with shared infrastructure - Do not use etcd as a database for unbounded objects like CRs. Use an external database like Postgres. +**Feature Flags**: + +- All new features deployed with flags defaulting to `false` +- Gradual rollout via flag percentage or tenant targeting +- Monitor error rates and latency during rollout +- Maintain rollback capability via flag disable + ## Governance ### Amendment Process @@ -404,6 +486,7 @@ npm run build # Must pass with 0 errors, 0 warnings - Pre-commit checklists MUST be followed for backend, frontend, and operator code - Complexity violations MUST be justified in implementation plans - Constitution supersedes all other practices and guidelines +- New features MUST include feature flag implementation per Principle XI ### Development Guidance @@ -413,5 +496,4 @@ Runtime development guidance is maintained in: - Component-specific README files - MkDocs documentation in `/docs` -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +**Version**: 1.1.0 | **Ratified**: 2025-11-13 | **Last Amended**: 2026-02-16 diff --git a/Makefile b/Makefile index 28b3ffc15..d5c667b5a 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ .PHONY: local-test local-test-dev local-test-quick test-all local-url local-troubleshoot local-port-forward local-stop-port-forward .PHONY: push-all registry-login setup-hooks remove-hooks check-minikube check-kind check-kubectl dev-bootstrap .PHONY: e2e-test e2e-setup e2e-clean deploy-langfuse-openshift +.PHONY: deploy-unleash deploy-unleash-kind deploy-unleash-openshift unleash-port-forward unleash-status unleash-clean .PHONY: setup-minio minio-console minio-logs minio-status .PHONY: validate-makefile lint-makefile check-shell makefile-health .PHONY: _create-operator-config _auto-port-forward _show-access-info _build-and-load @@ -668,6 +669,47 @@ deploy-langfuse-openshift: ## Deploy Langfuse to OpenShift/ROSA cluster @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Deploying Langfuse to OpenShift cluster..." @cd e2e && ./scripts/deploy-langfuse.sh --openshift +##@ Unleash Feature Flags + +deploy-unleash: ## Deploy Unleash (auto-detect platform) + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Deploying Unleash feature flag server..." + @./e2e/scripts/deploy-unleash.sh + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Unleash deployed" + +deploy-unleash-kind: check-kind check-kubectl ## Deploy Unleash to kind cluster + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Deploying Unleash to kind cluster..." + @./e2e/scripts/deploy-unleash.sh --kubernetes + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Unleash deployed to kind" + @echo "" + @echo "$(COLOR_BOLD)Next steps:$(COLOR_RESET)" + @echo " 1. Run: $(COLOR_BLUE)make unleash-port-forward$(COLOR_RESET)" + @echo " 2. Access Unleash UI at: http://localhost:4242" + @echo " 3. Login: admin / unleash4all" + +deploy-unleash-openshift: ## Deploy Unleash to OpenShift/CRC cluster + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Deploying Unleash to OpenShift cluster..." + @./e2e/scripts/deploy-unleash.sh --openshift + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Unleash deployed to OpenShift" + +unleash-port-forward: check-kubectl ## Port-forward Unleash (localhost:4242) + @echo "$(COLOR_BOLD)🔌 Port forwarding Unleash$(COLOR_RESET)" + @echo "" + @echo " Unleash UI: http://localhost:4242" + @echo " Login: admin / unleash4all" + @echo "" + @echo "$(COLOR_YELLOW)Press Ctrl+C to stop$(COLOR_RESET)" + @kubectl port-forward svc/unleash 4242:4242 -n unleash + +unleash-status: check-kubectl ## Show Unleash deployment status + @echo "$(COLOR_BOLD)Unleash Status$(COLOR_RESET)" + @kubectl get deployment,pod,svc -l 'app.kubernetes.io/name in (unleash,postgresql)' -n unleash 2>/dev/null || \ + echo "$(COLOR_RED)✗$(COLOR_RESET) Unleash not found. Run 'make deploy-unleash' first." + +unleash-clean: check-kubectl ## Remove Unleash deployment + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Removing Unleash..." + @kubectl delete namespace unleash --ignore-not-found=true --timeout=60s + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Unleash removed" + ##@ Internal Helpers (do not call directly) check-minikube: ## Check if minikube is installed diff --git a/components/backend/README.md b/components/backend/README.md index f709d1392..202b8baef 100644 --- a/components/backend/README.md +++ b/components/backend/README.md @@ -179,6 +179,10 @@ make deps-verify # Verify dependencies make check-env # Verify Go, kubectl, docker installed ``` +### Feature flags (Unleash) + +See [docs/feature-flags](../../docs/feature-flags/README.md) for env vars, handler usage, and examples. + ## Architecture See `CLAUDE.md` in project root for: @@ -193,6 +197,8 @@ See `CLAUDE.md` in project root for: - `handlers/sessions.go` - AgenticSession lifecycle, user/SA client usage - `handlers/middleware.go` - Auth patterns, token extraction, RBAC - `handlers/helpers.go` - Utility functions (StringPtr, BoolPtr) +- `handlers/featureflags.go` - Feature flag helpers (see docs/feature-flags/) +- `featureflags/featureflags.go` - Unleash client init - `types/common.go` - Type definitions - `server/server.go` - Server setup, middleware chain, token redaction - `routes.go` - HTTP route definitions and registration diff --git a/components/backend/featureflags/featureflags.go b/components/backend/featureflags/featureflags.go new file mode 100644 index 000000000..8a49ce3c5 --- /dev/null +++ b/components/backend/featureflags/featureflags.go @@ -0,0 +1,63 @@ +// Package featureflags provides optional Unleash-backed feature flag checks for the backend. +// When UNLEASH_URL and UNLEASH_CLIENT_KEY are not set, all flags are disabled (IsEnabled returns false). +package featureflags + +import ( + "log" + "net/http" + "os" + "strings" + + "github.com/Unleash/unleash-go-sdk/v5" + unleashContext "github.com/Unleash/unleash-go-sdk/v5/context" +) + +const appName = "ambient-code-backend" + +var initialized bool + +// Init initializes the Unleash client when UNLEASH_URL and UNLEASH_CLIENT_KEY are set. +// Safe to call multiple times; only initializes once when config is present. +// Call from main after loading env and before starting the server. +func Init() { + url := strings.TrimSpace(os.Getenv("UNLEASH_URL")) + clientKey := strings.TrimSpace(os.Getenv("UNLEASH_CLIENT_KEY")) + if url == "" || clientKey == "" { + return + } + // Ensure URL has a trailing slash for the SDK + if !strings.HasSuffix(url, "/") { + url += "/" + } + unleash.Initialize( + unleash.WithAppName(appName), + unleash.WithUrl(url), + unleash.WithCustomHeaders(http.Header{"Authorization": {clientKey}}), + ) + initialized = true + log.Printf("Unleash feature flags enabled (url=%s)", strings.TrimSuffix(url, "/")) +} + +// IsEnabled returns true if the named feature flag is enabled. +// When Unleash is not configured, returns false. Safe to call from any handler. +func IsEnabled(flagName string) bool { + if !initialized { + return false + } + return unleash.IsEnabled(flagName) +} + +// IsEnabledWithContext returns true if the flag is enabled for the given user context. +// Use for strategies that depend on userId, sessionId, or remoteAddress. +// When Unleash is not configured, returns false. +func IsEnabledWithContext(flagName string, userID, sessionID, remoteAddress string) bool { + if !initialized { + return false + } + ctx := unleashContext.Context{ + UserId: userID, + SessionId: sessionID, + RemoteAddress: remoteAddress, + } + return unleash.IsEnabled(flagName, unleash.WithContext(ctx)) +} diff --git a/components/backend/go.mod b/components/backend/go.mod index 2cf62958c..4738f7310 100644 --- a/components/backend/go.mod +++ b/components/backend/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.7 require ( + github.com/Unleash/unleash-go-sdk/v5 v5.1.0 github.com/anthropics/anthropic-sdk-go v1.2.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 @@ -54,6 +55,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/launchdarkly/eventsource v1.10.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -64,11 +66,13 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/twmb/murmur3 v1.1.8 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/components/backend/go.sum b/components/backend/go.sum index d92b7491c..e1d26985d 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -8,6 +8,8 @@ cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykW github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Unleash/unleash-go-sdk/v5 v5.1.0 h1:W+HHQklU5/H9kjYTn/T4TKvDHE0BxnZ0+MyTk06RdYw= +github.com/Unleash/unleash-go-sdk/v5 v5.1.0/go.mod h1:1u8BfdyjlkV5j43la61n9A9ul4E+YQC2kKQotz8z7BE= github.com/anthropics/anthropic-sdk-go v1.2.0 h1:RQzJUqaROewrPTl7Rl4hId/TqmjFvfnkmhHJ6pP1yJ8= github.com/anthropics/anthropic-sdk-go v1.2.0/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= @@ -116,6 +118,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -137,6 +143,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/launchdarkly/eventsource v1.10.0 h1:H9Tp6AfGu/G2qzBJC26iperrvwhzdbiA/gx7qE2nDFI= +github.com/launchdarkly/eventsource v1.10.0/go.mod h1:J3oa50bPvJesZqNAJtb5btSIo5N6roDWhiAS3IpsKck= +github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= +github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -155,6 +165,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= @@ -194,6 +206,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= +github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/components/backend/handlers/featureflags.go b/components/backend/handlers/featureflags.go new file mode 100644 index 000000000..0d5cd17b3 --- /dev/null +++ b/components/backend/handlers/featureflags.go @@ -0,0 +1,27 @@ +// Package handlers: feature flag helpers for use inside HTTP handlers. +// Backed by the optional Unleash integration (see featureflags package). +// When Unleash is not configured, all flags are disabled. + +package handlers + +import ( + "ambient-code-backend/featureflags" + + "github.com/gin-gonic/gin" +) + +// FeatureEnabled returns true if the named feature flag is enabled (global check). +// When Unleash is not configured, returns false. Safe to call from any handler. +func FeatureEnabled(flagName string) bool { + return featureflags.IsEnabled(flagName) +} + +// FeatureEnabledForRequest returns true if the flag is enabled for the current request. +// Uses forwarded user ID, client IP, and optional session for Unleash strategies (e.g. userId, remoteAddress). +// When Unleash is not configured, returns false. +func FeatureEnabledForRequest(c *gin.Context, flagName string) bool { + userID := c.GetString("userID") + sessionID := c.GetHeader("X-Session-Id") // optional + remoteAddr := c.ClientIP() + return featureflags.IsEnabledWithContext(flagName, userID, sessionID, remoteAddr) +} diff --git a/components/backend/handlers/featureflags_admin.go b/components/backend/handlers/featureflags_admin.go new file mode 100644 index 000000000..e7b21cf0a --- /dev/null +++ b/components/backend/handlers/featureflags_admin.go @@ -0,0 +1,686 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // FeatureFlagOverridesConfigMap is the name of the ConfigMap storing workspace-specific flag overrides + FeatureFlagOverridesConfigMap = "feature-flag-overrides" +) + +var ( + unleashAdminURL = os.Getenv("UNLEASH_ADMIN_URL") // e.g., https://unleash.example.com + unleashAdminToken = os.Getenv("UNLEASH_ADMIN_TOKEN") // Admin API token + unleashProject = os.Getenv("UNLEASH_PROJECT") // Unleash project ID (default: "default") + unleashEnv = os.Getenv("UNLEASH_ENVIRONMENT") // Environment (default: "development") + unleashWorkspaceTagType = os.Getenv("UNLEASH_WORKSPACE_TAG_TYPE") // Tag type for workspace-configurable flags (default: "scope") + unleashWorkspaceTagValue = os.Getenv("UNLEASH_WORKSPACE_TAG_VALUE") // Tag value for workspace-configurable flags (default: "workspace") +) + +// FeatureToggle represents a feature toggle with workspace override status +type FeatureToggle struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + Type string `json:"type,omitempty"` // release, experiment, operational, etc. + Stale bool `json:"stale,omitempty"` + Tags []Tag `json:"tags,omitempty"` + Environments []EnvState `json:"environments,omitempty"` + Source string `json:"source"` // "workspace-override" or "unleash" + OverrideEnabled *bool `json:"overrideEnabled,omitempty"` // nil if no override, true/false if overridden +} + +// Tag represents an Unleash tag +type Tag struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// EnvState represents the state of a toggle in an environment +type EnvState struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +// unleashFeaturesResponse is the response from Unleash Admin API for listing features +type unleashFeaturesResponse struct { + Features []unleashFeature `json:"features"` +} + +// unleashFeature is a single feature from Unleash Admin API +type unleashFeature struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Stale bool `json:"stale"` + Tags []Tag `json:"tags"` + Environments []unleashEnvironment `json:"environments"` +} + +// unleashEnvironment represents an environment in Unleash +type unleashEnvironment struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +// OverrideRequest represents a request to set a feature flag override +type OverrideRequest struct { + Enabled bool `json:"enabled"` +} + +// getWorkspaceOverrides reads the feature-flag-overrides ConfigMap for a namespace +func getWorkspaceOverrides(ctx context.Context, namespace string) (map[string]string, error) { + cm, err := K8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + if errors.IsNotFound(err) { + return nil, nil // No overrides ConfigMap exists + } + if err != nil { + return nil, err + } + return cm.Data, nil +} + +// isWorkspaceConfigurable checks if a feature flag has the workspace-configurable tag. +// Only flags with this tag are shown in the workspace admin UI. +// Flags without this tag are platform-only and can only be managed via Unleash UI. +func isWorkspaceConfigurable(tags []Tag) bool { + tagType := getWorkspaceTagType() + tagValue := getWorkspaceTagValue() + + for _, tag := range tags { + if tag.Type == tagType && tag.Value == tagValue { + return true + } + } + return false +} + +func getWorkspaceTagType() string { + if unleashWorkspaceTagType == "" { + unleashWorkspaceTagType = os.Getenv("UNLEASH_WORKSPACE_TAG_TYPE") + } + if unleashWorkspaceTagType == "" { + return "scope" + } + return unleashWorkspaceTagType +} + +func getWorkspaceTagValue() string { + if unleashWorkspaceTagValue == "" { + unleashWorkspaceTagValue = os.Getenv("UNLEASH_WORKSPACE_TAG_VALUE") + } + if unleashWorkspaceTagValue == "" { + return "workspace" + } + return unleashWorkspaceTagValue +} + +// ListFeatureFlags handles GET /api/projects/:projectName/feature-flags +// Lists all feature flags from Unleash with workspace override status +func ListFeatureFlags(c *gin.Context) { + ctx := context.Background() + namespace := c.Param("projectName") + + // Verify user has project access first (uses user token) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + // Get workspace overrides + overrides, err := getWorkspaceOverrides(ctx, namespace) + if err != nil { + log.Printf("Failed to get workspace overrides for %s: %v", namespace, err) + // Continue - overrides are optional + } + + // Check if Unleash Admin is configured + if getUnleashAdminURL() == "" || getUnleashAdminToken() == "" { + // Return just workspace overrides if Unleash not configured + if len(overrides) > 0 { + features := make([]FeatureToggle, 0, len(overrides)) + for flagName, value := range overrides { + enabled := value == "true" + features = append(features, FeatureToggle{ + Name: flagName, + Enabled: enabled, + Source: "workspace-override", + OverrideEnabled: &enabled, + }) + } + c.JSON(http.StatusOK, gin.H{"features": features}) + return + } + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Unleash Admin API not configured", + "message": "Set UNLEASH_ADMIN_URL and UNLEASH_ADMIN_TOKEN environment variables", + }) + return + } + + url := fmt.Sprintf("%s/api/admin/projects/%s/features", + strings.TrimSuffix(getUnleashAdminURL(), "/"), + getUnleashProject()) + + resp, err := unleashAdminRequest("GET", url, nil) + if err != nil { + log.Printf("Failed to connect to Unleash Admin API: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to connect to Unleash"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read Unleash response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response"}) + return + } + + if resp.StatusCode != http.StatusOK { + log.Printf("Unleash Admin API returned %d: %s", resp.StatusCode, string(body)) + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch feature flags from Unleash"}) + return + } + + // Parse and transform response + var unleashResp unleashFeaturesResponse + if err := json.Unmarshal(body, &unleashResp); err != nil { + log.Printf("Failed to parse Unleash response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse response"}) + return + } + + // Transform to our format with workspace override status + // Only include flags that are workspace-configurable (have the required tag) + targetEnv := getUnleashEnv() + features := make([]FeatureToggle, 0, len(unleashResp.Features)) + for _, f := range unleashResp.Features { + // Filter: Only show flags with workspace-configurable tag + // Platform-only flags (without tag) are hidden from workspace admin UI + if !isWorkspaceConfigurable(f.Tags) { + continue + } + + // Determine Unleash enabled state in target environment + unleashEnabled := false + envStates := make([]EnvState, 0, len(f.Environments)) + for _, env := range f.Environments { + envStates = append(envStates, EnvState(env)) + if env.Name == targetEnv { + unleashEnabled = env.Enabled + } + } + + // Check for workspace override + var overrideEnabled *bool + effectiveEnabled := unleashEnabled + source := "unleash" + + if overrides != nil { + if override, exists := overrides[f.Name]; exists { + enabled := override == "true" + overrideEnabled = &enabled + effectiveEnabled = enabled + source = "workspace-override" + } + } + + features = append(features, FeatureToggle{ + Name: f.Name, + Description: f.Description, + Enabled: effectiveEnabled, + Type: f.Type, + Stale: f.Stale, + Tags: f.Tags, + Environments: envStates, + Source: source, + OverrideEnabled: overrideEnabled, + }) + } + + c.JSON(http.StatusOK, gin.H{"features": features}) +} + +// EvaluateFeatureFlag handles GET /api/projects/:projectName/feature-flags/evaluate/:flagName +// Evaluates a feature flag for a workspace using three-state logic: +// 1. Check ConfigMap override - if set, return that value +// 2. Fall back to Unleash default +func EvaluateFeatureFlag(c *gin.Context) { + ctx := context.Background() + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + // Verify user has project access first + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + if flagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Flag name is required"}) + return + } + + // 1. Check ConfigMap for workspace override + overrides, err := getWorkspaceOverrides(ctx, namespace) + if err != nil { + log.Printf("Failed to get workspace overrides for %s: %v", namespace, err) + // Continue to Unleash fallback + } + + if overrides != nil { + if override, exists := overrides[flagName]; exists { + enabled := override == "true" + c.JSON(http.StatusOK, gin.H{ + "flag": flagName, + "enabled": enabled, + "source": "workspace-override", + }) + return + } + } + + // 2. Fall back to Unleash Client SDK (generates metrics for flag evaluation) + // This uses the initialized Unleash Go SDK which properly tracks usage metrics. + // The SDK was initialized in main.go via featureflags.Init(). + enabled := FeatureEnabledForRequest(c, flagName) + c.JSON(http.StatusOK, gin.H{ + "flag": flagName, + "enabled": enabled, + "source": "unleash", + }) +} + +// GetFeatureFlag handles GET /api/projects/:projectName/feature-flags/:flagName +// Gets details for a specific feature toggle +func GetFeatureFlag(c *gin.Context) { + // Verify user has project access first + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + flagName := c.Param("flagName") + if flagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Flag name is required"}) + return + } + + // Check if Unleash Admin is configured + if getUnleashAdminURL() == "" || getUnleashAdminToken() == "" { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Unleash Admin API not configured", + "message": "Set UNLEASH_ADMIN_URL and UNLEASH_ADMIN_TOKEN environment variables", + }) + return + } + + url := fmt.Sprintf("%s/api/admin/projects/%s/features/%s", + strings.TrimSuffix(getUnleashAdminURL(), "/"), + getUnleashProject(), + flagName) + + resp, err := unleashAdminRequest("GET", url, nil) + if err != nil { + log.Printf("Failed to connect to Unleash Admin API: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to connect to Unleash"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read Unleash response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response"}) + return + } + + if resp.StatusCode != http.StatusOK { + log.Printf("Unleash Admin API returned %d for flag %s: %s", resp.StatusCode, flagName, string(body)) + if resp.StatusCode == http.StatusNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Feature flag not found"}) + } else { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch feature flag from Unleash"}) + } + return + } + + c.Data(resp.StatusCode, "application/json", body) +} + +// SetFeatureFlagOverride handles PUT /api/projects/:projectName/feature-flags/:flagName/override +// Sets a workspace-scoped override for a feature flag +func SetFeatureFlagOverride(c *gin.Context) { + ctx := context.Background() + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + // Verify user has project access first + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + if flagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Flag name is required"}) + return + } + + var req OverrideRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Get or create ConfigMap + cm, err := K8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + if errors.IsNotFound(err) { + // Create new ConfigMap + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "ambient-code", + "app.kubernetes.io/component": "feature-flags", + }, + }, + Data: map[string]string{}, + } + cm, err = K8sClient.CoreV1().ConfigMaps(namespace).Create(ctx, cm, metav1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create override"}) + return + } + } else if err != nil { + log.Printf("Failed to get feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get overrides"}) + return + } + + // Set override + if cm.Data == nil { + cm.Data = map[string]string{} + } + cm.Data[flagName] = strconv.FormatBool(req.Enabled) + + _, err = K8sClient.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set override"}) + return + } + + log.Printf("Feature flag override set: %s=%v in workspace %s", flagName, req.Enabled, namespace) + c.JSON(http.StatusOK, gin.H{ + "message": "Override set", + "flag": flagName, + "enabled": req.Enabled, + "source": "workspace-override", + }) +} + +// DeleteFeatureFlagOverride handles DELETE /api/projects/:projectName/feature-flags/:flagName/override +// Removes a workspace-scoped override, reverting to Unleash default +func DeleteFeatureFlagOverride(c *gin.Context) { + ctx := context.Background() + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + // Verify user has project access first + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + if flagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Flag name is required"}) + return + } + + // Get ConfigMap + cm, err := K8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + if errors.IsNotFound(err) { + // No overrides exist, nothing to delete + c.JSON(http.StatusOK, gin.H{ + "message": "No override to remove", + "flag": flagName, + }) + return + } + if err != nil { + log.Printf("Failed to get feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get overrides"}) + return + } + + // Remove override + if cm.Data != nil { + delete(cm.Data, flagName) + } + + _, err = K8sClient.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove override"}) + return + } + + log.Printf("Feature flag override removed: %s in workspace %s", flagName, namespace) + c.JSON(http.StatusOK, gin.H{ + "message": "Override removed", + "flag": flagName, + "source": "unleash", + }) +} + +// EnableFeatureFlag handles POST /api/projects/:projectName/feature-flags/:flagName/enable +// Sets a workspace override to enable the feature flag +func EnableFeatureFlag(c *gin.Context) { + ctx := context.Background() + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + // Verify user has project access first + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + if flagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Flag name is required"}) + return + } + + // Get or create ConfigMap + cm, err := K8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + if errors.IsNotFound(err) { + // Create new ConfigMap + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "ambient-code", + "app.kubernetes.io/component": "feature-flags", + }, + }, + Data: map[string]string{}, + } + cm, err = K8sClient.CoreV1().ConfigMaps(namespace).Create(ctx, cm, metav1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable feature"}) + return + } + } else if err != nil { + log.Printf("Failed to get feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable feature"}) + return + } + + // Set override to true + if cm.Data == nil { + cm.Data = map[string]string{} + } + cm.Data[flagName] = "true" + + _, err = K8sClient.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable feature"}) + return + } + + log.Printf("Feature flag enabled: %s in workspace %s", flagName, namespace) + c.JSON(http.StatusOK, gin.H{ + "message": "Feature enabled", + "flag": flagName, + "enabled": true, + "source": "workspace-override", + }) +} + +// DisableFeatureFlag handles POST /api/projects/:projectName/feature-flags/:flagName/disable +// Sets a workspace override to disable the feature flag +func DisableFeatureFlag(c *gin.Context) { + ctx := context.Background() + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + // Verify user has project access first + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User token required"}) + c.Abort() + return + } + + if flagName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Flag name is required"}) + return + } + + // Get or create ConfigMap + cm, err := K8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + if errors.IsNotFound(err) { + // Create new ConfigMap + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "ambient-code", + "app.kubernetes.io/component": "feature-flags", + }, + }, + Data: map[string]string{}, + } + cm, err = K8sClient.CoreV1().ConfigMaps(namespace).Create(ctx, cm, metav1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable feature"}) + return + } + } else if err != nil { + log.Printf("Failed to get feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable feature"}) + return + } + + // Set override to false + if cm.Data == nil { + cm.Data = map[string]string{} + } + cm.Data[flagName] = "false" + + _, err = K8sClient.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update feature flag overrides ConfigMap in %s: %v", namespace, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable feature"}) + return + } + + log.Printf("Feature flag disabled: %s in workspace %s", flagName, namespace) + c.JSON(http.StatusOK, gin.H{ + "message": "Feature disabled", + "flag": flagName, + "enabled": false, + "source": "workspace-override", + }) +} + +// unleashAdminRequest makes an authenticated request to the Unleash Admin API +func unleashAdminRequest(method, url string, body io.Reader) (*http.Response, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", getUnleashAdminToken()) + req.Header.Set("Content-Type", "application/json") + return client.Do(req) +} + +func getUnleashAdminURL() string { + if unleashAdminURL == "" { + unleashAdminURL = os.Getenv("UNLEASH_ADMIN_URL") + } + return unleashAdminURL +} + +func getUnleashAdminToken() string { + if unleashAdminToken == "" { + unleashAdminToken = os.Getenv("UNLEASH_ADMIN_TOKEN") + } + return unleashAdminToken +} + +func getUnleashProject() string { + if unleashProject == "" { + unleashProject = os.Getenv("UNLEASH_PROJECT") + } + if unleashProject == "" { + return "default" + } + return unleashProject +} + +func getUnleashEnv() string { + if unleashEnv == "" { + unleashEnv = os.Getenv("UNLEASH_ENVIRONMENT") + } + if unleashEnv == "" { + return "development" + } + return unleashEnv +} diff --git a/components/backend/handlers/featureflags_admin_test.go b/components/backend/handlers/featureflags_admin_test.go new file mode 100644 index 000000000..95035a349 --- /dev/null +++ b/components/backend/handlers/featureflags_admin_test.go @@ -0,0 +1,1080 @@ +//go:build test + +package handlers + +import ( + "context" + "net/http" + + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Feature Flags Admin Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelFeatureFlags), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + fakeClients *test_utils.FakeClientSet + testToken string + ) + + BeforeEach(func() { + logger.Log("Setting up Feature Flags Admin Handler test") + + // Use centralized K8s test setup with fake cluster + k8sUtils = test_utils.NewK8sTestUtils(false, "test-project") + SetupHandlerDependencies(k8sUtils) + + // Create fake clients that match the K8s utils setup + fakeClients = &test_utils.FakeClientSet{ + K8sClient: k8sUtils.K8sClient, + DynamicClient: k8sUtils.DynamicClient, + } + + httpUtils = test_utils.NewHTTPTestUtils() + + // Create namespace + role and mint a valid test token for this suite + ctx := context.Background() + _, err := k8sUtils.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "test-project"}, + }, metav1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + _, err = k8sUtils.CreateTestRole(ctx, "test-project", "test-full-access-role", []string{"get", "list", "create", "update", "delete", "patch"}, "*", "") + Expect(err).NotTo(HaveOccurred()) + + token, _, err := httpUtils.SetValidTestToken( + k8sUtils, + "test-project", + []string{"get", "list", "create", "update", "delete", "patch"}, + "*", + "", + "test-full-access-role", + ) + Expect(err).NotTo(HaveOccurred()) + testToken = token + }) + + AfterEach(func() { + // Clean up created namespace (best-effort) + if k8sUtils != nil { + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(context.Background(), "test-project", metav1.DeleteOptions{}) + } + }) + + Context("Authentication", func() { + Describe("ListFeatureFlags", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + // Don't set auth header + + // Act + ListFeatureFlags(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("ListFeatureFlags correctly requires authentication") + }) + }) + + Describe("EnableFeatureFlag", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/enable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + // Don't set auth header + + // Act + EnableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("EnableFeatureFlag correctly requires authentication") + }) + }) + + Describe("DisableFeatureFlag", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/disable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + // Don't set auth header + + // Act + DisableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("DisableFeatureFlag correctly requires authentication") + }) + }) + + Describe("DeleteFeatureFlagOverride", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("DELETE", "/api/projects/test-project/feature-flags/my-flag/override", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + // Don't set auth header + + // Act + DeleteFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("DeleteFeatureFlagOverride correctly requires authentication") + }) + }) + + Describe("EvaluateFeatureFlag", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/my-flag", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + // Don't set auth header + + // Act + EvaluateFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("EvaluateFeatureFlag correctly requires authentication") + }) + }) + + Describe("GetFeatureFlag", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/my-flag", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + // Don't set auth header + + // Act + GetFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("GetFeatureFlag correctly requires authentication") + }) + }) + + Describe("SetFeatureFlagOverride", func() { + It("Should require authentication", func() { + // Arrange + restore := WithAuthCheckEnabled() + defer restore() + + requestBody := map[string]interface{}{ + "enabled": true, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/feature-flags/my-flag/override", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + // Don't set auth header + + // Act + SetFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + httpUtils.AssertErrorMessage("User token required") + + logger.Log("SetFeatureFlagOverride correctly requires authentication") + }) + }) + }) + + Context("ConfigMap Operations", func() { + Describe("EnableFeatureFlag", func() { + It("Should create ConfigMap when none exists and enable flag", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/enable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EnableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["message"]).To(Equal("Feature enabled")) + Expect(response["flag"]).To(Equal("my-flag")) + Expect(response["enabled"]).To(Equal(true)) + Expect(response["source"]).To(Equal("workspace-override")) + + // Verify ConfigMap was created + ctx := context.Background() + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("true")) + Expect(cm.Labels["app.kubernetes.io/managed-by"]).To(Equal("ambient-code")) + Expect(cm.Labels["app.kubernetes.io/component"]).To(Equal("feature-flags")) + + logger.Log("Successfully created ConfigMap and enabled flag") + }) + + It("Should update existing ConfigMap when enabling flag", func() { + // Arrange - create existing ConfigMap + ctx := context.Background() + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "other-flag": "true", + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, existingCM, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/enable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EnableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + // Verify both flags exist in ConfigMap + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("true")) + Expect(cm.Data["other-flag"]).To(Equal("true")) + + logger.Log("Successfully updated existing ConfigMap") + }) + + It("Should require flag name parameter", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags//enable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: ""}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EnableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Flag name is required") + + logger.Log("Correctly validated flag name requirement") + }) + }) + + Describe("DisableFeatureFlag", func() { + It("Should create ConfigMap when none exists and disable flag", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/disable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DisableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["message"]).To(Equal("Feature disabled")) + Expect(response["flag"]).To(Equal("my-flag")) + Expect(response["enabled"]).To(Equal(false)) + Expect(response["source"]).To(Equal("workspace-override")) + + // Verify ConfigMap was created with flag disabled + ctx := context.Background() + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("false")) + + logger.Log("Successfully created ConfigMap and disabled flag") + }) + + It("Should update existing ConfigMap when disabling flag", func() { + // Arrange - create existing ConfigMap with flag enabled + ctx := context.Background() + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "my-flag": "true", + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, existingCM, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/disable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DisableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + // Verify flag is now disabled + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("false")) + + logger.Log("Successfully updated existing ConfigMap to disable flag") + }) + + It("Should require flag name parameter", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags//disable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: ""}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DisableFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Flag name is required") + + logger.Log("Correctly validated flag name requirement") + }) + }) + + Describe("DeleteFeatureFlagOverride", func() { + It("Should return success when no ConfigMap exists", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("DELETE", "/api/projects/test-project/feature-flags/my-flag/override", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DeleteFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["message"]).To(Equal("No override to remove")) + Expect(response["flag"]).To(Equal("my-flag")) + + logger.Log("Correctly handled missing ConfigMap") + }) + + It("Should remove flag from ConfigMap", func() { + // Arrange - create ConfigMap with flag + ctx := context.Background() + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "my-flag": "true", + "other-flag": "false", + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, existingCM, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("DELETE", "/api/projects/test-project/feature-flags/my-flag/override", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DeleteFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["message"]).To(Equal("Override removed")) + Expect(response["flag"]).To(Equal("my-flag")) + Expect(response["source"]).To(Equal("unleash")) + + // Verify flag was removed but other flags remain + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data).NotTo(HaveKey("my-flag")) + Expect(cm.Data["other-flag"]).To(Equal("false")) + + logger.Log("Successfully removed flag override from ConfigMap") + }) + + It("Should require flag name parameter", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("DELETE", "/api/projects/test-project/feature-flags//override", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: ""}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DeleteFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Flag name is required") + + logger.Log("Correctly validated flag name requirement") + }) + }) + + Describe("SetFeatureFlagOverride", func() { + It("Should create ConfigMap and set override when enabling", func() { + // Arrange + requestBody := map[string]interface{}{ + "enabled": true, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/feature-flags/my-flag/override", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + SetFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["message"]).To(Equal("Override set")) + Expect(response["flag"]).To(Equal("my-flag")) + Expect(response["enabled"]).To(Equal(true)) + + // Verify ConfigMap was created + ctx := context.Background() + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("true")) + + logger.Log("Successfully set override to enable") + }) + + It("Should update ConfigMap when disabling via override", func() { + // Arrange - create existing ConfigMap with flag enabled + ctx := context.Background() + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "my-flag": "true", + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, existingCM, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + requestBody := map[string]interface{}{ + "enabled": false, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/feature-flags/my-flag/override", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + SetFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["enabled"]).To(Equal(false)) + + // Verify flag is now disabled + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("false")) + + logger.Log("Successfully set override to disable") + }) + + It("Should require valid JSON body", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/feature-flags/my-flag/override", "invalid-json") + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + SetFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Invalid request body") + + logger.Log("Correctly rejected invalid JSON body") + }) + + It("Should require flag name parameter", func() { + // Arrange + requestBody := map[string]interface{}{ + "enabled": true, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/feature-flags//override", requestBody) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: ""}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + SetFeatureFlagOverride(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Flag name is required") + + logger.Log("Correctly validated flag name requirement") + }) + }) + }) + + Context("Unleash API Handling", func() { + Describe("ListFeatureFlags", func() { + It("Should return 503 when Unleash is not configured", func() { + // Arrange - ensure no Unleash config exists (default state) + // Note: In unit tests, Unleash env vars are not set + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + ListFeatureFlags(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusServiceUnavailable) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["error"]).To(Equal("Unleash Admin API not configured")) + + logger.Log("Correctly returned 503 when Unleash not configured") + }) + + It("Should return workspace overrides when Unleash not configured but overrides exist", func() { + // Arrange - create ConfigMap with overrides + ctx := context.Background() + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "my-flag": "true", + "another-flag": "false", + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, cm, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + ListFeatureFlags(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("features")) + + features := response["features"].([]interface{}) + Expect(features).To(HaveLen(2)) + + // Check that features are returned with correct source + flagNames := make(map[string]bool) + for _, f := range features { + feature := f.(map[string]interface{}) + flagNames[feature["name"].(string)] = feature["enabled"].(bool) + Expect(feature["source"]).To(Equal("workspace-override")) + } + Expect(flagNames["my-flag"]).To(BeTrue()) + Expect(flagNames["another-flag"]).To(BeFalse()) + + logger.Log("Correctly returned workspace overrides when Unleash not configured") + }) + }) + + Describe("GetFeatureFlag", func() { + It("Should return 503 when Unleash is not configured", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/my-flag", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + GetFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusServiceUnavailable) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["error"]).To(Equal("Unleash Admin API not configured")) + + logger.Log("Correctly returned 503 when Unleash not configured") + }) + + It("Should require flag name parameter", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: ""}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + GetFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Flag name is required") + + logger.Log("Correctly validated flag name requirement") + }) + }) + + Describe("EvaluateFeatureFlag", func() { + It("Should return workspace override when present", func() { + // Arrange - create ConfigMap with override + ctx := context.Background() + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "my-flag": "true", + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, cm, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/my-flag", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EvaluateFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["flag"]).To(Equal("my-flag")) + Expect(response["enabled"]).To(Equal(true)) + Expect(response["source"]).To(Equal("workspace-override")) + + logger.Log("Correctly evaluated flag with workspace override") + }) + + It("Should return default disabled when no override and Unleash not configured", func() { + // Arrange - no ConfigMap, no Unleash + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/my-flag", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EvaluateFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["flag"]).To(Equal("my-flag")) + Expect(response["enabled"]).To(Equal(false)) + // Source is "unleash" because we use the Client SDK (which returns false when not configured) + Expect(response["source"]).To(Equal("unleash")) + + logger.Log("Correctly returned disabled from Unleash SDK when nothing configured") + }) + + It("Should require flag name parameter", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: ""}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EvaluateFeatureFlag(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("Flag name is required") + + logger.Log("Correctly validated flag name requirement") + }) + }) + }) + + Context("Override Precedence", func() { + Describe("Workspace override takes precedence", func() { + It("Should respect workspace override over Unleash default in evaluation", func() { + // Arrange - create workspace override + ctx := context.Background() + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + Data: map[string]string{ + "feature-a": "true", // Override to enabled + "feature-b": "false", // Override to disabled + }, + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, cm, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Test feature-a evaluation + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/feature-a", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "feature-a"}, + } + httpUtils.SetAuthHeader(testToken) + + EvaluateFeatureFlag(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var responseA map[string]interface{} + httpUtils.GetResponseJSON(&responseA) + Expect(responseA["enabled"]).To(Equal(true)) + Expect(responseA["source"]).To(Equal("workspace-override")) + + // Test feature-b evaluation + httpUtils = test_utils.NewHTTPTestUtils() // Reset + ginCtx = httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/feature-b", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "feature-b"}, + } + httpUtils.SetAuthHeader(testToken) + + EvaluateFeatureFlag(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var responseB map[string]interface{} + httpUtils.GetResponseJSON(&responseB) + Expect(responseB["enabled"]).To(Equal(false)) + Expect(responseB["source"]).To(Equal("workspace-override")) + + // Test feature-c (no override) - should use default + httpUtils = test_utils.NewHTTPTestUtils() // Reset + ginCtx = httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/feature-flags/evaluate/feature-c", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "feature-c"}, + } + httpUtils.SetAuthHeader(testToken) + + EvaluateFeatureFlag(ginCtx) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var responseC map[string]interface{} + httpUtils.GetResponseJSON(&responseC) + Expect(responseC["enabled"]).To(Equal(false)) + // Source is "unleash" because we use the Client SDK (returns false when not configured) + Expect(responseC["source"]).To(Equal("unleash")) + + logger.Log("Successfully verified override precedence logic") + }) + }) + }) + + Context("Error Handling", func() { + It("Should handle empty ConfigMap data gracefully in enable", func() { + // Arrange - create ConfigMap with nil Data + ctx := context.Background() + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + // Data is nil + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, existingCM, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/enable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + EnableFeatureFlag(ginCtx) + + // Assert - should handle nil Data gracefully + httpUtils.AssertHTTPStatus(http.StatusOK) + + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("true")) + + logger.Log("Handled nil ConfigMap data gracefully") + }) + + It("Should handle empty ConfigMap data gracefully in disable", func() { + // Arrange - create ConfigMap with nil Data + ctx := context.Background() + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: FeatureFlagOverridesConfigMap, + Namespace: "test-project", + }, + // Data is nil + } + _, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Create( + ctx, existingCM, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/my-flag/disable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "my-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + // Act + DisableFeatureFlag(ginCtx) + + // Assert - should handle nil Data gracefully + httpUtils.AssertHTTPStatus(http.StatusOK) + + cm, err := fakeClients.GetK8sClient().CoreV1().ConfigMaps("test-project").Get( + ctx, FeatureFlagOverridesConfigMap, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data["my-flag"]).To(Equal("false")) + + logger.Log("Handled nil ConfigMap data gracefully") + }) + + It("Should handle concurrent operations", func() { + // Test that multiple requests don't cause issues + for i := 0; i < 3; i++ { + httpUtils = test_utils.NewHTTPTestUtils() // Reset for each test + + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/feature-flags/concurrent-flag/enable", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + {Key: "flagName", Value: "concurrent-flag"}, + } + httpUtils.SetAuthHeader(testToken) + + EnableFeatureFlag(ginCtx) + + // Each request should be handled independently without errors + status := httpUtils.GetResponseRecorder().Code + Expect(status).To(BeElementOf(http.StatusOK, http.StatusInternalServerError)) + + logger.Log("Concurrent request %d handled successfully", i+1) + } + }) + }) + + Context("Tag-Based Filtering", func() { + Describe("isWorkspaceConfigurable", func() { + It("Should return true for flags with scope:workspace tag", func() { + tags := []Tag{ + {Type: "scope", Value: "workspace"}, + } + Expect(isWorkspaceConfigurable(tags)).To(BeTrue()) + + logger.Log("Correctly identified workspace-configurable flag") + }) + + It("Should return true when tag is among multiple tags", func() { + tags := []Tag{ + {Type: "team", Value: "platform"}, + {Type: "scope", Value: "workspace"}, + {Type: "priority", Value: "high"}, + } + Expect(isWorkspaceConfigurable(tags)).To(BeTrue()) + + logger.Log("Correctly identified workspace-configurable flag with multiple tags") + }) + + It("Should return false for flags without workspace tag", func() { + tags := []Tag{ + {Type: "scope", Value: "platform"}, + } + Expect(isWorkspaceConfigurable(tags)).To(BeFalse()) + + logger.Log("Correctly identified platform-only flag") + }) + + It("Should return false for flags with no tags", func() { + tags := []Tag{} + Expect(isWorkspaceConfigurable(tags)).To(BeFalse()) + + logger.Log("Correctly identified flag with no tags as platform-only") + }) + + It("Should return false for nil tags", func() { + var tags []Tag = nil + Expect(isWorkspaceConfigurable(tags)).To(BeFalse()) + + logger.Log("Correctly handled nil tags") + }) + + It("Should return false for different tag type", func() { + tags := []Tag{ + {Type: "category", Value: "workspace"}, + } + Expect(isWorkspaceConfigurable(tags)).To(BeFalse()) + + logger.Log("Correctly rejected wrong tag type") + }) + + It("Should return false for different tag value", func() { + tags := []Tag{ + {Type: "scope", Value: "internal"}, + } + Expect(isWorkspaceConfigurable(tags)).To(BeFalse()) + + logger.Log("Correctly rejected wrong tag value") + }) + }) + }) +}) diff --git a/components/backend/main.go b/components/backend/main.go index 39c3d5c52..811fcd738 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -5,6 +5,7 @@ import ( "log" "os" + "ambient-code-backend/featureflags" "ambient-code-backend/git" "ambient-code-backend/github" "ambient-code-backend/handlers" @@ -91,6 +92,9 @@ func main() { server.InitConfig() + // Optional: Unleash feature flags (when UNLEASH_URL and UNLEASH_CLIENT_KEY are set) + featureflags.Init() + // Initialize git package git.GetProjectSettingsResource = k8s.GetProjectSettingsResource git.GetGitHubInstallation = func(ctx context.Context, userID string) (interface{}, error) { diff --git a/components/backend/routes.go b/components/backend/routes.go index 69f52d096..0dd83825b 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -109,6 +109,15 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/integration-secrets", handlers.ListIntegrationSecrets) projectGroup.PUT("/integration-secrets", handlers.UpdateIntegrationSecrets) + // Feature flags admin endpoints (workspace-scoped with Unleash fallback) + projectGroup.GET("/feature-flags", handlers.ListFeatureFlags) + projectGroup.GET("/feature-flags/evaluate/:flagName", handlers.EvaluateFeatureFlag) + projectGroup.GET("/feature-flags/:flagName", handlers.GetFeatureFlag) + projectGroup.PUT("/feature-flags/:flagName/override", handlers.SetFeatureFlagOverride) + projectGroup.DELETE("/feature-flags/:flagName/override", handlers.DeleteFeatureFlagOverride) + projectGroup.POST("/feature-flags/:flagName/enable", handlers.EnableFeatureFlag) + projectGroup.POST("/feature-flags/:flagName/disable", handlers.DisableFeatureFlag) + // GitLab authentication endpoints (DEPRECATED - moved to cluster-scoped) // Kept for backward compatibility, will be removed in future version projectGroup.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal) diff --git a/components/backend/tests/constants/labels.go b/components/backend/tests/constants/labels.go index 2c48e0f7c..bdc7a5d57 100644 --- a/components/backend/tests/constants/labels.go +++ b/components/backend/tests/constants/labels.go @@ -12,19 +12,20 @@ const ( LabelTypes = "types" // Specific component labels for handlers - LabelRepo = "repo" - LabelRepoSeed = "repo_seed" - LabelSecrets = "secrets" - LabelRepository = "repository" - LabelMiddleware = "middleware" - LabelPermissions = "permissions" - LabelProjects = "projects" - LabelGitHubAuth = "github-auth" - LabelGitLabAuth = "gitlab-auth" - LabelSessions = "sessions" - LabelContent = "content" - LabelDisplayName = "display-name" - LabelHealth = "health" + LabelRepo = "repo" + LabelRepoSeed = "repo_seed" + LabelSecrets = "secrets" + LabelRepository = "repository" + LabelMiddleware = "middleware" + LabelPermissions = "permissions" + LabelProjects = "projects" + LabelGitHubAuth = "github-auth" + LabelGitLabAuth = "gitlab-auth" + LabelSessions = "sessions" + LabelContent = "content" + LabelFeatureFlags = "feature-flags" + LabelDisplayName = "display-name" + LabelHealth = "health" // Specific component labels for other areas LabelOperations = "operations" // for git operations diff --git a/components/frontend/.env.example b/components/frontend/.env.example index 181961845..ab2058603 100644 --- a/components/frontend/.env.example +++ b/components/frontend/.env.example @@ -32,6 +32,16 @@ MAX_UPLOAD_SIZE_IMAGES=3145728 IMAGE_COMPRESSION_TARGET=358400 +# Unleash feature flags (optional) +# When set, the frontend proxy /api/feature-flags will forward to Unleash. +# UNLEASH_URL=https://unleash.example.com +# UNLEASH_CLIENT_KEY=your-frontend-api-token +# UNLEASH_APP_NAME=ambient-code-platform +# NEXT_PUBLIC_UNLEASH_ENV_CONTEXT_FIELD: Environment value sent in SDK context (default: development) +# Note: This does NOT select the Unleash environment - that's determined by the token scope. +# This is only used for strategy constraints that check context.environment. +# NEXT_PUBLIC_UNLEASH_ENV_CONTEXT_FIELD=development + # Langfuse Configuration for User Feedback # These are used by the /api/feedback route to submit user feedback scores # Get your keys from your Langfuse instance: Settings > API Keys diff --git a/components/frontend/README.md b/components/frontend/README.md index a5988e42e..20cbf5a98 100644 --- a/components/frontend/README.md +++ b/components/frontend/README.md @@ -95,6 +95,7 @@ In production, put an OAuth/ingress proxy in front of the app to set these heade - `FEEDBACK_URL` (optional) - URL for the feedback link in the masthead. If not set, the link will not appear. - Optional dev helpers: `OC_USER`, `OC_EMAIL`, `OC_TOKEN`, `ENABLE_OC_WHOAMI=1` +- **Feature flags (Unleash)**: see [docs/feature-flags](../../docs/feature-flags/README.md) for env vars, usage in components, and deployment. You can also put these in a `.env.local` file in this folder: ``` diff --git a/components/frontend/next.config.js b/components/frontend/next.config.js index b6f3dafe1..c5ac90a89 100644 --- a/components/frontend/next.config.js +++ b/components/frontend/next.config.js @@ -3,9 +3,6 @@ const nextConfig = { output: 'standalone', turbopack: { root: __dirname, // Silence "inferred workspace root" warning in monorepo - }, - experimental: { - instrumentationHook: true, } } diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 39dddb186..823a0b605 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -17,11 +17,13 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", + "@unleash/proxy-client-react": "^5.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -40,6 +42,7 @@ "remark-gfm": "^4.0.1", "sharp": "^0.33.0", "tailwind-merge": "^3.3.1", + "unleash-proxy-client": "^3.6.1", "zod": "^4.1.5" }, "devDependencies": { @@ -1869,6 +1872,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -2691,6 +2723,7 @@ "version": "19.2.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2700,7 +2733,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3006,6 +3039,18 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@unleash/proxy-client-react": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@unleash/proxy-client-react/-/proxy-client-react-5.0.1.tgz", + "integrity": "sha512-F/IDo853ghZkGreLWg4fSVSM4NiLg5aZb1Kvr4vG29u5/PB0JLKNgNVdadt+qrlkI1GMzmP7IuFXSnv9A0McRw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "unleash-proxy-client": "^3.7.3" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -3874,6 +3919,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -9091,6 +9137,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9476,6 +9528,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unleash-proxy-client": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/unleash-proxy-client/-/unleash-proxy-client-3.7.8.tgz", + "integrity": "sha512-VX0jDcOporeVb1nh4+HGpEZIwcwHl/HP/7cyZxQq3umffN0hx44Tw1u4nmdopmm9s7Hb2BES0pFCIntr/lh3vQ==", + "license": "Apache-2.0", + "dependencies": { + "tiny-emitter": "^2.1.0", + "uuid": "^9.0.1" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -9573,6 +9635,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/components/frontend/package.json b/components/frontend/package.json index acb9a2ca1..3cb539c71 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -18,11 +18,14 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", + "@unleash/proxy-client-react": "^5.0.1", + "unleash-proxy-client": "^3.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/components/frontend/src/app/api/feature-flags/client/metrics/route.ts b/components/frontend/src/app/api/feature-flags/client/metrics/route.ts new file mode 100644 index 000000000..8090c3109 --- /dev/null +++ b/components/frontend/src/app/api/feature-flags/client/metrics/route.ts @@ -0,0 +1,48 @@ +import { env } from '@/lib/env'; +import { NextRequest } from 'next/server'; + +/** + * POST /api/feature-flags/client/metrics + * Proxies usage metrics from the Unleash SDK to the Unleash server. + * This enables impression data and usage tracking in Unleash. + */ +export async function POST(request: NextRequest) { + const baseUrl = env.UNLEASH_URL?.replace(/\/$/, ''); + const clientKey = env.UNLEASH_CLIENT_KEY; + + // If Unleash isn't configured, just acknowledge the request + if (!baseUrl || !clientKey) { + console.log('[Unleash Metrics] Unleash not configured, ignoring metrics'); + return new Response(null, { status: 202 }); + } + + const url = new URL('/api/frontend/client/metrics', baseUrl); + + try { + const body = await request.json(); + console.log('[Unleash Metrics] Forwarding metrics to:', url.toString()); + console.log('[Unleash Metrics] Payload:', JSON.stringify(body, null, 2)); + + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + Authorization: clientKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + console.error('[Unleash Metrics] Error:', res.status, errorText); + // Still return 202 to not break the client + return new Response(null, { status: 202 }); + } + + console.log('[Unleash Metrics] Success:', res.status); + return new Response(null, { status: 202 }); + } catch (error) { + console.error('[Unleash Metrics] Fetch error:', error); + return new Response(null, { status: 202 }); + } +} diff --git a/components/frontend/src/app/api/feature-flags/client/register/route.ts b/components/frontend/src/app/api/feature-flags/client/register/route.ts new file mode 100644 index 000000000..b8b3ae8bb --- /dev/null +++ b/components/frontend/src/app/api/feature-flags/client/register/route.ts @@ -0,0 +1,43 @@ +import { env } from '@/lib/env'; +import { NextRequest } from 'next/server'; + +/** + * POST /api/feature-flags/client/register + * Proxies client registration from the Unleash SDK to the Unleash server. + * This allows Unleash to track connected clients/applications. + */ +export async function POST(request: NextRequest) { + const baseUrl = env.UNLEASH_URL?.replace(/\/$/, ''); + const clientKey = env.UNLEASH_CLIENT_KEY; + + // If Unleash isn't configured, just acknowledge the request + if (!baseUrl || !clientKey) { + return new Response(null, { status: 202 }); + } + + const url = new URL('/api/frontend/client/register', baseUrl); + + try { + const body = await request.json(); + + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + Authorization: clientKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + console.error('Unleash register proxy error:', res.status, await res.text()); + // Still return 202 to not break the client + return new Response(null, { status: 202 }); + } + + return new Response(null, { status: 202 }); + } catch (error) { + console.error('Unleash register proxy fetch error:', error); + return new Response(null, { status: 202 }); + } +} diff --git a/components/frontend/src/app/api/feature-flags/route.ts b/components/frontend/src/app/api/feature-flags/route.ts new file mode 100644 index 000000000..45c0f9a9d --- /dev/null +++ b/components/frontend/src/app/api/feature-flags/route.ts @@ -0,0 +1,45 @@ +import { env } from '@/lib/env'; +import { NextRequest } from 'next/server'; + +/** + * GET /api/feature-flags + * Proxies to Unleash Frontend API when UNLEASH_URL and UNLEASH_CLIENT_KEY are set. + * Returns empty toggles when Unleash is not configured (SDK still works, all flags off). + * Used by @unleash/proxy-client-react so the client never sees the real Unleash URL or key. + */ +export async function GET(request: NextRequest) { + const baseUrl = env.UNLEASH_URL?.replace(/\/$/, ''); + const clientKey = env.UNLEASH_CLIENT_KEY; + + if (!baseUrl || !clientKey) { + return Response.json({ toggles: [] }); + } + + const url = new URL('/api/frontend', baseUrl); + // Forward query params (e.g. projectId) if needed for strategies + request.nextUrl.searchParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + + try { + const res = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: clientKey, + 'Content-Type': 'application/json', + }, + next: { revalidate: 15 }, + }); + + if (!res.ok) { + console.error('Unleash proxy error:', res.status, await res.text()); + return Response.json({ toggles: [] }); + } + + const data = await res.json(); + return Response.json(data); + } catch (error) { + console.error('Unleash proxy fetch error:', error); + return Response.json({ toggles: [] }); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/disable/route.ts b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/disable/route.ts new file mode 100644 index 000000000..896d89050 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/disable/route.ts @@ -0,0 +1,37 @@ +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +/** + * POST /api/projects/:projectName/feature-flags/:flagName/disable + * Proxies to backend to disable a feature flag in Unleash + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ name: string; flagName: string }> } +) { + try { + const { name: projectName, flagName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}/disable`, + { + method: "POST", + headers, + } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to disable feature flag:", error); + return Response.json( + { error: "Failed to disable feature flag" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/enable/route.ts b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/enable/route.ts new file mode 100644 index 000000000..56e04944a --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/enable/route.ts @@ -0,0 +1,37 @@ +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +/** + * POST /api/projects/:projectName/feature-flags/:flagName/enable + * Proxies to backend to enable a feature flag in Unleash + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ name: string; flagName: string }> } +) { + try { + const { name: projectName, flagName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}/enable`, + { + method: "POST", + headers, + } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to enable feature flag:", error); + return Response.json( + { error: "Failed to enable feature flag" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/override/route.ts b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/override/route.ts new file mode 100644 index 000000000..d974289e9 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/override/route.ts @@ -0,0 +1,74 @@ +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +/** + * PUT /api/projects/:projectName/feature-flags/:flagName/override + * Sets a workspace-scoped override for a feature flag + */ +export async function PUT( + request: Request, + { params }: { params: Promise<{ name: string; flagName: string }> } +) { + try { + const { name: projectName, flagName } = await params; + const headers = await buildForwardHeadersAsync(request); + const body = await request.text(); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}/override`, + { + method: "PUT", + headers, + body, + } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to set feature flag override:", error); + return Response.json( + { error: "Failed to set feature flag override" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/projects/:projectName/feature-flags/:flagName/override + * Removes a workspace-scoped override, reverting to Unleash default + */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ name: string; flagName: string }> } +) { + try { + const { name: projectName, flagName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}/override`, + { + method: "DELETE", + headers, + } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to remove feature flag override:", error); + return Response.json( + { error: "Failed to remove feature flag override" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/route.ts b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/route.ts new file mode 100644 index 000000000..78e5fd8f0 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/feature-flags/[flagName]/route.ts @@ -0,0 +1,34 @@ +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +/** + * GET /api/projects/:projectName/feature-flags/:flagName + * Proxies to backend to get a specific feature flag from Unleash + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string; flagName: string }> } +) { + try { + const { name: projectName, flagName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}`, + { headers } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to fetch feature flag:", error); + return Response.json( + { error: "Failed to fetch feature flag" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/feature-flags/evaluate/[flagName]/route.ts b/components/frontend/src/app/api/projects/[name]/feature-flags/evaluate/[flagName]/route.ts new file mode 100644 index 000000000..b51d33cdb --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/feature-flags/evaluate/[flagName]/route.ts @@ -0,0 +1,34 @@ +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +/** + * GET /api/projects/:projectName/feature-flags/evaluate/:flagName + * Evaluates a feature flag for a workspace (ConfigMap override > Unleash default) + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string; flagName: string }> } +) { + try { + const { name: projectName, flagName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/evaluate/${encodeURIComponent(flagName)}`, + { headers } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to evaluate feature flag:", error); + return Response.json( + { error: "Failed to evaluate feature flag" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/feature-flags/route.ts b/components/frontend/src/app/api/projects/[name]/feature-flags/route.ts new file mode 100644 index 000000000..fe5b16276 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/feature-flags/route.ts @@ -0,0 +1,34 @@ +import { BACKEND_URL } from "@/lib/config"; +import { buildForwardHeadersAsync } from "@/lib/auth"; + +/** + * GET /api/projects/:projectName/feature-flags + * Proxies to backend to list all feature flags from Unleash + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name: projectName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags`, + { headers } + ); + + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to fetch feature flags:", error); + return Response.json( + { error: "Failed to fetch feature flags" }, + { status: 500 } + ); + } +} diff --git a/components/frontend/src/app/layout.tsx b/components/frontend/src/app/layout.tsx index 957df8968..6b17b82bb 100644 --- a/components/frontend/src/app/layout.tsx +++ b/components/frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Navigation } from "@/components/navigation"; import { QueryProvider } from "@/components/providers/query-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { SyntaxThemeProvider } from "@/components/providers/syntax-theme-provider"; +import { FeatureFlagProvider } from "@/components/providers/feature-flag-provider"; import { Toaster } from "@/components/ui/toaster"; import { env } from "@/lib/env"; @@ -41,11 +42,13 @@ export default function RootLayout({ disableTransitionOnChange > - - -
{children}
- -
+ + + +
{children}
+ +
+
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 9ef2be160..e7b29aa3c 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -95,6 +95,7 @@ import { useWorkspaceList, } from "@/services/queries/use-workspace"; import { successToast, errorToast } from "@/hooks/use-toast"; +import { useWorkspaceFlag } from "@/services/queries/use-feature-flags-admin"; import { useOOTBWorkflows, useWorkflowMetadata, @@ -209,10 +210,13 @@ export default function ProjectSessionDetailPage({ // Check integration status const { data: integrationsStatus } = useIntegrationsStatus(); const githubConfigured = integrationsStatus?.github?.active != null; - + // Get current user for feedback context const { data: currentUser } = useCurrentUser(); + // Feature flags - workspace-scoped (see constitution Principle XI for naming convention) + const { enabled: fileExplorerEnabled } = useWorkspaceFlag(projectName, "frontend.file-explorer.enabled"); + // Extract phase for sidebar state management const phase = session?.status?.phase || "Pending"; @@ -1647,7 +1651,8 @@ export default function ProjectSessionDetailPage({ - {/* File Explorer */} + {/* File Explorer (feature flagged) */} + {fileExplorerEnabled && ( + )} @@ -2104,7 +2110,8 @@ export default function ProjectSessionDetailPage({ /> - {/* File Explorer */} + {/* File Explorer (feature flagged) */} + {fileExplorerEnabled && ( + )} diff --git a/components/frontend/src/components/providers/feature-flag-provider.tsx b/components/frontend/src/components/providers/feature-flag-provider.tsx new file mode 100644 index 000000000..566ff9aa0 --- /dev/null +++ b/components/frontend/src/components/providers/feature-flag-provider.tsx @@ -0,0 +1,55 @@ +'use client'; + +/** + * Unleash feature flag provider. + * Wraps the app so components can use useFlag() and useVariant() from @unleash/proxy-client-react. + * Flags are fetched from our Next.js proxy /api/feature-flags (which forwards to Unleash when configured). + * When Unleash is not configured, the proxy returns empty toggles and all flags are false. + */ + +import { FlagProvider } from '@unleash/proxy-client-react'; +import { useState, useEffect, type ReactNode } from 'react'; + +const UNLEASH_APP_NAME = 'ambient-code-platform'; + +// Get client key from environment or use placeholder +// The placeholder key is used when Unleash is not configured - the SDK requires a non-empty string +const UNLEASH_CLIENT_KEY = process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY || 'placeholder-not-configured'; + +type FeatureFlagProviderProps = { + children: ReactNode; +}; + +export function FeatureFlagProvider({ children }: FeatureFlagProviderProps) { + const [mounted, setMounted] = useState(false); + const [baseUrl, setBaseUrl] = useState(null); + + // Only run on client side after mount to get the correct origin + useEffect(() => { + if (typeof window !== 'undefined') { + // Use environment variable URL if set, otherwise construct from window.location + const unleashUrl = process.env.NEXT_PUBLIC_UNLEASH_URL || `${window.location.origin}/api/feature-flags`; + setBaseUrl(unleashUrl); + setMounted(true); + } + }, []); + + // During SSR or before mount, just render children without the provider + // This avoids the "Invalid URL" error during static generation + if (!mounted || !baseUrl) { + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/components/frontend/src/components/ui/switch.tsx b/components/frontend/src/components/ui/switch.tsx new file mode 100644 index 000000000..82188ef90 --- /dev/null +++ b/components/frontend/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/components/frontend/src/components/workspace-sections/feature-flags-section.tsx b/components/frontend/src/components/workspace-sections/feature-flags-section.tsx new file mode 100644 index 000000000..c4ddfbc5a --- /dev/null +++ b/components/frontend/src/components/workspace-sections/feature-flags-section.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useState } from "react"; +import { Flag, RefreshCw, Loader2, Info, AlertTriangle, RotateCcw } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Switch } from "@/components/ui/switch"; +import { EmptyState } from "@/components/empty-state"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { + useFeatureFlags, + useToggleFeatureFlag, + useRemoveFeatureFlagOverride, +} from "@/services/queries/use-feature-flags-admin"; +import { successToast, errorToast } from "@/hooks/use-toast"; + +type FeatureFlagsSectionProps = { + projectName: string; +}; + +export function FeatureFlagsSection({ projectName }: FeatureFlagsSectionProps) { + const { + data: flags = [], + isLoading, + isError, + error, + refetch, + } = useFeatureFlags(projectName); + const toggleMutation = useToggleFeatureFlag(); + const removeOverrideMutation = useRemoveFeatureFlagOverride(); + + const [pendingFlags, setPendingFlags] = useState>(new Set()); + + const handleToggle = (flagName: string, currentEnabled: boolean) => { + const newEnabled = !currentEnabled; + setPendingFlags((prev) => new Set(prev).add(flagName)); + + toggleMutation.mutate( + { projectName, flagName, enable: newEnabled }, + { + onSuccess: () => { + successToast( + `Feature "${flagName}" ${newEnabled ? "enabled" : "disabled"} for this workspace` + ); + setPendingFlags((prev) => { + const next = new Set(prev); + next.delete(flagName); + return next; + }); + }, + onError: (err) => { + errorToast( + err instanceof Error ? err.message : "Failed to update feature flag" + ); + setPendingFlags((prev) => { + const next = new Set(prev); + next.delete(flagName); + return next; + }); + }, + } + ); + }; + + const handleResetToDefault = (flagName: string) => { + setPendingFlags((prev) => new Set(prev).add(flagName)); + + removeOverrideMutation.mutate( + { projectName, flagName }, + { + onSuccess: () => { + successToast(`Feature "${flagName}" reset to platform default`); + setPendingFlags((prev) => { + const next = new Set(prev); + next.delete(flagName); + return next; + }); + }, + onError: (err) => { + errorToast( + err instanceof Error ? err.message : "Failed to reset feature flag" + ); + setPendingFlags((prev) => { + const next = new Set(prev); + next.delete(flagName); + return next; + }); + }, + } + ); + }; + + const getTypeBadge = (type?: string) => { + switch (type) { + case "experiment": + return Experiment; + case "operational": + return Operational; + case "kill-switch": + return Kill Switch; + case "permission": + return Permission; + default: + return Release; + } + }; + + const getSourceBadge = (source?: string, hasOverride?: boolean) => { + if (source === "workspace-override" || hasOverride) { + return ( + + Workspace Override + + ); + } + return ( + + Platform Default + + ); + }; + + // Check if Unleash is not configured (service unavailable error) + const isNotConfigured = + isError && + error instanceof Error && + error.message.includes("not configured"); + + return ( + + +
+
+ Feature Flags + + Manage feature toggles for this workspace. Changes only affect this workspace. + +
+
+ +
+
+
+ + {isNotConfigured ? ( + + + Unleash Not Configured + + Feature flag management requires Unleash Admin API configuration. + Set the UNLEASH_ADMIN_URL and{" "} + UNLEASH_ADMIN_TOKEN environment variables on the + backend to enable this feature. + + + ) : ( + <> + + + Workspace-Scoped Feature Flags + + Toggle switches set workspace-specific overrides. Use the reset button + to revert to the platform default (controlled by Unleash). + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( + + + Error Loading Feature Flags + + {error instanceof Error + ? error.message + : "Failed to load feature flags"} + + + ) : flags.length === 0 ? ( + + ) : ( + + + + Enabled + Feature + Source + Type + + Description + + Actions + + + + {flags.map((flag) => { + const isPending = pendingFlags.has(flag.name); + const hasOverride = flag.overrideEnabled !== undefined && flag.overrideEnabled !== null; + + return ( + + + {isPending ? ( + + ) : ( + + handleToggle(flag.name, flag.enabled) + } + aria-label={`Toggle ${flag.name}`} + /> + )} + + +
+ {flag.name} +
+ {flag.stale && ( + + Stale + + )} +
+ + {getSourceBadge(flag.source, hasOverride)} + + + {getTypeBadge(flag.type)} + + + {flag.description || "—"} + + + {hasOverride && ( + + + + + + + Reset to platform default + + + + )} + +
+ ); + })} +
+
+ )} + + )} +
+
+ ); +} diff --git a/components/frontend/src/components/workspace-sections/feature-flags-settings.tsx b/components/frontend/src/components/workspace-sections/feature-flags-settings.tsx new file mode 100644 index 000000000..b70be701b --- /dev/null +++ b/components/frontend/src/components/workspace-sections/feature-flags-settings.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Flag, RefreshCw, Loader2, Info, AlertTriangle, Save, RotateCcw } from "lucide-react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { EmptyState } from "@/components/empty-state"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { useFeatureFlags } from "@/services/queries/use-feature-flags-admin"; +import * as featureFlagsApi from "@/services/api/feature-flags-admin"; +import { successToast, errorToast } from "@/hooks/use-toast"; +import { useQueryClient } from "@tanstack/react-query"; + +type FeatureFlagsSettingsProps = { + projectName: string; +}; + +type LocalFlagState = { + enabled: boolean; + changed: boolean; // true if user toggled this flag + markedForReset: boolean; // true if user wants to remove the workspace override +}; + +export function FeatureFlagsSettings({ projectName }: FeatureFlagsSettingsProps) { + const queryClient = useQueryClient(); + const { + data: flags = [], + isLoading, + isError, + error, + refetch, + } = useFeatureFlags(projectName); + + // Local state to track pending changes + const [localState, setLocalState] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + + // Stable serialization of flags to detect actual data changes + const flagsKey = useMemo(() => { + return flags.map(f => `${f.name}:${f.enabled}:${f.overrideEnabled}`).join('|'); + }, [flags]); + + // Reset local state when flags data changes + useEffect(() => { + // Initialize local state from server state + const initial: Record = {}; + for (const flag of flags) { + initial[flag.name] = { + enabled: flag.enabled, + changed: false, + markedForReset: false, + }; + } + setLocalState(initial); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flagsKey]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + return Object.values(localState).some((s) => s.changed || s.markedForReset); + }, [localState]); + + // Get the count of changed flags + const changedCount = useMemo(() => { + return Object.values(localState).filter((s) => s.changed || s.markedForReset).length; + }, [localState]); + + const handleToggle = (flagName: string) => { + setLocalState((prev) => { + const current = prev[flagName]; + if (!current) return prev; + + // If marked for reset, clear that first + if (current.markedForReset) { + return { + ...prev, + [flagName]: { + ...current, + markedForReset: false, + }, + }; + } + + // Find the original server state + const serverFlag = flags.find((f) => f.name === flagName); + const serverEnabled = serverFlag?.enabled ?? false; + + const newEnabled = !current.enabled; + // Mark as changed only if different from server state + const isChanged = newEnabled !== serverEnabled; + + return { + ...prev, + [flagName]: { + enabled: newEnabled, + changed: isChanged, + markedForReset: false, + }, + }; + }); + }; + + const handleMarkForReset = (flagName: string) => { + setLocalState((prev) => { + const current = prev[flagName]; + if (!current) return prev; + + return { + ...prev, + [flagName]: { + ...current, + markedForReset: true, + changed: false, // Clear toggle change since we're resetting + }, + }; + }); + }; + + const handleUndoReset = (flagName: string) => { + setLocalState((prev) => { + const current = prev[flagName]; + if (!current) return prev; + + // Restore to server state + const serverFlag = flags.find((f) => f.name === flagName); + + return { + ...prev, + [flagName]: { + enabled: serverFlag?.enabled ?? false, + changed: false, + markedForReset: false, + }, + }; + }); + }; + + const handleSave = async () => { + const changedFlags = Object.entries(localState).filter(([, s]) => s.changed); + const resetFlags = Object.entries(localState).filter(([, s]) => s.markedForReset); + + if (changedFlags.length === 0 && resetFlags.length === 0) { + return; + } + + setIsSaving(true); + + try { + const promises: Promise[] = []; + + // Save all changed flags (set overrides) + for (const [flagName, state] of changedFlags) { + if (state.enabled) { + promises.push(featureFlagsApi.enableFeatureFlag(projectName, flagName)); + } else { + promises.push(featureFlagsApi.disableFeatureFlag(projectName, flagName)); + } + } + + // Reset flags (remove overrides) + for (const [flagName] of resetFlags) { + promises.push(featureFlagsApi.removeFeatureFlagOverride(projectName, flagName)); + } + + await Promise.all(promises); + + const totalChanges = changedFlags.length + resetFlags.length; + successToast(`${totalChanges} feature flag${totalChanges > 1 ? "s" : ""} updated`); + + // Invalidate queries to refetch fresh data + queryClient.invalidateQueries({ queryKey: ["feature-flags", "list", projectName] }); + } catch (err) { + errorToast(err instanceof Error ? err.message : "Failed to save feature flags"); + } finally { + setIsSaving(false); + } + }; + + const handleDiscard = () => { + // Reset to server state + const initial: Record = {}; + for (const flag of flags) { + initial[flag.name] = { + enabled: flag.enabled, + changed: false, + markedForReset: false, + }; + } + setLocalState(initial); + }; + + const getTypeBadge = (type?: string) => { + switch (type) { + case "experiment": + return Experiment; + case "operational": + return Operational; + case "kill-switch": + return Kill Switch; + case "permission": + return Permission; + default: + return Release; + } + }; + + const getSourceBadge = (source?: string, hasOverride?: boolean, markedForReset?: boolean) => { + if (markedForReset) { + return ( + + Will Reset + + ); + } + if (source === "workspace-override" || hasOverride) { + return ( + + Workspace Override + + ); + } + return ( + + Platform Default + + ); + }; + + // Check if Unleash is not configured (service unavailable error) + const isNotConfigured = + isError && + error instanceof Error && + error.message.includes("not configured"); + + return ( + + +
+
+ + + Feature Flags + + + Enable or disable features for this workspace. Changes are saved when you click Save. + +
+ +
+
+ + + {isNotConfigured ? ( + + + Feature Flags Not Available + + Feature flag management requires Unleash to be configured. + Contact your platform administrator to enable this feature. + + + ) : ( + <> + + + Workspace-Scoped Feature Flags + + Toggle switches to enable or disable features for this workspace only. + Use the reset button to revert to the platform default. + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( + + + Error Loading Feature Flags + + {error instanceof Error + ? error.message + : "Failed to load feature flags"} + + + ) : flags.length === 0 ? ( + + ) : ( + <> + + + + Enabled + Feature + Description + Source + Type + Actions + + + + {flags.map((flag) => { + const state = localState[flag.name]; + const isEnabled = state?.enabled ?? flag.enabled; + const isChanged = state?.changed ?? false; + const isMarkedForReset = state?.markedForReset ?? false; + const hasOverride = flag.overrideEnabled !== undefined && flag.overrideEnabled !== null; + const hasUnsavedChange = isChanged || isMarkedForReset; + + return ( + + + handleToggle(flag.name)} + disabled={isMarkedForReset} + aria-label={`Toggle ${flag.name}`} + /> + + +
+ + {flag.name} + + {isChanged && ( + + Unsaved + + )} + {isMarkedForReset && ( + + Will Reset + + )} +
+ {flag.stale && ( + + Stale + + )} +
+ +
+ {flag.description || "—"} +
+
+ + {getSourceBadge(flag.source, hasOverride, isMarkedForReset)} + + + {getTypeBadge(flag.type)} + + + {isMarkedForReset ? ( + + + + + + + Undo reset + + + + ) : hasOverride ? ( + + + + + + + Reset to platform default + + + + ) : null} + +
+ ); + })} +
+
+ + {/* Save/Discard buttons */} +
+
+ + {hasChanges && ( + + )} +
+
+ {hasChanges ? ( + + {changedCount} unsaved change{changedCount > 1 ? "s" : ""} + + ) : ( + "No unsaved changes" + )} +
+
+ + )} + + )} +
+
+ ); +} diff --git a/components/frontend/src/components/workspace-sections/index.ts b/components/frontend/src/components/workspace-sections/index.ts index 0ca5dd8cc..e50dd69b0 100644 --- a/components/frontend/src/components/workspace-sections/index.ts +++ b/components/frontend/src/components/workspace-sections/index.ts @@ -1,4 +1,5 @@ export { SessionsSection } from './sessions-section'; export { SharingSection } from './sharing-section'; export { SettingsSection } from './settings-section'; +export { FeatureFlagsSection } from './feature-flags-section'; diff --git a/components/frontend/src/components/workspace-sections/settings-section.tsx b/components/frontend/src/components/workspace-sections/settings-section.tsx index e5ecab026..133ef806e 100644 --- a/components/frontend/src/components/workspace-sections/settings-section.tsx +++ b/components/frontend/src/components/workspace-sections/settings-section.tsx @@ -16,6 +16,7 @@ import { useProject, useUpdateProject } from "@/services/queries/use-projects"; import { useSecretsValues, useUpdateSecrets, useIntegrationSecrets, useUpdateIntegrationSecrets } from "@/services/queries/use-secrets"; import { useClusterInfo } from "@/hooks/use-cluster-info"; import { useMemo } from "react"; +import { FeatureFlagsSettings } from "./feature-flags-settings"; type SettingsSectionProps = { projectName: string; @@ -542,6 +543,9 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { + + {/* Feature Flags Section */} + ); } diff --git a/components/frontend/src/lib/env.ts b/components/frontend/src/lib/env.ts index be1b8a018..209e84deb 100644 --- a/components/frontend/src/lib/env.ts +++ b/components/frontend/src/lib/env.ts @@ -26,6 +26,11 @@ type EnvConfig = { OC_USER?: string; OC_EMAIL?: string; ENABLE_OC_WHOAMI?: boolean; + + // Unleash feature flags (server-side only, optional) + UNLEASH_URL?: string; + UNLEASH_CLIENT_KEY?: string; + UNLEASH_APP_NAME?: string; }; function getEnv(key: string, defaultValue?: string): string { @@ -66,6 +71,9 @@ export const env: EnvConfig = { OC_USER: getOptionalEnv('OC_USER'), OC_EMAIL: getOptionalEnv('OC_EMAIL'), ENABLE_OC_WHOAMI: getBooleanEnv('ENABLE_OC_WHOAMI', false), + UNLEASH_URL: getOptionalEnv('UNLEASH_URL'), + UNLEASH_CLIENT_KEY: getOptionalEnv('UNLEASH_CLIENT_KEY'), + UNLEASH_APP_NAME: getOptionalEnv('UNLEASH_APP_NAME') || 'ambient-code-platform', }; /** diff --git a/components/frontend/src/lib/feature-flags.ts b/components/frontend/src/lib/feature-flags.ts new file mode 100644 index 000000000..066d818fe --- /dev/null +++ b/components/frontend/src/lib/feature-flags.ts @@ -0,0 +1,18 @@ +/** + * Feature flags via Unleash (optional). + * Re-export SDK hooks so the app uses a single import path. + * + * Usage: + * import { useFlag, useVariant, useFlagsStatus } from '@/lib/feature-flags'; + * + * const enabled = useFlag('my-feature-name'); + * const variant = useVariant('experiment-name'); + * const { flagsReady, flagsError } = useFlagsStatus(); + */ + +export { + useFlag, + useVariant, + useFlagsStatus, + useUnleashContext, +} from '@unleash/proxy-client-react'; diff --git a/components/frontend/src/services/api/feature-flags-admin.ts b/components/frontend/src/services/api/feature-flags-admin.ts new file mode 100644 index 000000000..4900b5390 --- /dev/null +++ b/components/frontend/src/services/api/feature-flags-admin.ts @@ -0,0 +1,130 @@ +/** + * Feature Flags Admin API + * Workspace-scoped feature flag management with Unleash fallback + */ + +import { apiClient } from './client'; + +export type Tag = { + type: string; + value: string; +}; + +export type EnvState = { + name: string; + enabled: boolean; +}; + +export type FeatureToggle = { + name: string; + description?: string; + enabled: boolean; + type?: string; + stale?: boolean; + tags?: Tag[]; + environments?: EnvState[]; + source: 'workspace-override' | 'unleash' | 'default'; + overrideEnabled?: boolean | null; // null if no override, true/false if overridden +}; + +type FeatureToggleListResponse = { + features: FeatureToggle[]; +}; + +type ToggleResponse = { + message: string; + flag: string; + enabled: boolean; + source: string; +}; + +type EvaluateResponse = { + flag: string; + enabled: boolean; + source: 'workspace-override' | 'unleash' | 'default'; + error?: string; +}; + +/** + * Get all feature flags for a project with workspace override status + */ +export async function getFeatureFlags(projectName: string): Promise { + const response = await apiClient.get( + `/projects/${projectName}/feature-flags` + ); + return response.features || []; +} + +/** + * Evaluate a feature flag for a workspace (ConfigMap override > Unleash default) + */ +export async function evaluateFeatureFlag( + projectName: string, + flagName: string +): Promise { + return apiClient.get( + `/projects/${projectName}/feature-flags/evaluate/${flagName}` + ); +} + +/** + * Get details for a specific feature flag from Unleash + */ +export async function getFeatureFlag( + projectName: string, + flagName: string +): Promise { + return apiClient.get( + `/projects/${projectName}/feature-flags/${flagName}` + ); +} + +/** + * Set a workspace-scoped override for a feature flag + */ +export async function setFeatureFlagOverride( + projectName: string, + flagName: string, + enabled: boolean +): Promise { + return apiClient.put( + `/projects/${projectName}/feature-flags/${flagName}/override`, + { enabled } + ); +} + +/** + * Remove a workspace-scoped override (revert to Unleash default) + */ +export async function removeFeatureFlagOverride( + projectName: string, + flagName: string +): Promise { + return apiClient.delete( + `/projects/${projectName}/feature-flags/${flagName}/override` + ); +} + +/** + * Enable a feature flag for this workspace (sets override to true) + */ +export async function enableFeatureFlag( + projectName: string, + flagName: string +): Promise { + return apiClient.post( + `/projects/${projectName}/feature-flags/${flagName}/enable` + ); +} + +/** + * Disable a feature flag for this workspace (sets override to false) + */ +export async function disableFeatureFlag( + projectName: string, + flagName: string +): Promise { + return apiClient.post( + `/projects/${projectName}/feature-flags/${flagName}/disable` + ); +} diff --git a/components/frontend/src/services/queries/index.ts b/components/frontend/src/services/queries/index.ts index f5064ee2f..b336b40c1 100644 --- a/components/frontend/src/services/queries/index.ts +++ b/components/frontend/src/services/queries/index.ts @@ -13,3 +13,4 @@ export * from './use-repo'; export * from './use-workspace'; export * from './use-auth'; export * from './use-google'; +export * from './use-feature-flags-admin'; diff --git a/components/frontend/src/services/queries/use-feature-flags-admin.ts b/components/frontend/src/services/queries/use-feature-flags-admin.ts new file mode 100644 index 000000000..1628d7f6d --- /dev/null +++ b/components/frontend/src/services/queries/use-feature-flags-admin.ts @@ -0,0 +1,184 @@ +/** + * React Query hooks for Feature Flags Admin + * Workspace-scoped feature flag management with Unleash fallback + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as featureFlagsApi from '../api/feature-flags-admin'; + +export const featureFlagKeys = { + all: ['feature-flags'] as const, + list: (projectName: string) => [...featureFlagKeys.all, 'list', projectName] as const, + detail: (projectName: string, flagName: string) => + [...featureFlagKeys.all, 'detail', projectName, flagName] as const, + evaluate: (projectName: string, flagName: string) => + [...featureFlagKeys.all, 'evaluate', projectName, flagName] as const, +}; + +/** + * Hook to fetch all feature flags for a project with workspace override status + */ +export function useFeatureFlags(projectName: string) { + return useQuery({ + queryKey: featureFlagKeys.list(projectName), + queryFn: () => featureFlagsApi.getFeatureFlags(projectName), + enabled: !!projectName, + refetchInterval: 30000, // Refresh every 30s to stay in sync + staleTime: 10000, // Consider data stale after 10s + }); +} + +/** + * Hook to evaluate a workspace-scoped feature flag + * Returns the effective value (ConfigMap override > Unleash default) + */ +export function useWorkspaceFlag(projectName: string, flagName: string) { + const { data, isLoading, error } = useQuery({ + queryKey: featureFlagKeys.evaluate(projectName, flagName), + queryFn: () => featureFlagsApi.evaluateFeatureFlag(projectName, flagName), + enabled: !!projectName && !!flagName, + staleTime: 15000, // 15s cache + refetchInterval: 30000, // Refresh every 30s + }); + + return { + enabled: data?.enabled ?? false, + source: data?.source, + isLoading, + error, + }; +} + +/** + * Hook to fetch a specific feature flag from Unleash + */ +export function useFeatureFlag(projectName: string, flagName: string) { + return useQuery({ + queryKey: featureFlagKeys.detail(projectName, flagName), + queryFn: () => featureFlagsApi.getFeatureFlag(projectName, flagName), + enabled: !!projectName && !!flagName, + }); +} + +/** + * Hook to toggle a feature flag (enable or disable) for this workspace + */ +export function useToggleFeatureFlag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + flagName, + enable, + }: { + projectName: string; + flagName: string; + enable: boolean; + }) => + enable + ? featureFlagsApi.enableFeatureFlag(projectName, flagName) + : featureFlagsApi.disableFeatureFlag(projectName, flagName), + onSuccess: (_, { projectName, flagName }) => { + // Invalidate both list and evaluate queries + queryClient.invalidateQueries({ queryKey: featureFlagKeys.list(projectName) }); + queryClient.invalidateQueries({ + queryKey: featureFlagKeys.evaluate(projectName, flagName), + }); + }, + }); +} + +/** + * Hook to set a workspace-scoped override for a feature flag + */ +export function useSetFeatureFlagOverride() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + flagName, + enabled, + }: { + projectName: string; + flagName: string; + enabled: boolean; + }) => featureFlagsApi.setFeatureFlagOverride(projectName, flagName, enabled), + onSuccess: (_, { projectName, flagName }) => { + queryClient.invalidateQueries({ queryKey: featureFlagKeys.list(projectName) }); + queryClient.invalidateQueries({ + queryKey: featureFlagKeys.evaluate(projectName, flagName), + }); + }, + }); +} + +/** + * Hook to remove a workspace-scoped override (revert to Unleash default) + */ +export function useRemoveFeatureFlagOverride() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + flagName, + }: { + projectName: string; + flagName: string; + }) => featureFlagsApi.removeFeatureFlagOverride(projectName, flagName), + onSuccess: (_, { projectName, flagName }) => { + queryClient.invalidateQueries({ queryKey: featureFlagKeys.list(projectName) }); + queryClient.invalidateQueries({ + queryKey: featureFlagKeys.evaluate(projectName, flagName), + }); + }, + }); +} + +/** + * Hook to enable a feature flag for this workspace + */ +export function useEnableFeatureFlag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + flagName, + }: { + projectName: string; + flagName: string; + }) => featureFlagsApi.enableFeatureFlag(projectName, flagName), + onSuccess: (_, { projectName, flagName }) => { + queryClient.invalidateQueries({ queryKey: featureFlagKeys.list(projectName) }); + queryClient.invalidateQueries({ + queryKey: featureFlagKeys.evaluate(projectName, flagName), + }); + }, + }); +} + +/** + * Hook to disable a feature flag for this workspace + */ +export function useDisableFeatureFlag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + flagName, + }: { + projectName: string; + flagName: string; + }) => featureFlagsApi.disableFeatureFlag(projectName, flagName), + onSuccess: (_, { projectName, flagName }) => { + queryClient.invalidateQueries({ queryKey: featureFlagKeys.list(projectName) }); + queryClient.invalidateQueries({ + queryKey: featureFlagKeys.evaluate(projectName, flagName), + }); + }, + }); +} diff --git a/docs/adr/0006-unleash-feature-flags.md b/docs/adr/0006-unleash-feature-flags.md new file mode 100644 index 000000000..3ca54e4f2 --- /dev/null +++ b/docs/adr/0006-unleash-feature-flags.md @@ -0,0 +1,441 @@ +# ADR-0006: Unleash for Feature Flag Management + +**Status:** Proposed +**Date:** 2026-02-17 +**Deciders:** Platform Team +**Technical Story:** Constitution Principle XI requires all new features gated behind feature flags + +## Context and Problem Statement + +The project constitution (Principle XI: Feature Flag Discipline) mandates that all new features must be gated behind feature flags. We need a feature flag system that supports: + +1. Gradual rollouts and percentage-based targeting +2. A/B testing and experimentation +3. Environment-specific configurations +4. Workspace-scoped overrides (workspace admins can opt-in/out of features) +5. An admin UI for operations to toggle flags without code deploys +6. Client SDK for frontend feature gating + +How should we implement feature flag management for the platform? + +## Decision Drivers + +* **Constitution compliance:** Principle XI requires feature flags for all new features +* **Workspace autonomy:** Workspace admins need to control features for their workspace +* **Platform control:** Platform team needs gradual rollout and A/B testing capabilities +* **Operational control:** Need to enable/disable features without redeployment +* **Multi-environment:** Different flag states for dev, staging, production +* **Security:** Admin API credentials must not be exposed to frontend +* **Ephemeral flags:** Flag definitions change frequently, avoid rigid schemas +* **Flag visibility governance:** Some flags should only be controllable by platform team, not workspace admins + +## Considered Options + +### Feature Flag Backend + +1. **Unleash (self-hosted)** - Open-source feature management platform +2. **LaunchDarkly** - SaaS feature flag service +3. **ConfigMaps/Environment Variables** - Simple Kubernetes-native approach +4. **Custom solution** - Build feature flag system from scratch +5. **Flipt** - Open-source alternative to Unleash + +### Workspace Scoping + +A. **Unleash strategies with workspace constraints** - Pass workspace as context, use Unleash constraints +B. **ConfigMap overrides per workspace** - Store overrides in K8s ConfigMap, fall back to Unleash +C. **Separate Unleash projects per workspace** - One Unleash project per workspace +D. **ProjectSettings CRD field** - Add featureFlagOverrides to ProjectSettings spec + +## Decision Outcome + +### Feature Flag Backend + +Chosen option: **"Unleash (self-hosted)"**, because: + +1. **Open source:** Self-hosted, no vendor lock-in, data sovereignty +2. **Rich feature set:** Strategies, variants, A/B testing, gradual rollouts +3. **Mature ecosystem:** React SDK, REST APIs, well-documented +4. **Admin UI:** Built-in web interface for flag management +5. **Kubernetes-friendly:** Easy to deploy via Helm charts +6. **Cost:** Free for self-hosted (vs LaunchDarkly pricing) + +### Workspace Scoping + +Chosen option: **"ConfigMap overrides per workspace"**, because: + +1. **Decoupled from CRD schema:** Flags are ephemeral; ConfigMaps don't require schema changes +2. **Kubernetes-native:** ConfigMaps are the standard pattern for configuration +3. **Simple evaluation:** Check ConfigMap first, fall back to Unleash +4. **Preserves Unleash capabilities:** A/B testing and gradual rollouts still work via Unleash +5. **Workspace autonomy:** Admins can override without affecting other workspaces + +### Flag Visibility Control + +Chosen option: **"Tag-based filtering"**, because: + +1. **Platform-controlled:** Platform team decides which flags are workspace-configurable via Unleash tags +2. **No code changes:** Adding/removing workspace-configurable flags requires only tag changes in Unleash +3. **Clear governance:** Explicit separation between platform-only and workspace-configurable flags +4. **Audit trail:** Unleash tracks tag changes with history + +**Implementation:** +- Flags with tag `scope: workspace` appear in the workspace admin UI +- Flags without this tag are platform-only (controllable only via Unleash UI) +- Tag type/value configurable via environment variables + +**Alternatives considered:** +- Naming convention (e.g., `workspace.*` prefix) - Less flexible, requires flag renaming +- Separate Unleash projects - More complex, harder to manage +- Backend allowlist - Requires code changes for each new flag +- Flag type filtering - Unleash types have semantic meaning, shouldn't overload + +### Consequences + +**Positive:** + +* Full control over feature flag data and availability +* Rich targeting strategies (user ID, percentage, custom constraints) via Unleash +* Workspace admins can opt-in/out of features independently +* Platform team retains A/B testing and gradual rollout capabilities +* No CRD schema changes when flags are added/removed +* React SDK with hooks (`useFlag`) for clean frontend integration + +**Negative:** + +* Additional infrastructure to maintain (Unleash server + PostgreSQL) +* Two-layer evaluation adds complexity (ConfigMap + Unleash) +* Must implement backend proxy to hide Admin API credentials +* Frontend SDK requires proxy endpoint for Client API + +**Risks:** + +* Unleash server downtime affects feature flag evaluation (mitigated by ConfigMap overrides) +* ConfigMap and Unleash state could diverge (workspace override vs global state) +* SDK polling interval affects flag update latency (default 15s) + +## Implementation Notes + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Frontend (NextJS) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ useWorkspaceFlag(flagName) │ +│ └─ Calls /api/projects/:name/feature-flags/evaluate/:flagName │ +│ └─ Returns merged result (ConfigMap override OR Unleash default) │ +│ │ +│ FeatureFlagsSettings (Admin UI in Workspace Settings tab) │ +│ └─ Lists flags from Unleash (global definitions) │ +│ └─ Shows workspace override status from ConfigMap │ +│ └─ Batch save pattern: toggles tracked locally, saved on click │ +│ └─ Reset button removes override (reverts to Unleash default) │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Backend (Go) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ Flag Evaluation (/projects/:name/feature-flags/evaluate/:flagName) │ +│ 1. Read ConfigMap "feature-flag-overrides" in workspace namespace │ +│ 2. If override exists → return override value │ +│ 3. If no override → query Unleash with workspace + user context │ +│ │ +│ Override Management (/projects/:name/feature-flags/:flagName/override) │ +│ └─ PUT: Set override in ConfigMap (true/false) │ +│ └─ DELETE: Remove override from ConfigMap (use Unleash default) │ +│ │ +│ Flag Listing (/projects/:name/feature-flags) │ +│ └─ Returns Unleash flags filtered by tag (scope: workspace) │ +│ └─ Includes workspace override status from ConfigMap │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────┐ ┌────────────────────────────────┐ +│ ConfigMap (per workspace) │ │ Unleash Server │ +├───────────────────────────────────┤ ├────────────────────────────────┤ +│ Name: feature-flag-overrides │ │ Global flag definitions │ +│ Namespace: workspace-foo │ │ A/B testing strategies │ +│ │ │ Gradual rollout % │ +│ data: │ │ Environment configs │ +│ frontend.feature.enabled: true │ │ │ +│ backend.feature.enabled: false │ │ PostgreSQL storage │ +└───────────────────────────────────┘ └────────────────────────────────┘ +``` + +### Evaluation Logic (Three-State) + +| ConfigMap Override | Unleash State | Result | Who Controls | +|--------------------|---------------|--------|--------------| +| `"true"` | (any) | `true` | Workspace admin | +| `"false"` | (any) | `false` | Workspace admin | +| (not set) | enabled | `true` | Platform team | +| (not set) | disabled | `false` | Platform team | +| (not set) | 50% rollout | (evaluated) | Platform team | + +### Use Cases + +| Scenario | Implementation | +|----------|----------------| +| Global rollout to X% of workspaces | Unleash gradual rollout strategy with workspace context | +| A/B test within workspaces | Unleash A/B strategy with user ID context | +| Workspace opts into beta | Workspace admin sets ConfigMap override = `"true"` | +| Workspace opts out of feature | Workspace admin sets ConfigMap override = `"false"` | +| Reset to platform default | Workspace admin deletes ConfigMap key | + +### ConfigMap Structure + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: feature-flag-overrides + namespace: workspace-foo + labels: + app.kubernetes.io/managed-by: ambient-code + app.kubernetes.io/component: feature-flags +data: + # Override format: flag-name: "true" | "false" + # Absence of key = use Unleash default + frontend.file-explorer.enabled: "true" + frontend.new-chat-ui.enabled: "false" +``` + +### Flag Naming Convention (Constitution Principle XI) + +All feature flags MUST follow the naming pattern: + +``` +.. +``` + +Examples: +* `frontend.file-explorer.enabled` - File explorer feature in frontend +* `backend.multi-repo.enabled` - Multi-repo support in backend +* `runner.langfuse.tracing` - Langfuse tracing in runner + +### Flag Visibility (Platform-Only vs Workspace-Configurable) + +Not all feature flags should be controllable by workspace admins. The platform uses **tag-based filtering** to control which flags appear in the workspace admin UI: + +| Flag Type | Unleash Tag | Visible in Workspace UI | Controllable By | +|-----------|-------------|------------------------|-----------------| +| Workspace-configurable | `scope: workspace` | ✅ Yes | Workspace admins + Platform team | +| Platform-only | (no tag) | ❌ No | Platform team only (via Unleash UI) | + +**When to use each:** + +| Use Case | Flag Type | Rationale | +|----------|-----------|-----------| +| Beta features users can opt into | Workspace-configurable | User choice | +| Experimental UI changes | Workspace-configurable | Users can revert if issues | +| Infrastructure/operational flags | Platform-only | Requires platform expertise | +| Security-related flags | Platform-only | Must be centrally controlled | +| Gradual rollouts (A/B tests) | Platform-only | Platform controls rollout % | +| Kill switches | Platform-only | Emergency platform control | + +**Adding the tag in Unleash:** +1. Navigate to the feature flag in Unleash UI +2. Click "Add tag" +3. Type: `scope`, Value: `workspace` +4. Save + +**Filtering logic in backend:** +```go +// Only include flags with scope:workspace tag +func isWorkspaceConfigurable(tags []Tag) bool { + tagType := getEnvOrDefault("UNLEASH_WORKSPACE_TAG_TYPE", "scope") + tagValue := getEnvOrDefault("UNLEASH_WORKSPACE_TAG_VALUE", "workspace") + + for _, tag := range tags { + if tag.Type == tagType && tag.Value == tagValue { + return true + } + } + return false +} +``` + +### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/projects/:name/feature-flags` | GET | List all flags with override status | +| `/projects/:name/feature-flags/evaluate/:flagName` | GET | Evaluate flag for workspace | +| `/projects/:name/feature-flags/:flagName/override` | PUT | Set workspace override | +| `/projects/:name/feature-flags/:flagName/override` | DELETE | Remove workspace override | + +### Key Files + +**Backend:** + +* `handlers/featureflags.go` - Client API proxy for frontend SDK +* `handlers/featureflags_admin.go` - Flag evaluation, override management +* `routes.go` - Route registration + +**Frontend:** + +* `src/lib/feature-flags.ts` - Re-exports Unleash SDK hooks (`useFlag`, `useVariant`) +* `src/components/providers/feature-flag-provider.tsx` - Unleash provider with environment context +* `src/components/workspace-sections/feature-flags-settings.tsx` - Admin UI (in Settings tab) with batch save +* `src/services/queries/use-feature-flags-admin.ts` - React Query hooks including `useWorkspaceFlag` +* `src/services/api/feature-flags-admin.ts` - API service functions +* `src/app/api/projects/[name]/feature-flags/*` - Next.js proxy routes + +**Deployment:** + +* `e2e/scripts/deploy-unleash.sh` - Unleash + PostgreSQL deployment script +* `Makefile:deploy-unleash*` - Deployment targets for KinD/CRC + +### Environment Variables + +| Variable | Component | Description | +|----------|-----------|-------------| +| `UNLEASH_URL` | Backend | Unleash server URL | +| `UNLEASH_CLIENT_KEY` | Backend | Client API token (read-only) | +| `UNLEASH_ADMIN_URL` | Backend | Unleash Admin API URL | +| `UNLEASH_ADMIN_TOKEN` | Backend | Admin API token (read-write) | +| `UNLEASH_PROJECT` | Backend | Unleash project ID (default: "default") | +| `UNLEASH_ENVIRONMENT` | Backend | Target environment (default: "development") | +| `UNLEASH_WORKSPACE_TAG_TYPE` | Backend | Tag type for workspace-configurable flags (default: "scope") | +| `UNLEASH_WORKSPACE_TAG_VALUE` | Backend | Tag value for workspace-configurable flags (default: "workspace") | +| `NEXT_PUBLIC_UNLEASH_ENV_CONTEXT_FIELD` | Frontend | Environment value sent in SDK context (default: "development"). Note: This does NOT select the Unleash environment—that's determined by the token scope. Used for strategy constraints that check `context.environment`. | + +### Patterns Established + +**Pattern 1: Workspace-Scoped Flag Evaluation** + +```go +func EvaluateFeatureFlag(c *gin.Context) { + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + // 1. Check ConfigMap for workspace override + cm, err := k8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, "feature-flag-overrides", metav1.GetOptions{}) + if err == nil { + if override, exists := cm.Data[flagName]; exists { + enabled := override == "true" + c.JSON(http.StatusOK, gin.H{"flag": flagName, "enabled": enabled, "source": "workspace-override"}) + return + } + } + + // 2. Fall back to Unleash + enabled := unleashClient.IsEnabled(flagName, unleash.WithContext(unleash.Context{ + Properties: map[string]string{"workspace": namespace}, + })) + c.JSON(http.StatusOK, gin.H{"flag": flagName, "enabled": enabled, "source": "unleash"}) +} +``` + +**Pattern 2: Setting Workspace Override** + +```go +func SetFeatureFlagOverride(c *gin.Context) { + namespace := c.Param("projectName") + flagName := c.Param("flagName") + + var req struct { + Enabled bool `json:"enabled"` + } + c.ShouldBindJSON(&req) + + // Get or create ConfigMap + cm, err := k8sClient.CoreV1().ConfigMaps(namespace).Get(ctx, "feature-flag-overrides", metav1.GetOptions{}) + if errors.IsNotFound(err) { + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "feature-flag-overrides", + Namespace: namespace, + }, + Data: map[string]string{}, + } + cm, err = k8sClient.CoreV1().ConfigMaps(namespace).Create(ctx, cm, metav1.CreateOptions{}) + } + + // Set override + if cm.Data == nil { + cm.Data = map[string]string{} + } + cm.Data[flagName] = strconv.FormatBool(req.Enabled) + + _, err = k8sClient.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + c.JSON(http.StatusOK, gin.H{"message": "Override set", "flag": flagName, "enabled": req.Enabled}) +} +``` + +**Pattern 3: Frontend Workspace Flag Hook** + +```typescript +import { useQuery } from "@tanstack/react-query"; + +export function useWorkspaceFlag(projectName: string, flagName: string) { + const { data, isLoading } = useQuery({ + queryKey: ["feature-flag", projectName, flagName], + queryFn: async () => { + const res = await fetch(`/api/projects/${projectName}/feature-flags/evaluate/${flagName}`); + return res.json(); + }, + staleTime: 15000, // 15s cache + enabled: !!projectName && !!flagName, + }); + + return { + enabled: data?.enabled ?? false, + source: data?.source, + isLoading, + }; +} +``` + +**Pattern 4: Batch Save for Admin UI** + +The Feature Flags admin UI (located in Workspace Settings tab) uses a batch save pattern to prevent excessive ConfigMap updates: + +1. **Local state tracking**: Toggle changes are tracked in React state, not immediately saved +2. **Visual indicators**: "Unsaved" and "Will Reset" badges show pending changes +3. **Batch operations**: Save button commits all changes (toggles + resets) in parallel +4. **Discard option**: Users can revert all pending changes without saving + +This pattern prevents ConfigMap update spam when users toggle multiple flags and provides a familiar "Save/Discard" UX consistent with other Settings sections. + +## Validation + +**Functional Testing:** + +* Workspace override takes precedence over Unleash global state +* Removing override reverts to Unleash default +* A/B testing works when no override is set +* Gradual rollout respects workspace context +* `useWorkspaceFlag()` hook returns correct values +* Only flags with `scope: workspace` tag appear in workspace admin UI +* Platform-only flags (without tag) are hidden from workspace admin UI + +**Security Testing:** + +* Admin API credentials not exposed to frontend +* User authorization validated before override operations +* ConfigMap access restricted to workspace namespace + +**Deployment Verification:** + +```bash +# Deploy Unleash to cluster +make deploy-unleash-kind # or deploy-unleash-openshift + +# Verify deployment +make unleash-status + +# Port-forward for local access +make unleash-port-forward +# Access at http://localhost:4242 +``` + +## Links + +* [Unleash Documentation](https://docs.getunleash.io/) +* [Unleash React SDK](https://docs.getunleash.io/reference/sdks/react) +* [Unleash Admin API](https://docs.getunleash.io/reference/api/unleash/admin) +* [Kubernetes ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/) +* Constitution Principle XI: Feature Flag Discipline +* Related: docs/feature-flags/README.md diff --git a/docs/adr/README.md b/docs/adr/README.md index 6360d0e1f..c5fce6e76 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,6 +61,7 @@ Create an ADR for decisions that: | [0003](0003-multi-repo-support.md) | Multi-Repository Support in AgenticSessions | Accepted | 2024-11-21 | | [0004](0004-go-backend-python-runner.md) | Go Backend with Python Claude Runner | Accepted | 2024-11-21 | | [0005](0005-nextjs-shadcn-react-query.md) | Next.js with Shadcn UI and React Query | Accepted | 2024-11-21 | +| [0006](0006-unleash-feature-flags.md) | Unleash for Feature Flag Management | Accepted | 2026-02-17 | ## References diff --git a/docs/feature-flags/README.md b/docs/feature-flags/README.md new file mode 100644 index 000000000..8210c9127 --- /dev/null +++ b/docs/feature-flags/README.md @@ -0,0 +1,94 @@ +# Feature Flags + +Documentation for feature flag integration in the Ambient Code Platform. + +## Available Integrations + +### Unleash – Feature Toggles +**[Unleash Integration Guide](feature-flags-unleash.md)** + +Use [Unleash](https://www.getunleash.io/) to enable or disable features without redeploying: + +- **Frontend**: Next.js proxy at `/api/feature-flags`; use `useFlag()` / `useVariant()` from `@/lib/feature-flags` in client components +- **Backend**: Go SDK; use `handlers.FeatureEnabled()` or `handlers.FeatureEnabledForRequest()` in handlers +- **Admin UI**: Manage feature toggles directly from the workspace UI (see below) +- When Unleash is not configured, all flags are disabled (safe default) + +**Environment variables:** `UNLEASH_URL`, `UNLEASH_CLIENT_KEY` (and optionally `UNLEASH_APP_NAME` for frontend). See the guide for per-component details. + +### Feature Flags Admin UI + +The platform includes a built-in admin UI for managing feature flags directly from the workspace. Navigate to **Workspace > Feature Flags** to: + +- View all feature toggles and their current state +- Enable/disable toggles with a single click +- See toggle types (release, experiment, operational, kill-switch) +- View toggle descriptions and stale status + +**Additional environment variables for Admin UI:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `UNLEASH_ADMIN_URL` | Yes | Unleash server base URL (same as `UNLEASH_URL` typically) | +| `UNLEASH_ADMIN_TOKEN` | Yes | Admin API token (different from Client API token) | +| `UNLEASH_PROJECT` | No | Unleash project ID (default: `default`) | +| `UNLEASH_ENVIRONMENT` | No | Target environment for toggles (default: `development`) | + +To get an Admin API token, go to Unleash UI > Admin > API tokens and create a token with Admin permissions. + +--- + +## Quick Start + +### 1. Configure Unleash + +Set environment variables for the components you use: + +**Frontend** (ConfigMap/Secret or `.env.local`): + +```bash +UNLEASH_URL=https://unleash.example.com +UNLEASH_CLIENT_KEY=your-frontend-api-token +``` + +**Backend** (ConfigMap/Secret): + +```bash +UNLEASH_URL=https://unleash.example.com +UNLEASH_CLIENT_KEY=your-client-api-token +``` + +### 2. Create a toggle in Unleash + +In the Unleash UI, create a feature toggle (e.g. `my-feature`). Enable or disable it at any time; no redeploy needed. + +### 3. Use in code + +**Frontend (client component):** + +```ts +import { useFlag } from '@/lib/feature-flags'; +const enabled = useFlag('my-feature'); +``` + +**Backend (handler):** + +```go +if handlers.FeatureEnabled("my-feature") { + // new behavior +} +``` + +--- + +## Related Documentation + +- [Frontend README](../../components/frontend/README.md) – Development and env overview +- [Backend README](../../components/backend/README.md) – Development and env overview +- [Architecture](../architecture/) – System design + +## References + +- **Unleash**: https://docs.getunleash.io/ +- **Unleash Go SDK**: https://pkg.go.dev/github.com/Unleash/unleash-go-sdk/v5 +- **Unleash React SDK**: https://docs.getunleash.io/sdks/react diff --git a/docs/feature-flags/feature-flags-unleash.md b/docs/feature-flags/feature-flags-unleash.md new file mode 100644 index 000000000..3e631d433 --- /dev/null +++ b/docs/feature-flags/feature-flags-unleash.md @@ -0,0 +1,171 @@ +# Feature Flags with Unleash + +The platform uses [Unleash](https://www.getunleash.io/) for optional feature toggles in the frontend and backend. When Unleash is not configured, all flags are disabled (safe default). + +## Overview + +- **Frontend**: Next.js proxy at `/api/feature-flags` forwards to Unleash so the client key is never exposed. Use `useFlag()` / `useVariant()` from `@/lib/feature-flags` in client components. +- **Backend**: Go SDK initializes when `UNLEASH_URL` and `UNLEASH_CLIENT_KEY` are set. Use `handlers.FeatureEnabled()` or `handlers.FeatureEnabledForRequest()` in handlers. + +Create toggles in the Unleash UI; enable or disable them without redeploying. + +--- + +## Frontend + +### Environment variables + +Set these for the **frontend** (e.g. in deployment ConfigMap/Secret or `.env.local`): + +| Variable | Required | Description | +|----------|----------|-------------| +| `UNLEASH_URL` | Yes* | Unleash server base URL (e.g. `https://unleash.example.com`) | +| `UNLEASH_CLIENT_KEY` | Yes* | Frontend API token (used by the Next.js proxy only; never sent to the browser) | +| `UNLEASH_APP_NAME` | No | App name sent to Unleash (default: `ambient-code-platform`) | + +\*If either is missing, the proxy returns empty toggles and all flags are false. + +### Usage in components + +In **client components** only: + +```ts +import { useFlag, useVariant, useFlagsStatus } from '@/lib/feature-flags'; + +// Simple toggle +const enabled = useFlag('my-feature-name'); +if (enabled) return ; +return ; + +// A/B or variant +const variant = useVariant('experiment-name'); +// use variant.name to decide which variant to show + +// Wait for flags to load before rendering flag-dependent UI +const { flagsReady, flagsError } = useFlagsStatus(); +if (!flagsReady) return ; +``` + +### Deployment + +Add `UNLEASH_URL`, `UNLEASH_CLIENT_KEY`, and optionally `UNLEASH_APP_NAME` to the frontend deployment (ConfigMap/Secret). The backend does not need these unless you use backend feature flags. + +--- + +## Backend + +### Environment variables + +Set these for the **backend** (e.g. in deployment ConfigMap/Secret): + +| Variable | Required | Description | +|----------|----------|-------------| +| `UNLEASH_URL` | Yes* | Unleash server base URL (e.g. `https://unleash.example.com`) | +| `UNLEASH_CLIENT_KEY` | Yes* | API token for the Unleash Client API (backend token; can be same or different from frontend) | + +\*If either is missing, `featureflags.Init()` does nothing and all flag checks return `false`. + +### Usage in handlers + +- **Global check** (same for all requests): `handlers.FeatureEnabled("flag-name")` +- **Per-request** (user/session/IP for strategies): `handlers.FeatureEnabledForRequest(c, "flag-name")` + +When Unleash is not configured, both return `false`. + +### Example: enable/disable a feature (e.g. FakeFeature) + +1. In Unleash, create a toggle named `fake-feature` (or whatever name you use in code). +2. In the handler (or middleware) that should be gated: + +```go +// Option A: Hide the feature entirely (e.g. return 404 when disabled) +if !handlers.FeatureEnabled("fake-feature") { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return +} +// ... handle FakeFeature ... + +// Option B: Branch behavior (legacy vs new) +if handlers.FeatureEnabled("fake-feature") { + handleFakeFeatureNew(c) +} else { + handleFakeFeatureLegacy(c) +} + +// Option C: Per-user rollout (e.g. beta users only) +if !handlers.FeatureEnabledForRequest(c, "fake-feature") { + c.JSON(http.StatusForbidden, gin.H{"error": "feature not enabled for you"}) + return +} +``` + +Turn the feature on or off in the Unleash UI; no redeploy needed. + +### Dependency + +Backend uses `github.com/Unleash/unleash-go-sdk/v5`. Ensure it is in `go.mod` and run `go mod tidy` or `go get github.com/Unleash/unleash-go-sdk/v5` if needed. + +### Reference + +- `components/backend/featureflags/featureflags.go` – Unleash client init, `IsEnabled`, `IsEnabledWithContext` +- `components/backend/handlers/featureflags.go` – `FeatureEnabled`, `FeatureEnabledForRequest` + +--- + +## Admin UI + +The platform includes a built-in admin UI for managing feature flags directly from the workspace. This allows users to view and toggle flags without accessing the Unleash dashboard. + +### Environment Variables + +Set these for the **backend** to enable the Admin UI: + +| Variable | Required | Description | +|----------|----------|-------------| +| `UNLEASH_ADMIN_URL` | Yes | Unleash server base URL (e.g. `https://unleash.example.com`) | +| `UNLEASH_ADMIN_TOKEN` | Yes | Admin API token (from Unleash > Admin > API tokens) | +| `UNLEASH_PROJECT` | No | Unleash project ID (default: `default`) | +| `UNLEASH_ENVIRONMENT` | No | Target environment for toggles (default: `development`) | + +**Note:** The Admin API token is different from the Client API token. Create one in Unleash UI > Admin > API tokens with Admin permissions. + +### Using the Admin UI + +1. Navigate to your workspace in the platform +2. Click **Feature Flags** in the sidebar +3. View all toggles with their current enabled/disabled state +4. Click the toggle switch to enable or disable a flag +5. Changes take effect immediately for new sessions + +### API Endpoints + +The backend exposes these endpoints (proxied to Unleash Admin API): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/projects/:projectName/feature-flags` | GET | List all feature toggles | +| `/api/projects/:projectName/feature-flags/:flagName` | GET | Get toggle details | +| `/api/projects/:projectName/feature-flags/:flagName/enable` | POST | Enable toggle in environment | +| `/api/projects/:projectName/feature-flags/:flagName/disable` | POST | Disable toggle in environment | + +### Example: Toggle a flag via API + +```bash +# List all flags +curl -H "Authorization: Bearer $TOKEN" \ + https://your-backend/api/projects/my-workspace/feature-flags + +# Enable a flag +curl -X POST -H "Authorization: Bearer $TOKEN" \ + https://your-backend/api/projects/my-workspace/feature-flags/my-feature/enable + +# Disable a flag +curl -X POST -H "Authorization: Bearer $TOKEN" \ + https://your-backend/api/projects/my-workspace/feature-flags/my-feature/disable +``` + +### Reference + +- `components/backend/handlers/featureflags_admin.go` – Admin API handlers +- `components/frontend/src/components/workspace-sections/feature-flags-section.tsx` – Admin UI component +- `components/frontend/src/services/queries/use-feature-flags-admin.ts` – React Query hooks diff --git a/e2e/scripts/deploy-unleash.sh b/e2e/scripts/deploy-unleash.sh new file mode 100755 index 000000000..9ad957f4c --- /dev/null +++ b/e2e/scripts/deploy-unleash.sh @@ -0,0 +1,356 @@ +#!/bin/bash +set -euo pipefail + +# Deploy Unleash feature flag server to Kubernetes/OpenShift +# Uses Helm chart with bundled PostgreSQL (same pattern as deploy-langfuse.sh) + +# Parse command line arguments +PLATFORM="auto" +NAMESPACE="unleash" +while [[ $# -gt 0 ]]; do + case $1 in + --openshift|--crc) + PLATFORM="openshift" + shift + ;; + --kubernetes|--k8s|--kind) + PLATFORM="kubernetes" + shift + ;; + --namespace|-n) + NAMESPACE="$2" + shift 2 + ;; + --help|-h) + echo "Usage: $0 [--openshift|--kubernetes] [--namespace ]" + echo "" + echo "Options:" + echo " --openshift, --crc Force OpenShift mode (use oc, create Route)" + echo " --kubernetes, --kind Force Kubernetes mode (use kubectl, create Ingress)" + echo " --namespace, -n Target namespace (default: unleash)" + echo " (default) Auto-detect based on available CLI and cluster type" + echo "" + echo "After deployment, connect the backend by setting:" + echo " UNLEASH_URL=http://unleash..svc.cluster.local:4242/api" + echo " UNLEASH_CLIENT_KEY=default:development.unleash-client-token" + echo " UNLEASH_ADMIN_URL=http://unleash..svc.cluster.local:4242" + echo " UNLEASH_ADMIN_TOKEN=*:*.unleash-admin-token" + echo "" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +echo "======================================" +echo "Deploying Unleash Feature Flag Server" +echo "======================================" +echo "" + +# Detect platform if auto mode +if [ "$PLATFORM" = "auto" ]; then + echo "Auto-detecting platform..." + + # Check if oc is available and we're on OpenShift + if command -v oc &> /dev/null; then + if oc api-resources --api-group=route.openshift.io &>/dev/null 2>&1; then + PLATFORM="openshift" + echo " Detected OpenShift cluster" + else + PLATFORM="kubernetes" + echo " Detected Kubernetes cluster (oc CLI available)" + fi + elif command -v kubectl &> /dev/null; then + PLATFORM="kubernetes" + echo " Detected Kubernetes cluster" + else + echo "Neither kubectl nor oc found. Please install Kubernetes CLI." + exit 1 + fi + echo "" +fi + +# Set CLI tool based on platform +if [ "$PLATFORM" = "openshift" ]; then + CLI="oc" + PLATFORM_NAME="OpenShift" +else + CLI="kubectl" + PLATFORM_NAME="Kubernetes" +fi + +echo "Platform: $PLATFORM_NAME" +echo "CLI: $CLI" +echo "Namespace: $NAMESPACE" +echo "" + +# Check prerequisites +if ! command -v helm &> /dev/null; then + echo "Helm not found. Please install Helm 3.x first." + echo " Visit: https://helm.sh/docs/intro/install/" + exit 1 +fi + +# Check cluster connection +if ! $CLI cluster-info &>/dev/null; then + echo "Not connected to $PLATFORM_NAME cluster" + if [ "$PLATFORM" = "openshift" ]; then + echo " Please run: $CLI login " + else + echo " Please configure kubectl: kubectl config use-context " + fi + exit 1 +fi + +CLUSTER_USER=$($CLI config view --minify -o jsonpath='{.contexts[0].context.user}' 2>/dev/null || echo "unknown") +CLUSTER_URL=$($CLI config view --minify -o jsonpath='{.clusters[0].cluster.server}') +echo "Connected to $PLATFORM_NAME:" +echo " User: $CLUSTER_USER" +echo " Cluster: $CLUSTER_URL" +echo "" + +# Prompt for credentials or use defaults for testing (same pattern as Langfuse) +read -p "Use simple test passwords? (y/n, default: y): " USE_TEST_CREDS +USE_TEST_CREDS=${USE_TEST_CREDS:-y} + +if [[ "$USE_TEST_CREDS" =~ ^[Yy]$ ]]; then + echo "Setting simple passwords for test environment..." + POSTGRES_PASSWORD="postgres123" + echo " Test credentials configured" +else + echo "Generating secure random credentials..." + POSTGRES_PASSWORD=$(openssl rand -base64 32) + echo " Secure credentials generated" +fi + +# Add Bitnami Helm repository (for PostgreSQL) +echo "" +echo "Adding Helm repositories..." +helm repo add bitnami https://charts.bitnami.com/bitnami &>/dev/null || true +helm repo update &>/dev/null +echo " Helm repositories updated" + +# Create namespace +echo "" +echo "Creating namespace '$NAMESPACE'..." +if $CLI get namespace "$NAMESPACE" &>/dev/null; then + echo " Namespace '$NAMESPACE' already exists" +else + $CLI create namespace "$NAMESPACE" + echo " Namespace created" +fi + +# Deploy PostgreSQL using Bitnami Helm chart (same pattern as Langfuse) +echo "" +echo "Installing PostgreSQL with Helm..." +echo " (This may take 2-3 minutes...)" +echo "" + +helm upgrade --install unleash-postgresql bitnami/postgresql \ + --namespace "$NAMESPACE" \ + --set auth.username=unleash \ + --set auth.password="$POSTGRES_PASSWORD" \ + --set auth.database=unleash \ + --set primary.podAntiAffinityPreset=none \ + --set primary.persistence.size=1Gi \ + --set primary.resources.requests.memory=256Mi \ + --set primary.resources.limits.memory=512Mi \ + --set primary.resources.requests.cpu=100m \ + --set primary.resources.limits.cpu=500m \ + --wait \ + --timeout=5m + +echo " PostgreSQL installed" + +# Wait for PostgreSQL to be ready +echo "" +echo "Waiting for PostgreSQL to be ready..." +$CLI wait --namespace "$NAMESPACE" \ + --for=condition=ready \ + --timeout=180s \ + pod -l app.kubernetes.io/name=postgresql +echo " PostgreSQL is ready" + +# Deploy Unleash using raw manifests (no official Helm chart with good defaults) +echo "" +echo "Deploying Unleash server..." + +# Create Unleash secret with database URL +DATABASE_URL="postgres://unleash:${POSTGRES_PASSWORD}@unleash-postgresql:5432/unleash" +$CLI create secret generic unleash-secrets \ + --namespace "$NAMESPACE" \ + --from-literal=DATABASE_URL="$DATABASE_URL" \ + --dry-run=client -o yaml | $CLI apply -f - + +cat <