Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,101 @@ graph TD
* REST API for frontend consumption
* Persistence layer with in-memory and Kubernetes ConfigMap backends
* Command Line Interface (CLI) for managing feature flags
* **Multi-application support** for managing feature flags across multiple applications
* **Field-level access control** with editable field restrictions
* Workload restart functionality for Deployments, StatefulSets, and DaemonSets
* Designed for Kubernetes environments
* OpenTelemetry instrumentation for observability
* Configurable via environment variables and ConfigMaps
* Lightweight and easy to deploy

## Multi-Application Support

The feature service supports managing feature flags for multiple applications from a single service instance. Each application can have its own:

- Namespace
- Storage type (in-memory or ConfigMap)
- ConfigMap configuration
- Workload restart settings
- Editable field restrictions

### Configuration

**Helm Chart:**
```yaml
service:
applications:
- name: yasm-frontend
namespace: frontend
storageType: configmap
configMap:
name: yasm-frontend
preset: BANNER=Hello
editable: BANNER
workload:
enabled: true
type: deployment
name: yasm-frontend
- name: yasm-backend
namespace: backend
storageType: configmap
configMap:
name: yasm-backend
preset: AUTH_ENABLED=true,BACKGROUND=blue
editable: BACKGROUND
workload:
enabled: true
type: deployment
name: yasm-backend
defaultApplication: yasm-frontend
```

### CLI Usage

```bash
# List all configured applications
feature-cli applications

# Get all features for a specific application
feature-cli -a yasm-frontend getall

# Set a feature value for an application
feature-cli -a yasm-backend set BACKGROUND green

# Get a feature value (uses default application if -a not specified)
feature-cli get BANNER

# Delete a feature
feature-cli -a yasm-frontend delete DEBUG_MODE
```

The `-a` or `--application` flag can be used with all feature management commands. If not specified, the default application (first in the list or set via `defaultApplication`) is used.

### Environment Variables

For multi-application mode, set:

```bash
APPLICATIONS=app1,app2,app3
DEFAULT_APPLICATION=app1

# Configuration for app1
APP1_NAMESPACE=namespace1
APP1_STORAGE_TYPE=configmap
APP1_CONFIGMAP_NAME=app1-config
APP1_PRESET=KEY1=value1,KEY2=value2
APP1_EDITABLE=KEY1
APP1_RESTART_ENABLED=true
APP1_RESTART_TYPE=deployment
APP1_RESTART_NAME=app1-deployment
```

Replace hyphens with underscores in application names for environment variable prefixes (e.g., `yasm-frontend` becomes `YASM_FRONTEND_`).

### Legacy Single-Application Mode

The service still supports the legacy single-application mode for backward compatibility. If `applications` is not set in the Helm chart or `APPLICATIONS` environment variable is not set, the service operates in single-application mode using the legacy configuration.

## Field-Level Access Control

The service supports restricting which feature flags can be modified at runtime through the `EDITABLE` configuration.
Expand Down
12 changes: 12 additions & 0 deletions api/feature/feature.proto
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import "google/protobuf/empty.proto";

message Key {
string name = 1;
string application = 2;
}

message Value {
Expand All @@ -18,6 +19,16 @@ message KeyValue {
string key = 1;
string value = 2;
bool editable = 3;
string application = 4;
}

message ApplicationsRequest {
}

message Application {
string name = 1;
string namespace = 2;
string storage_type = 3;
}

service Feature {
Expand All @@ -26,4 +37,5 @@ service Feature {
rpc Set (KeyValue) returns (google.protobuf.Empty);
rpc Get(Key) returns (Value);
rpc Delete(Key) returns (google.protobuf.Empty);
rpc GetApplications (ApplicationsRequest) returns (stream Application);
}
5 changes: 5 additions & 0 deletions charts/feature/templates/cli-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ data:
ENDPOINT: {{ default (printf "%s:%v" (include "feature.fullname" .) .Values.service.service.port) .Values.cli.endpoint | quote }}
OPENTELEMETRY_ENABLED: {{ ternary "true" "false" .Values.cli.opentelemetry.enabled | quote }}
OPENTELEMETRY_ENDPOINT: {{ .Values.cli.opentelemetry.endpoint | quote }}
{{- if .Values.service.defaultApplication }}
DEFAULT_APPLICATION: {{ .Values.service.defaultApplication | quote }}
{{- else if .Values.service.applications }}
DEFAULT_APPLICATION: {{ (index .Values.service.applications 0).name | quote }}
{{- end }}
{{- end }}
32 changes: 27 additions & 5 deletions charts/feature/templates/service-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,40 @@ metadata:
{{- include "feature.labels" . | nindent 4 }}
app.kubernetes.io/component: service
data:
{{- if .Values.service.applications }}
{{- /* Multi-application mode */ -}}
APPLICATIONS: {{ join "," (pluck "name" .Values.service.applications) | quote }}
DEFAULT_APPLICATION: {{ .Values.service.defaultApplication | default (index .Values.service.applications 0).name | quote }}
{{- range .Values.service.applications }}
{{- $appPrefix := upper (replace "-" "_" .name) }}
{{ $appPrefix }}_NAMESPACE: {{ .namespace | default "default" | quote }}
{{ $appPrefix }}_STORAGE_TYPE: {{ .storageType | default "inmemory" | quote }}
{{- if .configMap }}
{{ $appPrefix }}_CONFIGMAP_NAME: {{ .configMap.name | quote }}
{{ $appPrefix }}_PRESET: {{ .configMap.preset | default "" | quote }}
{{ $appPrefix }}_EDITABLE: {{ .configMap.editable | default "" | quote }}
{{- end }}
{{- if .workload }}
{{ $appPrefix }}_RESTART_ENABLED: {{ ternary "true" "false" .workload.enabled | quote }}
{{ $appPrefix }}_RESTART_TYPE: {{ .workload.type | default "deployment" | quote }}
{{ $appPrefix }}_RESTART_NAME: {{ .workload.name | default "" | quote }}
{{- end }}
{{- end }}
{{- else }}
{{- /* Legacy single-application mode */ -}}
STORAGE_TYPE: {{ .Values.service.storageType | quote }}
CONFIGMAP_NAME: {{ .Values.service.configMap.name | quote }}
PORT: {{ .Values.service.port | quote }}
PRESET: {{ .Values.service.preset | quote }}
OPENTELEMETRY_ENABLED: {{ ternary "true" "false" .Values.cli.opentelemetry.enabled | quote }}
OPENTELEMETRY_ENDPOINT: {{ .Values.cli.opentelemetry.endpoint | quote }}
NOTIFICATION_ENABLED: {{ ternary "true" "false" .Values.service.notification.enabled | quote }}
NOTIFICATION_TYPE: {{ .Values.service.notification.type | quote }}
RESTART_ENABLED: {{ ternary "true" "false" .Values.service.restart.enabled | quote }}
RESTART_TYPE: {{ .Values.service.restart.type | quote }}
RESTART_NAME: {{ .Values.service.restart.name | quote }}
EDITABLE: {{ .Values.service.configMap.editable | quote }}
{{- end }}
PORT: {{ .Values.service.port | quote }}
OPENTELEMETRY_ENABLED: {{ ternary "true" "false" (default .Values.cli.opentelemetry.enabled .Values.service.opentelemetry.enabled) | quote }}
OPENTELEMETRY_ENDPOINT: {{ default .Values.cli.opentelemetry.endpoint .Values.service.opentelemetry.endpoint | quote }}
NOTIFICATION_ENABLED: {{ ternary "true" "false" .Values.service.notification.enabled | quote }}
NOTIFICATION_TYPE: {{ .Values.service.notification.type | quote }}
AUTHENTICATION_ENABLED: {{ ternary "true" "false" .Values.service.authentication.enabled | quote }}
AUTHENTICATION_USERNAME: {{ .Values.service.authentication.username | quote }}
{{- end }}
48 changes: 48 additions & 0 deletions charts/feature/templates/service-rbac.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,51 @@
{{- if and .Values.service.rbac.create .Values.serviceAccount.create -}}
{{- if .Values.service.applications }}
{{- /* Multi-application mode - create ClusterRole and RoleBindings for each namespace */ -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "feature.fullname" . }}
labels:
{{- include "feature.labels" . | nindent 4 }}
app.kubernetes.io/component: service
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["get", "list", "patch", "update"]
---
{{- /* Create a RoleBinding in each unique namespace */ -}}
{{- $serviceAccountName := include "feature.serviceAccountName" . -}}
{{- $releaseNamespace := .Release.Namespace -}}
{{- $clusterRoleName := include "feature.fullname" . -}}
{{- $uniqueNamespaces := dict -}}
{{- range .Values.service.applications }}
{{- $namespace := .namespace | default "default" -}}
{{- $_ := set $uniqueNamespaces $namespace true -}}
{{- end }}
{{- range $namespace, $_ := $uniqueNamespaces }}
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ $clusterRoleName }}
namespace: {{ $namespace }}
labels:
{{- include "feature.labels" $ | nindent 4 }}
app.kubernetes.io/component: service
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ $clusterRoleName }}
subjects:
- kind: ServiceAccount
name: {{ $serviceAccountName }}
namespace: {{ $releaseNamespace }}
---
{{- end }}
{{- else }}
{{- /* Legacy single-application mode - use Role in current namespace */ -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
Expand Down Expand Up @@ -30,3 +77,4 @@ subjects:
name: {{ include "feature.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
{{- end }}
27 changes: 27 additions & 0 deletions charts/feature/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ service:
# The port that the Kubernetes Service listens on
port: 80
# The storage type, either "inmemory" or "configmap"
# Legacy single-application mode - will be used if applications is not set
storageType: inmemory
# ConfigMap data, only used if storageType is "configmap"
configMap:
Expand All @@ -37,6 +38,32 @@ service:
editable: ""
# Pre-set key-value pairs in the format key=value (comma-separated)
preset: "COLOR=red,THEME=dark,BOOKING=true"
# Multi-application configuration (if set, overrides single-application settings above)
# applications:
# - name: yasm-frontend
# namespace: frontend
# storageType: configmap
# configMap:
# name: yasm-frontend
# preset: BANNER=Hello
# editable: BANNER
# workload:
# enabled: true
# type: deployment
# name: yasm-frontend
# - name: yasm-backend
# namespace: backend
# storageType: configmap
# configMap:
# name: yasm-backend
# preset: AUTH_ENABLED=true,BACKGROUND=blue
# editable: BACKGROUND
# workload:
# enabled: true
# type: deployment
# name: yasm-backend
# Default application (used when --application is not specified in CLI)
defaultApplication: ""
# Authentication settings
authentication:
enabled: false # Enable authentication for Feature and Workload services
Expand Down
35 changes: 35 additions & 0 deletions cli/command/applications/applications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package applications

import (
"context"
"log/slog"

"github.com/dkrizic/feature/cli/command"
feature "github.com/dkrizic/feature/cli/repository/feature/v1"
"github.com/urfave/cli/v3"
"go.opentelemetry.io/otel"
)

func Applications(ctx context.Context, cmd *cli.Command) error {
ctx, span := otel.Tracer("cli/command/applications").Start(ctx, "Applications")
defer span.End()

fc, err := command.FeatureClient(cmd)
if err != nil {
return err
}

slog.InfoContext(ctx, "Getting all applications")
all, err := fc.GetApplications(ctx, &feature.ApplicationsRequest{})
if err != nil {
return err
}
for {
app, err := all.Recv()
if err != nil {
break
}
slog.InfoContext(ctx, "Application", "name", app.Name, "namespace", app.Namespace, "storage_type", app.StorageType)
}
return nil
}
9 changes: 9 additions & 0 deletions cli/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ func (c *basicAuthCreds) RequireTransportSecurity() bool {
return false
}

// GetApplicationName returns the application name from the command
func GetApplicationName(cmd *cli.Command) string {
app := cmd.String(constant.Application)
if app == "" {
app = cmd.String(constant.DefaultApplication)
}
return app
}

func FeatureClient(cmd *cli.Command) (feature.FeatureClient, error) {
endpoint := cmd.String(constant.Endpoint)
username := cmd.String(constant.Username)
Expand Down
7 changes: 4 additions & 3 deletions cli/command/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ func Delete(ctx context.Context, cmd *cli.Command) error {
}

key := cmd.StringArg("key")
value := cmd.StringArg("value")
app := command.GetApplicationName(cmd)

slog.Info("Deleting feature", "key", key, "value", value)
slog.Info("Deleting feature", "key", key, "application", app)
_, err = fc.Delete(ctx, &feature.Key{
Name: key,
Name: key,
Application: app,
})
return err
}
6 changes: 4 additions & 2 deletions cli/command/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ func Get(ctx context.Context, cmd *cli.Command) error {
}

key := cmd.StringArg("key")
app := command.GetApplicationName(cmd)

slog.InfoContext(ctx, "Getting feature", "key", key)
slog.InfoContext(ctx, "Getting feature", "key", key, "application", app)
result, err := fc.Get(ctx, &feature.Key{
Name: key,
Name: key,
Application: app,
})
if err == nil {
cmd.Writer.Write([]byte(result.Name + "\n"))
Expand Down
8 changes: 5 additions & 3 deletions cli/command/preset/preset.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ func PreSet(ctx context.Context, cmd *cli.Command) error {

key := cmd.StringArg("key")
value := cmd.StringArg("value")
app := command.GetApplicationName(cmd)

slog.Info("PreSetting feature", "key", key, "value", value)
slog.Info("PreSetting feature", "key", key, "value", value, "application", app)
_, err = fc.PreSet(ctx, &feature.KeyValue{
Key: key,
Value: value,
Key: key,
Value: value,
Application: app,
})
return err
}
Loading