From 235a3c037840cbe4fd266c278a9a27a62fcca619 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:11:09 +0000 Subject: [PATCH 1/6] Initial plan From cf782882780b2b44882102f6e23c6ed6ff6c027a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:20:51 +0000 Subject: [PATCH 2/6] Add multi-application support to proto, service, and CLI Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- api/feature/feature.proto | 12 ++ cli/command/applications/applications.go | 35 ++++ cli/command/command.go | 9 + cli/command/delete/delete.go | 7 +- cli/command/get/get.go | 6 +- cli/command/preset/preset.go | 8 +- cli/command/set/set.go | 8 +- cli/constant/constant.go | 2 + cli/main.go | 21 +++ cli/repository/feature/v1/feature.pb.go | 164 +++++++++++++++--- cli/repository/feature/v1/feature_grpc.pb.go | 53 +++++- cli/repository/meta/v1/meta.pb.go | 2 +- cli/repository/meta/v1/meta_grpc.pb.go | 2 +- cli/repository/workload/v1/workload.pb.go | 2 +- .../workload/v1/workload_grpc.pb.go | 2 +- service/service/feature/feature.go | 159 ++++++++++++++--- service/service/feature/v1/feature.pb.go | 164 +++++++++++++++--- service/service/feature/v1/feature_grpc.pb.go | 53 +++++- service/service/meta/v1/meta.pb.go | 2 +- service/service/meta/v1/meta_grpc.pb.go | 2 +- service/service/service.go | 93 +++++++--- service/service/workload/v1/workload.pb.go | 2 +- .../service/workload/v1/workload_grpc.pb.go | 2 +- ui/repository/feature/v1/feature.pb.go | 164 +++++++++++++++--- ui/repository/feature/v1/feature_grpc.pb.go | 53 +++++- ui/repository/meta/v1/meta.pb.go | 2 +- ui/repository/meta/v1/meta_grpc.pb.go | 2 +- ui/repository/workload/v1/workload.pb.go | 2 +- ui/repository/workload/v1/workload_grpc.pb.go | 2 +- 29 files changed, 883 insertions(+), 152 deletions(-) create mode 100644 cli/command/applications/applications.go diff --git a/api/feature/feature.proto b/api/feature/feature.proto index 1b91bb7..25ca8a7 100644 --- a/api/feature/feature.proto +++ b/api/feature/feature.proto @@ -8,6 +8,7 @@ import "google/protobuf/empty.proto"; message Key { string name = 1; + string application = 2; } message Value { @@ -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 { @@ -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); } diff --git a/cli/command/applications/applications.go b/cli/command/applications/applications.go new file mode 100644 index 0000000..810a284 --- /dev/null +++ b/cli/command/applications/applications.go @@ -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 +} diff --git a/cli/command/command.go b/cli/command/command.go index 931131c..fc824e4 100644 --- a/cli/command/command.go +++ b/cli/command/command.go @@ -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) diff --git a/cli/command/delete/delete.go b/cli/command/delete/delete.go index ac43451..30da44c 100644 --- a/cli/command/delete/delete.go +++ b/cli/command/delete/delete.go @@ -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 } diff --git a/cli/command/get/get.go b/cli/command/get/get.go index 2ece98c..651c7d5 100644 --- a/cli/command/get/get.go +++ b/cli/command/get/get.go @@ -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")) diff --git a/cli/command/preset/preset.go b/cli/command/preset/preset.go index 8f13912..0507b9c 100644 --- a/cli/command/preset/preset.go +++ b/cli/command/preset/preset.go @@ -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 } diff --git a/cli/command/set/set.go b/cli/command/set/set.go index 4720e73..dfd7177 100644 --- a/cli/command/set/set.go +++ b/cli/command/set/set.go @@ -24,11 +24,13 @@ func Set(ctx context.Context, cmd *cli.Command) error { key := cmd.StringArg("key") value := cmd.StringArg("value") + app := command.GetApplicationName(cmd) - slog.Info("Setting feature", "key", key, "value", value) + slog.Info("Setting feature", "key", key, "value", value, "application", app) _, err = fc.Set(ctx, &feature.KeyValue{ - Key: key, - Value: value, + Key: key, + Value: value, + Application: app, }) // Check if the error is a PermissionDenied error diff --git a/cli/constant/constant.go b/cli/constant/constant.go index 5568d7a..ff875ff 100644 --- a/cli/constant/constant.go +++ b/cli/constant/constant.go @@ -14,4 +14,6 @@ const ( OpenTelemetryEndpoint = "opentelemetry-endpoint" Username = "username" Password = "password" + Application = "application" + DefaultApplication = "default-application" ) diff --git a/cli/main.go b/cli/main.go index 03a47a4..f00c25e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/dkrizic/feature/cli/command/applications" "github.com/dkrizic/feature/cli/command/delete" "github.com/dkrizic/feature/cli/command/get" "github.com/dkrizic/feature/cli/command/getall" @@ -92,6 +93,21 @@ func main() { Usage: "Password for backend service authentication", Sources: cli.EnvVars("PASSWORD"), }, + &cli.StringFlag{ + Name: constant.Application, + Aliases: []string{"a"}, + Value: "", + Category: "application", + Usage: "Application name (defaults to default application if not specified)", + Sources: cli.EnvVars("APPLICATION"), + }, + &cli.StringFlag{ + Name: constant.DefaultApplication, + Value: "", + Category: "application", + Usage: "Default application name to use when -a is not specified", + Sources: cli.EnvVars("DEFAULT_APPLICATION"), + }, }, Before: before, After: after, @@ -104,6 +120,11 @@ func main() { return nil }, }, + &cli.Command{ + Name: "applications", + Usage: "List all configured applications", + Action: applications.Applications, + }, &cli.Command{ Name: "getall", Usage: "Get all features", diff --git a/cli/repository/feature/v1/feature.pb.go b/cli/repository/feature/v1/feature.pb.go index e4e7f58..d4dfb65 100644 --- a/cli/repository/feature/v1/feature.pb.go +++ b/cli/repository/feature/v1/feature.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: feature.proto package featurev1 @@ -25,6 +25,7 @@ const ( type Key struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Application string `protobuf:"bytes,2,opt,name=application,proto3" json:"application,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -66,6 +67,13 @@ func (x *Key) GetName() string { return "" } +func (x *Key) GetApplication() string { + if x != nil { + return x.Application + } + return "" +} + type Value struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -115,6 +123,7 @@ type KeyValue struct { Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` Editable bool `protobuf:"varint,3,opt,name=editable,proto3" json:"editable,omitempty"` + Application string `protobuf:"bytes,4,opt,name=application,proto3" json:"application,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -170,26 +179,137 @@ func (x *KeyValue) GetEditable() bool { return false } +func (x *KeyValue) GetApplication() string { + if x != nil { + return x.Application + } + return "" +} + +type ApplicationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplicationsRequest) Reset() { + *x = ApplicationsRequest{} + mi := &file_feature_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplicationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplicationsRequest) ProtoMessage() {} + +func (x *ApplicationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_feature_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplicationsRequest.ProtoReflect.Descriptor instead. +func (*ApplicationsRequest) Descriptor() ([]byte, []int) { + return file_feature_proto_rawDescGZIP(), []int{3} +} + +type Application struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + StorageType string `protobuf:"bytes,3,opt,name=storage_type,json=storageType,proto3" json:"storage_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Application) Reset() { + *x = Application{} + mi := &file_feature_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Application) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Application) ProtoMessage() {} + +func (x *Application) ProtoReflect() protoreflect.Message { + mi := &file_feature_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Application.ProtoReflect.Descriptor instead. +func (*Application) Descriptor() ([]byte, []int) { + return file_feature_proto_rawDescGZIP(), []int{4} +} + +func (x *Application) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Application) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *Application) GetStorageType() string { + if x != nil { + return x.StorageType + } + return "" +} + var File_feature_proto protoreflect.FileDescriptor const file_feature_proto_rawDesc = "" + "\n" + "\rfeature.proto\x12\n" + - "feature.v1\x1a\x1bgoogle/protobuf/empty.proto\"\x19\n" + + "feature.v1\x1a\x1bgoogle/protobuf/empty.proto\";\n" + "\x03Key\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"\x1b\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vapplication\x18\x02 \x01(\tR\vapplication\"\x1b\n" + "\x05Value\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"N\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"p\n" + "\bKeyValue\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\x12\x1a\n" + - "\beditable\x18\x03 \x01(\bR\beditable2\x8e\x02\n" + + "\beditable\x18\x03 \x01(\bR\beditable\x12 \n" + + "\vapplication\x18\x04 \x01(\tR\vapplication\"\x15\n" + + "\x13ApplicationsRequest\"b\n" + + "\vApplication\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tnamespace\x18\x02 \x01(\tR\tnamespace\x12!\n" + + "\fstorage_type\x18\x03 \x01(\tR\vstorageType2\xdd\x02\n" + "\aFeature\x128\n" + "\x06GetAll\x12\x16.google.protobuf.Empty\x1a\x14.feature.v1.KeyValue0\x01\x126\n" + "\x06PreSet\x12\x14.feature.v1.KeyValue\x1a\x16.google.protobuf.Empty\x123\n" + "\x03Set\x12\x14.feature.v1.KeyValue\x1a\x16.google.protobuf.Empty\x12)\n" + "\x03Get\x12\x0f.feature.v1.Key\x1a\x11.feature.v1.Value\x121\n" + - "\x06Delete\x12\x0f.feature.v1.Key\x1a\x16.google.protobuf.EmptyBHZFgithub.com/dkrizic/feature/service/service/feature/featurev1;featurev1b\x06proto3" + "\x06Delete\x12\x0f.feature.v1.Key\x1a\x16.google.protobuf.Empty\x12M\n" + + "\x0fGetApplications\x12\x1f.feature.v1.ApplicationsRequest\x1a\x17.feature.v1.Application0\x01BHZFgithub.com/dkrizic/feature/service/service/feature/featurev1;featurev1b\x06proto3" var ( file_feature_proto_rawDescOnce sync.Once @@ -203,26 +323,30 @@ func file_feature_proto_rawDescGZIP() []byte { return file_feature_proto_rawDescData } -var file_feature_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_feature_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_feature_proto_goTypes = []any{ - (*Key)(nil), // 0: feature.v1.Key - (*Value)(nil), // 1: feature.v1.Value - (*KeyValue)(nil), // 2: feature.v1.KeyValue - (*emptypb.Empty)(nil), // 3: google.protobuf.Empty + (*Key)(nil), // 0: feature.v1.Key + (*Value)(nil), // 1: feature.v1.Value + (*KeyValue)(nil), // 2: feature.v1.KeyValue + (*ApplicationsRequest)(nil), // 3: feature.v1.ApplicationsRequest + (*Application)(nil), // 4: feature.v1.Application + (*emptypb.Empty)(nil), // 5: google.protobuf.Empty } var file_feature_proto_depIdxs = []int32{ - 3, // 0: feature.v1.Feature.GetAll:input_type -> google.protobuf.Empty + 5, // 0: feature.v1.Feature.GetAll:input_type -> google.protobuf.Empty 2, // 1: feature.v1.Feature.PreSet:input_type -> feature.v1.KeyValue 2, // 2: feature.v1.Feature.Set:input_type -> feature.v1.KeyValue 0, // 3: feature.v1.Feature.Get:input_type -> feature.v1.Key 0, // 4: feature.v1.Feature.Delete:input_type -> feature.v1.Key - 2, // 5: feature.v1.Feature.GetAll:output_type -> feature.v1.KeyValue - 3, // 6: feature.v1.Feature.PreSet:output_type -> google.protobuf.Empty - 3, // 7: feature.v1.Feature.Set:output_type -> google.protobuf.Empty - 1, // 8: feature.v1.Feature.Get:output_type -> feature.v1.Value - 3, // 9: feature.v1.Feature.Delete:output_type -> google.protobuf.Empty - 5, // [5:10] is the sub-list for method output_type - 0, // [0:5] is the sub-list for method input_type + 3, // 5: feature.v1.Feature.GetApplications:input_type -> feature.v1.ApplicationsRequest + 2, // 6: feature.v1.Feature.GetAll:output_type -> feature.v1.KeyValue + 5, // 7: feature.v1.Feature.PreSet:output_type -> google.protobuf.Empty + 5, // 8: feature.v1.Feature.Set:output_type -> google.protobuf.Empty + 1, // 9: feature.v1.Feature.Get:output_type -> feature.v1.Value + 5, // 10: feature.v1.Feature.Delete:output_type -> google.protobuf.Empty + 4, // 11: feature.v1.Feature.GetApplications:output_type -> feature.v1.Application + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -239,7 +363,7 @@ func file_feature_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_feature_proto_rawDesc), len(file_feature_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/repository/feature/v1/feature_grpc.pb.go b/cli/repository/feature/v1/feature_grpc.pb.go index 3b0a8e2..cc24e03 100644 --- a/cli/repository/feature/v1/feature_grpc.pb.go +++ b/cli/repository/feature/v1/feature_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: feature.proto package featurev1 @@ -20,11 +20,12 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Feature_GetAll_FullMethodName = "/feature.v1.Feature/GetAll" - Feature_PreSet_FullMethodName = "/feature.v1.Feature/PreSet" - Feature_Set_FullMethodName = "/feature.v1.Feature/Set" - Feature_Get_FullMethodName = "/feature.v1.Feature/Get" - Feature_Delete_FullMethodName = "/feature.v1.Feature/Delete" + Feature_GetAll_FullMethodName = "/feature.v1.Feature/GetAll" + Feature_PreSet_FullMethodName = "/feature.v1.Feature/PreSet" + Feature_Set_FullMethodName = "/feature.v1.Feature/Set" + Feature_Get_FullMethodName = "/feature.v1.Feature/Get" + Feature_Delete_FullMethodName = "/feature.v1.Feature/Delete" + Feature_GetApplications_FullMethodName = "/feature.v1.Feature/GetApplications" ) // FeatureClient is the client API for Feature service. @@ -36,6 +37,7 @@ type FeatureClient interface { Set(ctx context.Context, in *KeyValue, opts ...grpc.CallOption) (*emptypb.Empty, error) Get(ctx context.Context, in *Key, opts ...grpc.CallOption) (*Value, error) Delete(ctx context.Context, in *Key, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetApplications(ctx context.Context, in *ApplicationsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Application], error) } type featureClient struct { @@ -105,6 +107,25 @@ func (c *featureClient) Delete(ctx context.Context, in *Key, opts ...grpc.CallOp return out, nil } +func (c *featureClient) GetApplications(ctx context.Context, in *ApplicationsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Application], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Feature_ServiceDesc.Streams[1], Feature_GetApplications_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ApplicationsRequest, Application]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Feature_GetApplicationsClient = grpc.ServerStreamingClient[Application] + // FeatureServer is the server API for Feature service. // All implementations must embed UnimplementedFeatureServer // for forward compatibility. @@ -114,6 +135,7 @@ type FeatureServer interface { Set(context.Context, *KeyValue) (*emptypb.Empty, error) Get(context.Context, *Key) (*Value, error) Delete(context.Context, *Key) (*emptypb.Empty, error) + GetApplications(*ApplicationsRequest, grpc.ServerStreamingServer[Application]) error mustEmbedUnimplementedFeatureServer() } @@ -139,6 +161,9 @@ func (UnimplementedFeatureServer) Get(context.Context, *Key) (*Value, error) { func (UnimplementedFeatureServer) Delete(context.Context, *Key) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method Delete not implemented") } +func (UnimplementedFeatureServer) GetApplications(*ApplicationsRequest, grpc.ServerStreamingServer[Application]) error { + return status.Error(codes.Unimplemented, "method GetApplications not implemented") +} func (UnimplementedFeatureServer) mustEmbedUnimplementedFeatureServer() {} func (UnimplementedFeatureServer) testEmbeddedByValue() {} @@ -243,6 +268,17 @@ func _Feature_Delete_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Feature_GetApplications_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ApplicationsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(FeatureServer).GetApplications(m, &grpc.GenericServerStream[ApplicationsRequest, Application]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Feature_GetApplicationsServer = grpc.ServerStreamingServer[Application] + // Feature_ServiceDesc is the grpc.ServiceDesc for Feature service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -273,6 +309,11 @@ var Feature_ServiceDesc = grpc.ServiceDesc{ Handler: _Feature_GetAll_Handler, ServerStreams: true, }, + { + StreamName: "GetApplications", + Handler: _Feature_GetApplications_Handler, + ServerStreams: true, + }, }, Metadata: "feature.proto", } diff --git a/cli/repository/meta/v1/meta.pb.go b/cli/repository/meta/v1/meta.pb.go index b8d8747..79dcdae 100644 --- a/cli/repository/meta/v1/meta.pb.go +++ b/cli/repository/meta/v1/meta.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: meta.proto package metav1 diff --git a/cli/repository/meta/v1/meta_grpc.pb.go b/cli/repository/meta/v1/meta_grpc.pb.go index 26118ea..95d0f4b 100644 --- a/cli/repository/meta/v1/meta_grpc.pb.go +++ b/cli/repository/meta/v1/meta_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: meta.proto package metav1 diff --git a/cli/repository/workload/v1/workload.pb.go b/cli/repository/workload/v1/workload.pb.go index 9834469..b24e767 100644 --- a/cli/repository/workload/v1/workload.pb.go +++ b/cli/repository/workload/v1/workload.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: workload.proto package workloadv1 diff --git a/cli/repository/workload/v1/workload_grpc.pb.go b/cli/repository/workload/v1/workload_grpc.pb.go index be06936..837da77 100644 --- a/cli/repository/workload/v1/workload_grpc.pb.go +++ b/cli/repository/workload/v1/workload_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: workload.proto package workloadv1 diff --git a/service/service/feature/feature.go b/service/service/feature/feature.go index f0877c7..4dc139b 100644 --- a/service/service/feature/feature.go +++ b/service/service/feature/feature.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" + "github.com/dkrizic/feature/service/service/application" "github.com/dkrizic/feature/service/service/feature/v1" "github.com/dkrizic/feature/service/service/persistence" "github.com/dkrizic/feature/service/telemetry/localmetrics" @@ -17,6 +18,8 @@ import ( type FeatureService struct { featurev1.UnimplementedFeatureServer + appManager *application.Manager + // Legacy fields for backward compatibility persistence persistence.Persistence editableFields map[string]bool // map of editable field names, empty means all are editable } @@ -57,6 +60,18 @@ func NewFeatureService(p persistence.Persistence, editableFieldsStr string) (*Fe }, nil } +// NewFeatureServiceWithAppManager creates a new feature service with multi-application support +func NewFeatureServiceWithAppManager(appManager *application.Manager) (*FeatureService, error) { + err := localmetrics.New() + if err != nil { + slog.Error("Failed to initialize local metrics", "error", err) + } + + return &FeatureService{ + appManager: appManager, + }, nil +} + // isEditable checks if a field is editable func (fs *FeatureService) isEditable(key string) bool { // If editableFields is empty, all fields are editable @@ -67,19 +82,59 @@ func (fs *FeatureService) isEditable(key string) bool { return fs.editableFields[key] } +// getAppPersistence returns the persistence and editable fields for a specific application +func (fs *FeatureService) getAppPersistence(ctx context.Context, appName string) (persistence.Persistence, map[string]bool, error) { + // If using legacy mode (single application) + if fs.appManager == nil { + return fs.persistence, fs.editableFields, nil + } + + // Multi-application mode + app, err := fs.appManager.GetApplication(appName) + if err != nil { + slog.ErrorContext(ctx, "Failed to get application", "application", appName, "error", err) + return nil, nil, status.Errorf(codes.NotFound, "application not found: %s", appName) + } + + editableFields := make(map[string]bool) + for _, field := range app.EditableList { + editableFields[field] = true + } + + return app.Persistence, editableFields, nil +} + func (fs *FeatureService) GetAll(empty *emptypb.Empty, stream grpc.ServerStreamingServer[featurev1.KeyValue]) error { ctx, span := otel.Tracer("feature/service").Start(stream.Context(), "GetAll") defer span.End() - values, err := fs.persistence.GetAll(ctx) + // For backward compatibility, if no application is specified and we're in legacy mode, + // use the default persistence. Otherwise, we would need application context from metadata + // For now, we'll get all from the default application + var appName string + if fs.appManager != nil { + appName = fs.appManager.GetDefaultApplication() + } + + pers, editableFields, err := fs.getAppPersistence(ctx, appName) + if err != nil { + return err + } + + values, err := pers.GetAll(ctx) if err != nil { return err } for _, kv := range values { + editable := true + if len(editableFields) > 0 { + editable = editableFields[kv.Key] + } err := stream.Send(&featurev1.KeyValue{ - Key: kv.Key, - Value: kv.Value, - Editable: fs.isEditable(kv.Key), + Key: kv.Key, + Value: kv.Value, + Editable: editable, + Application: appName, }) if err != nil { return err @@ -89,7 +144,7 @@ func (fs *FeatureService) GetAll(empty *emptypb.Empty, stream grpc.ServerStreami localmetrics.ActiveGauge().Record(ctx, int64(count)) localmetrics.GetAllCounter().Add(ctx, 1) - slog.InfoContext(ctx, "GetAll completed", "count", count) + slog.InfoContext(ctx, "GetAll completed", "count", count, "application", appName) return nil } @@ -97,15 +152,20 @@ func (fs *FeatureService) PreSet(ctx context.Context, kv *featurev1.KeyValue) (* ctx, span := otel.Tracer("feature/service").Start(ctx, "PreSet") defer span.End() - err := fs.persistence.PreSet(ctx, persistence.KeyValue{ + pers, _, err := fs.getAppPersistence(ctx, kv.Application) + if err != nil { + return nil, err + } + + err = pers.PreSet(ctx, persistence.KeyValue{ Key: kv.Key, Value: kv.Value, }) if err != nil { return nil, err } - slog.InfoContext(ctx, "PreSet completed", "key", kv.Key, "value", kv.Value) - count, err := fs.persistence.Count(ctx) + slog.InfoContext(ctx, "PreSet completed", "key", kv.Key, "value", kv.Value, "application", kv.Application) + count, err := pers.Count(ctx) if err != nil { return nil, err } @@ -118,10 +178,15 @@ func (fs *FeatureService) Set(ctx context.Context, kv *featurev1.KeyValue) (*emp ctx, span := otel.Tracer("feature/service").Start(ctx, "Set") defer span.End() + pers, editableFields, err := fs.getAppPersistence(ctx, kv.Application) + if err != nil { + return nil, err + } + // If editable fields are configured (not empty), additional restrictions apply - if len(fs.editableFields) > 0 { + if len(editableFields) > 0 { // Check if the field already exists by getting all fields - allFields, err := fs.persistence.GetAll(ctx) + allFields, err := pers.GetAll(ctx) if err != nil { return nil, err } @@ -136,26 +201,26 @@ func (fs *FeatureService) Set(ctx context.Context, kv *featurev1.KeyValue) (*emp // If field doesn't exist, creating new fields is not allowed if !fieldExists { - slog.WarnContext(ctx, "Attempt to create new field when editable restrictions are active", "key", kv.Key) + slog.WarnContext(ctx, "Attempt to create new field when editable restrictions are active", "key", kv.Key, "application", kv.Application) return nil, status.Errorf(codes.PermissionDenied, "creating new fields is not allowed when editable restrictions are active") } // Check if the existing field is editable - if !fs.isEditable(kv.Key) { - slog.WarnContext(ctx, "Attempt to set non-editable field", "key", kv.Key) + if !editableFields[kv.Key] { + slog.WarnContext(ctx, "Attempt to set non-editable field", "key", kv.Key, "application", kv.Application) return nil, status.Errorf(codes.PermissionDenied, "field '%s' is not editable", kv.Key) } } - err := fs.persistence.Set(ctx, persistence.KeyValue{ + err = pers.Set(ctx, persistence.KeyValue{ Key: kv.Key, Value: kv.Value, }) if err != nil { return nil, err } - slog.InfoContext(ctx, "Set completed", "key", kv.Key, "value", kv.Value) - count, err := fs.persistence.Count(ctx) + slog.InfoContext(ctx, "Set completed", "key", kv.Key, "value", kv.Value, "application", kv.Application) + count, err := pers.Count(ctx) if err != nil { return nil, err } @@ -168,12 +233,17 @@ func (fs *FeatureService) Get(ctx context.Context, kv *featurev1.Key) (*featurev ctx, span := otel.Tracer("feature/service").Start(ctx, "Get") defer span.End() - result, err := fs.persistence.Get(ctx, kv.Name) + pers, _, err := fs.getAppPersistence(ctx, kv.Application) + if err != nil { + return nil, err + } + + result, err := pers.Get(ctx, kv.Name) if err != nil { return nil, err } - slog.InfoContext(ctx, "Get completed", "key", kv.Name, "value", result) - count, err := fs.persistence.Count(ctx) + slog.InfoContext(ctx, "Get completed", "key", kv.Name, "value", result, "application", kv.Application) + count, err := pers.Count(ctx) if err != nil { return nil, err } @@ -188,18 +258,23 @@ func (fs *FeatureService) Delete(ctx context.Context, kv *featurev1.Key) (*empty ctx, span := otel.Tracer("feature/service").Start(ctx, "Delete") defer span.End() + pers, editableFields, err := fs.getAppPersistence(ctx, kv.Application) + if err != nil { + return nil, err + } + // If editable fields are configured (not empty), deletion is not allowed - if len(fs.editableFields) > 0 { - slog.WarnContext(ctx, "Attempt to delete field when editable restrictions are active", "key", kv.Name) + if len(editableFields) > 0 { + slog.WarnContext(ctx, "Attempt to delete field when editable restrictions are active", "key", kv.Name, "application", kv.Application) return nil, status.Errorf(codes.PermissionDenied, "deleting fields is not allowed when editable restrictions are active") } - err := fs.persistence.Delete(ctx, kv.Name) + err = pers.Delete(ctx, kv.Name) if err != nil { return nil, err } - slog.InfoContext(ctx, "Delete completed", "key", kv.Name) - count, err := fs.persistence.Count(ctx) + slog.InfoContext(ctx, "Delete completed", "key", kv.Name, "application", kv.Application) + count, err := pers.Count(ctx) if err != nil { return nil, err } @@ -207,3 +282,39 @@ func (fs *FeatureService) Delete(ctx context.Context, kv *featurev1.Key) (*empty localmetrics.DeleteCounter().Add(ctx, 1) return &emptypb.Empty{}, nil } + +// GetApplications returns a list of all configured applications +func (fs *FeatureService) GetApplications(req *featurev1.ApplicationsRequest, stream grpc.ServerStreamingServer[featurev1.Application]) error { + ctx, span := otel.Tracer("feature/service").Start(stream.Context(), "GetApplications") + defer span.End() + + if fs.appManager == nil { + // Legacy mode - return default application + err := stream.Send(&featurev1.Application{ + Name: "default", + Namespace: "default", + StorageType: "inmemory", + }) + if err != nil { + return err + } + slog.InfoContext(ctx, "GetApplications completed (legacy mode)", "count", 1) + return nil + } + + // Multi-application mode + apps := fs.appManager.ListApplications() + for _, app := range apps { + err := stream.Send(&featurev1.Application{ + Name: app.Name, + Namespace: app.Namespace, + StorageType: app.StorageType, + }) + if err != nil { + return err + } + } + + slog.InfoContext(ctx, "GetApplications completed", "count", len(apps)) + return nil +} diff --git a/service/service/feature/v1/feature.pb.go b/service/service/feature/v1/feature.pb.go index e4e7f58..d4dfb65 100644 --- a/service/service/feature/v1/feature.pb.go +++ b/service/service/feature/v1/feature.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: feature.proto package featurev1 @@ -25,6 +25,7 @@ const ( type Key struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Application string `protobuf:"bytes,2,opt,name=application,proto3" json:"application,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -66,6 +67,13 @@ func (x *Key) GetName() string { return "" } +func (x *Key) GetApplication() string { + if x != nil { + return x.Application + } + return "" +} + type Value struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -115,6 +123,7 @@ type KeyValue struct { Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` Editable bool `protobuf:"varint,3,opt,name=editable,proto3" json:"editable,omitempty"` + Application string `protobuf:"bytes,4,opt,name=application,proto3" json:"application,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -170,26 +179,137 @@ func (x *KeyValue) GetEditable() bool { return false } +func (x *KeyValue) GetApplication() string { + if x != nil { + return x.Application + } + return "" +} + +type ApplicationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplicationsRequest) Reset() { + *x = ApplicationsRequest{} + mi := &file_feature_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplicationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplicationsRequest) ProtoMessage() {} + +func (x *ApplicationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_feature_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplicationsRequest.ProtoReflect.Descriptor instead. +func (*ApplicationsRequest) Descriptor() ([]byte, []int) { + return file_feature_proto_rawDescGZIP(), []int{3} +} + +type Application struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + StorageType string `protobuf:"bytes,3,opt,name=storage_type,json=storageType,proto3" json:"storage_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Application) Reset() { + *x = Application{} + mi := &file_feature_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Application) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Application) ProtoMessage() {} + +func (x *Application) ProtoReflect() protoreflect.Message { + mi := &file_feature_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Application.ProtoReflect.Descriptor instead. +func (*Application) Descriptor() ([]byte, []int) { + return file_feature_proto_rawDescGZIP(), []int{4} +} + +func (x *Application) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Application) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *Application) GetStorageType() string { + if x != nil { + return x.StorageType + } + return "" +} + var File_feature_proto protoreflect.FileDescriptor const file_feature_proto_rawDesc = "" + "\n" + "\rfeature.proto\x12\n" + - "feature.v1\x1a\x1bgoogle/protobuf/empty.proto\"\x19\n" + + "feature.v1\x1a\x1bgoogle/protobuf/empty.proto\";\n" + "\x03Key\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"\x1b\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vapplication\x18\x02 \x01(\tR\vapplication\"\x1b\n" + "\x05Value\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"N\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"p\n" + "\bKeyValue\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\x12\x1a\n" + - "\beditable\x18\x03 \x01(\bR\beditable2\x8e\x02\n" + + "\beditable\x18\x03 \x01(\bR\beditable\x12 \n" + + "\vapplication\x18\x04 \x01(\tR\vapplication\"\x15\n" + + "\x13ApplicationsRequest\"b\n" + + "\vApplication\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tnamespace\x18\x02 \x01(\tR\tnamespace\x12!\n" + + "\fstorage_type\x18\x03 \x01(\tR\vstorageType2\xdd\x02\n" + "\aFeature\x128\n" + "\x06GetAll\x12\x16.google.protobuf.Empty\x1a\x14.feature.v1.KeyValue0\x01\x126\n" + "\x06PreSet\x12\x14.feature.v1.KeyValue\x1a\x16.google.protobuf.Empty\x123\n" + "\x03Set\x12\x14.feature.v1.KeyValue\x1a\x16.google.protobuf.Empty\x12)\n" + "\x03Get\x12\x0f.feature.v1.Key\x1a\x11.feature.v1.Value\x121\n" + - "\x06Delete\x12\x0f.feature.v1.Key\x1a\x16.google.protobuf.EmptyBHZFgithub.com/dkrizic/feature/service/service/feature/featurev1;featurev1b\x06proto3" + "\x06Delete\x12\x0f.feature.v1.Key\x1a\x16.google.protobuf.Empty\x12M\n" + + "\x0fGetApplications\x12\x1f.feature.v1.ApplicationsRequest\x1a\x17.feature.v1.Application0\x01BHZFgithub.com/dkrizic/feature/service/service/feature/featurev1;featurev1b\x06proto3" var ( file_feature_proto_rawDescOnce sync.Once @@ -203,26 +323,30 @@ func file_feature_proto_rawDescGZIP() []byte { return file_feature_proto_rawDescData } -var file_feature_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_feature_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_feature_proto_goTypes = []any{ - (*Key)(nil), // 0: feature.v1.Key - (*Value)(nil), // 1: feature.v1.Value - (*KeyValue)(nil), // 2: feature.v1.KeyValue - (*emptypb.Empty)(nil), // 3: google.protobuf.Empty + (*Key)(nil), // 0: feature.v1.Key + (*Value)(nil), // 1: feature.v1.Value + (*KeyValue)(nil), // 2: feature.v1.KeyValue + (*ApplicationsRequest)(nil), // 3: feature.v1.ApplicationsRequest + (*Application)(nil), // 4: feature.v1.Application + (*emptypb.Empty)(nil), // 5: google.protobuf.Empty } var file_feature_proto_depIdxs = []int32{ - 3, // 0: feature.v1.Feature.GetAll:input_type -> google.protobuf.Empty + 5, // 0: feature.v1.Feature.GetAll:input_type -> google.protobuf.Empty 2, // 1: feature.v1.Feature.PreSet:input_type -> feature.v1.KeyValue 2, // 2: feature.v1.Feature.Set:input_type -> feature.v1.KeyValue 0, // 3: feature.v1.Feature.Get:input_type -> feature.v1.Key 0, // 4: feature.v1.Feature.Delete:input_type -> feature.v1.Key - 2, // 5: feature.v1.Feature.GetAll:output_type -> feature.v1.KeyValue - 3, // 6: feature.v1.Feature.PreSet:output_type -> google.protobuf.Empty - 3, // 7: feature.v1.Feature.Set:output_type -> google.protobuf.Empty - 1, // 8: feature.v1.Feature.Get:output_type -> feature.v1.Value - 3, // 9: feature.v1.Feature.Delete:output_type -> google.protobuf.Empty - 5, // [5:10] is the sub-list for method output_type - 0, // [0:5] is the sub-list for method input_type + 3, // 5: feature.v1.Feature.GetApplications:input_type -> feature.v1.ApplicationsRequest + 2, // 6: feature.v1.Feature.GetAll:output_type -> feature.v1.KeyValue + 5, // 7: feature.v1.Feature.PreSet:output_type -> google.protobuf.Empty + 5, // 8: feature.v1.Feature.Set:output_type -> google.protobuf.Empty + 1, // 9: feature.v1.Feature.Get:output_type -> feature.v1.Value + 5, // 10: feature.v1.Feature.Delete:output_type -> google.protobuf.Empty + 4, // 11: feature.v1.Feature.GetApplications:output_type -> feature.v1.Application + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -239,7 +363,7 @@ func file_feature_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_feature_proto_rawDesc), len(file_feature_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/service/service/feature/v1/feature_grpc.pb.go b/service/service/feature/v1/feature_grpc.pb.go index 3b0a8e2..cc24e03 100644 --- a/service/service/feature/v1/feature_grpc.pb.go +++ b/service/service/feature/v1/feature_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: feature.proto package featurev1 @@ -20,11 +20,12 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Feature_GetAll_FullMethodName = "/feature.v1.Feature/GetAll" - Feature_PreSet_FullMethodName = "/feature.v1.Feature/PreSet" - Feature_Set_FullMethodName = "/feature.v1.Feature/Set" - Feature_Get_FullMethodName = "/feature.v1.Feature/Get" - Feature_Delete_FullMethodName = "/feature.v1.Feature/Delete" + Feature_GetAll_FullMethodName = "/feature.v1.Feature/GetAll" + Feature_PreSet_FullMethodName = "/feature.v1.Feature/PreSet" + Feature_Set_FullMethodName = "/feature.v1.Feature/Set" + Feature_Get_FullMethodName = "/feature.v1.Feature/Get" + Feature_Delete_FullMethodName = "/feature.v1.Feature/Delete" + Feature_GetApplications_FullMethodName = "/feature.v1.Feature/GetApplications" ) // FeatureClient is the client API for Feature service. @@ -36,6 +37,7 @@ type FeatureClient interface { Set(ctx context.Context, in *KeyValue, opts ...grpc.CallOption) (*emptypb.Empty, error) Get(ctx context.Context, in *Key, opts ...grpc.CallOption) (*Value, error) Delete(ctx context.Context, in *Key, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetApplications(ctx context.Context, in *ApplicationsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Application], error) } type featureClient struct { @@ -105,6 +107,25 @@ func (c *featureClient) Delete(ctx context.Context, in *Key, opts ...grpc.CallOp return out, nil } +func (c *featureClient) GetApplications(ctx context.Context, in *ApplicationsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Application], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Feature_ServiceDesc.Streams[1], Feature_GetApplications_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ApplicationsRequest, Application]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Feature_GetApplicationsClient = grpc.ServerStreamingClient[Application] + // FeatureServer is the server API for Feature service. // All implementations must embed UnimplementedFeatureServer // for forward compatibility. @@ -114,6 +135,7 @@ type FeatureServer interface { Set(context.Context, *KeyValue) (*emptypb.Empty, error) Get(context.Context, *Key) (*Value, error) Delete(context.Context, *Key) (*emptypb.Empty, error) + GetApplications(*ApplicationsRequest, grpc.ServerStreamingServer[Application]) error mustEmbedUnimplementedFeatureServer() } @@ -139,6 +161,9 @@ func (UnimplementedFeatureServer) Get(context.Context, *Key) (*Value, error) { func (UnimplementedFeatureServer) Delete(context.Context, *Key) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method Delete not implemented") } +func (UnimplementedFeatureServer) GetApplications(*ApplicationsRequest, grpc.ServerStreamingServer[Application]) error { + return status.Error(codes.Unimplemented, "method GetApplications not implemented") +} func (UnimplementedFeatureServer) mustEmbedUnimplementedFeatureServer() {} func (UnimplementedFeatureServer) testEmbeddedByValue() {} @@ -243,6 +268,17 @@ func _Feature_Delete_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Feature_GetApplications_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ApplicationsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(FeatureServer).GetApplications(m, &grpc.GenericServerStream[ApplicationsRequest, Application]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Feature_GetApplicationsServer = grpc.ServerStreamingServer[Application] + // Feature_ServiceDesc is the grpc.ServiceDesc for Feature service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -273,6 +309,11 @@ var Feature_ServiceDesc = grpc.ServiceDesc{ Handler: _Feature_GetAll_Handler, ServerStreams: true, }, + { + StreamName: "GetApplications", + Handler: _Feature_GetApplications_Handler, + ServerStreams: true, + }, }, Metadata: "feature.proto", } diff --git a/service/service/meta/v1/meta.pb.go b/service/service/meta/v1/meta.pb.go index b8d8747..79dcdae 100644 --- a/service/service/meta/v1/meta.pb.go +++ b/service/service/meta/v1/meta.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: meta.proto package metav1 diff --git a/service/service/meta/v1/meta_grpc.pb.go b/service/service/meta/v1/meta_grpc.pb.go index 26118ea..95d0f4b 100644 --- a/service/service/meta/v1/meta_grpc.pb.go +++ b/service/service/meta/v1/meta_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: meta.proto package metav1 diff --git a/service/service/service.go b/service/service/service.go index b4839f5..8dd3091 100644 --- a/service/service/service.go +++ b/service/service/service.go @@ -12,6 +12,7 @@ import ( "syscall" "github.com/dkrizic/feature/service/constant" + "github.com/dkrizic/feature/service/service/application" "github.com/dkrizic/feature/service/service/auth" "github.com/dkrizic/feature/service/service/persistence" "github.com/dkrizic/feature/service/service/persistence/factory" @@ -83,27 +84,73 @@ func Service(ctx context.Context, cmd *cli.Command) error { port := cmd.Int("port") slog.InfoContext(ctx, "Configuration", "port", port) - // configure persistence based on storage type - pers, err := factory.NewPersistence(ctx, cmd) + // Check if we're using multi-application mode + applicationsStr := os.Getenv("APPLICATIONS") + var featureService *feature.FeatureService + + if applicationsStr != "" { + // Multi-application mode + slog.InfoContext(ctx, "Using multi-application mode", "applications", applicationsStr) + appManager := application.NewManager() + err := appManager.LoadFromConfig(ctx, cmd) + if err != nil { + slog.ErrorContext(ctx, "Failed to load application configuration", "error", err) + return fmt.Errorf("failed to load application configuration: %w", err) + } + + // Pre-set values for all applications + for _, app := range appManager.ListApplications() { + err := appManager.PreSetApplication(ctx, app.Name) + if err != nil { + return fmt.Errorf("failed to preset application %s: %w", app.Name, err) + } + } + + featureService, err = feature.NewFeatureServiceWithAppManager(appManager) + if err != nil { + slog.Error("Failed to create feature service", "error", err) + return fmt.Errorf("failed to create feature service: %w", err) + } + } else { + // Legacy single-application mode + slog.InfoContext(ctx, "Using legacy single-application mode") + + // configure persistence based on storage type + pers, err := factory.NewPersistence(ctx, cmd) + if err != nil { + slog.ErrorContext(ctx, "Failed to create persistence", "error", err) + return fmt.Errorf("failed to create persistence: %w", err) + } - // check if there is a preset - preset := cmd.StringSlice(constant.PreSet) - for _, kv := range preset { - parts := strings.SplitN(kv, "=", 2) - if len(parts) != 2 { - slog.WarnContext(ctx, "Invalid preset format, expected key=value", "preset", kv) - continue + // check if there is a preset + preset := cmd.StringSlice(constant.PreSet) + for _, kv := range preset { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + slog.WarnContext(ctx, "Invalid preset format, expected key=value", "preset", kv) + continue + } + key := parts[0] + value := parts[1] + slog.InfoContext(ctx, "Pre-setting key-value", "key", key, "value", value) + err := pers.PreSet(ctx, persistence.KeyValue{ + Key: key, + Value: value, + }) + if err != nil { + slog.ErrorContext(ctx, "Failed to pre-set key-value", "key", key, "value", value, "error", err) + return fmt.Errorf("failed to pre-set key-value: %w", err) + } } - key := parts[0] - value := parts[1] - slog.InfoContext(ctx, "Pre-setting key-value", "key", key, "value", value) - err := pers.PreSet(ctx, persistence.KeyValue{ - Key: key, - Value: value, - }) + + // Get editable fields configuration + editableFields := cmd.String(constant.Editable) + + // feature + featureService, err = feature.NewFeatureService(pers, editableFields) if err != nil { - slog.ErrorContext(ctx, "Failed to pre-set key-value", "key", key, "value", value, "error", err) - return fmt.Errorf("failed to pre-set key-value: %w", err) + slog.Error("Failed to create feature service", "error", err) + return fmt.Errorf("failed to create feature service: %w", err) } } @@ -157,15 +204,7 @@ func Service(ctx context.Context, cmd *cli.Command) error { healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) - // Get editable fields configuration - editableFields := cmd.String(constant.Editable) - - // feature - featureService, err := feature.NewFeatureService(pers, editableFields) - if err != nil { - slog.Error("Failed to create feature service", "error", err) - return fmt.Errorf("failed to create feature service: %w", err) - } + // Register feature service featurev1.RegisterFeatureServer(grpcServer, featureService) // workload diff --git a/service/service/workload/v1/workload.pb.go b/service/service/workload/v1/workload.pb.go index 9834469..b24e767 100644 --- a/service/service/workload/v1/workload.pb.go +++ b/service/service/workload/v1/workload.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: workload.proto package workloadv1 diff --git a/service/service/workload/v1/workload_grpc.pb.go b/service/service/workload/v1/workload_grpc.pb.go index be06936..837da77 100644 --- a/service/service/workload/v1/workload_grpc.pb.go +++ b/service/service/workload/v1/workload_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: workload.proto package workloadv1 diff --git a/ui/repository/feature/v1/feature.pb.go b/ui/repository/feature/v1/feature.pb.go index e4e7f58..d4dfb65 100644 --- a/ui/repository/feature/v1/feature.pb.go +++ b/ui/repository/feature/v1/feature.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: feature.proto package featurev1 @@ -25,6 +25,7 @@ const ( type Key struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Application string `protobuf:"bytes,2,opt,name=application,proto3" json:"application,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -66,6 +67,13 @@ func (x *Key) GetName() string { return "" } +func (x *Key) GetApplication() string { + if x != nil { + return x.Application + } + return "" +} + type Value struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -115,6 +123,7 @@ type KeyValue struct { Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` Editable bool `protobuf:"varint,3,opt,name=editable,proto3" json:"editable,omitempty"` + Application string `protobuf:"bytes,4,opt,name=application,proto3" json:"application,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -170,26 +179,137 @@ func (x *KeyValue) GetEditable() bool { return false } +func (x *KeyValue) GetApplication() string { + if x != nil { + return x.Application + } + return "" +} + +type ApplicationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplicationsRequest) Reset() { + *x = ApplicationsRequest{} + mi := &file_feature_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplicationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplicationsRequest) ProtoMessage() {} + +func (x *ApplicationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_feature_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplicationsRequest.ProtoReflect.Descriptor instead. +func (*ApplicationsRequest) Descriptor() ([]byte, []int) { + return file_feature_proto_rawDescGZIP(), []int{3} +} + +type Application struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"` + StorageType string `protobuf:"bytes,3,opt,name=storage_type,json=storageType,proto3" json:"storage_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Application) Reset() { + *x = Application{} + mi := &file_feature_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Application) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Application) ProtoMessage() {} + +func (x *Application) ProtoReflect() protoreflect.Message { + mi := &file_feature_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Application.ProtoReflect.Descriptor instead. +func (*Application) Descriptor() ([]byte, []int) { + return file_feature_proto_rawDescGZIP(), []int{4} +} + +func (x *Application) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Application) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *Application) GetStorageType() string { + if x != nil { + return x.StorageType + } + return "" +} + var File_feature_proto protoreflect.FileDescriptor const file_feature_proto_rawDesc = "" + "\n" + "\rfeature.proto\x12\n" + - "feature.v1\x1a\x1bgoogle/protobuf/empty.proto\"\x19\n" + + "feature.v1\x1a\x1bgoogle/protobuf/empty.proto\";\n" + "\x03Key\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"\x1b\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vapplication\x18\x02 \x01(\tR\vapplication\"\x1b\n" + "\x05Value\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"N\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"p\n" + "\bKeyValue\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\x12\x1a\n" + - "\beditable\x18\x03 \x01(\bR\beditable2\x8e\x02\n" + + "\beditable\x18\x03 \x01(\bR\beditable\x12 \n" + + "\vapplication\x18\x04 \x01(\tR\vapplication\"\x15\n" + + "\x13ApplicationsRequest\"b\n" + + "\vApplication\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" + + "\tnamespace\x18\x02 \x01(\tR\tnamespace\x12!\n" + + "\fstorage_type\x18\x03 \x01(\tR\vstorageType2\xdd\x02\n" + "\aFeature\x128\n" + "\x06GetAll\x12\x16.google.protobuf.Empty\x1a\x14.feature.v1.KeyValue0\x01\x126\n" + "\x06PreSet\x12\x14.feature.v1.KeyValue\x1a\x16.google.protobuf.Empty\x123\n" + "\x03Set\x12\x14.feature.v1.KeyValue\x1a\x16.google.protobuf.Empty\x12)\n" + "\x03Get\x12\x0f.feature.v1.Key\x1a\x11.feature.v1.Value\x121\n" + - "\x06Delete\x12\x0f.feature.v1.Key\x1a\x16.google.protobuf.EmptyBHZFgithub.com/dkrizic/feature/service/service/feature/featurev1;featurev1b\x06proto3" + "\x06Delete\x12\x0f.feature.v1.Key\x1a\x16.google.protobuf.Empty\x12M\n" + + "\x0fGetApplications\x12\x1f.feature.v1.ApplicationsRequest\x1a\x17.feature.v1.Application0\x01BHZFgithub.com/dkrizic/feature/service/service/feature/featurev1;featurev1b\x06proto3" var ( file_feature_proto_rawDescOnce sync.Once @@ -203,26 +323,30 @@ func file_feature_proto_rawDescGZIP() []byte { return file_feature_proto_rawDescData } -var file_feature_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_feature_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_feature_proto_goTypes = []any{ - (*Key)(nil), // 0: feature.v1.Key - (*Value)(nil), // 1: feature.v1.Value - (*KeyValue)(nil), // 2: feature.v1.KeyValue - (*emptypb.Empty)(nil), // 3: google.protobuf.Empty + (*Key)(nil), // 0: feature.v1.Key + (*Value)(nil), // 1: feature.v1.Value + (*KeyValue)(nil), // 2: feature.v1.KeyValue + (*ApplicationsRequest)(nil), // 3: feature.v1.ApplicationsRequest + (*Application)(nil), // 4: feature.v1.Application + (*emptypb.Empty)(nil), // 5: google.protobuf.Empty } var file_feature_proto_depIdxs = []int32{ - 3, // 0: feature.v1.Feature.GetAll:input_type -> google.protobuf.Empty + 5, // 0: feature.v1.Feature.GetAll:input_type -> google.protobuf.Empty 2, // 1: feature.v1.Feature.PreSet:input_type -> feature.v1.KeyValue 2, // 2: feature.v1.Feature.Set:input_type -> feature.v1.KeyValue 0, // 3: feature.v1.Feature.Get:input_type -> feature.v1.Key 0, // 4: feature.v1.Feature.Delete:input_type -> feature.v1.Key - 2, // 5: feature.v1.Feature.GetAll:output_type -> feature.v1.KeyValue - 3, // 6: feature.v1.Feature.PreSet:output_type -> google.protobuf.Empty - 3, // 7: feature.v1.Feature.Set:output_type -> google.protobuf.Empty - 1, // 8: feature.v1.Feature.Get:output_type -> feature.v1.Value - 3, // 9: feature.v1.Feature.Delete:output_type -> google.protobuf.Empty - 5, // [5:10] is the sub-list for method output_type - 0, // [0:5] is the sub-list for method input_type + 3, // 5: feature.v1.Feature.GetApplications:input_type -> feature.v1.ApplicationsRequest + 2, // 6: feature.v1.Feature.GetAll:output_type -> feature.v1.KeyValue + 5, // 7: feature.v1.Feature.PreSet:output_type -> google.protobuf.Empty + 5, // 8: feature.v1.Feature.Set:output_type -> google.protobuf.Empty + 1, // 9: feature.v1.Feature.Get:output_type -> feature.v1.Value + 5, // 10: feature.v1.Feature.Delete:output_type -> google.protobuf.Empty + 4, // 11: feature.v1.Feature.GetApplications:output_type -> feature.v1.Application + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -239,7 +363,7 @@ func file_feature_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_feature_proto_rawDesc), len(file_feature_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/ui/repository/feature/v1/feature_grpc.pb.go b/ui/repository/feature/v1/feature_grpc.pb.go index 3b0a8e2..cc24e03 100644 --- a/ui/repository/feature/v1/feature_grpc.pb.go +++ b/ui/repository/feature/v1/feature_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: feature.proto package featurev1 @@ -20,11 +20,12 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Feature_GetAll_FullMethodName = "/feature.v1.Feature/GetAll" - Feature_PreSet_FullMethodName = "/feature.v1.Feature/PreSet" - Feature_Set_FullMethodName = "/feature.v1.Feature/Set" - Feature_Get_FullMethodName = "/feature.v1.Feature/Get" - Feature_Delete_FullMethodName = "/feature.v1.Feature/Delete" + Feature_GetAll_FullMethodName = "/feature.v1.Feature/GetAll" + Feature_PreSet_FullMethodName = "/feature.v1.Feature/PreSet" + Feature_Set_FullMethodName = "/feature.v1.Feature/Set" + Feature_Get_FullMethodName = "/feature.v1.Feature/Get" + Feature_Delete_FullMethodName = "/feature.v1.Feature/Delete" + Feature_GetApplications_FullMethodName = "/feature.v1.Feature/GetApplications" ) // FeatureClient is the client API for Feature service. @@ -36,6 +37,7 @@ type FeatureClient interface { Set(ctx context.Context, in *KeyValue, opts ...grpc.CallOption) (*emptypb.Empty, error) Get(ctx context.Context, in *Key, opts ...grpc.CallOption) (*Value, error) Delete(ctx context.Context, in *Key, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetApplications(ctx context.Context, in *ApplicationsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Application], error) } type featureClient struct { @@ -105,6 +107,25 @@ func (c *featureClient) Delete(ctx context.Context, in *Key, opts ...grpc.CallOp return out, nil } +func (c *featureClient) GetApplications(ctx context.Context, in *ApplicationsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Application], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Feature_ServiceDesc.Streams[1], Feature_GetApplications_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ApplicationsRequest, Application]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Feature_GetApplicationsClient = grpc.ServerStreamingClient[Application] + // FeatureServer is the server API for Feature service. // All implementations must embed UnimplementedFeatureServer // for forward compatibility. @@ -114,6 +135,7 @@ type FeatureServer interface { Set(context.Context, *KeyValue) (*emptypb.Empty, error) Get(context.Context, *Key) (*Value, error) Delete(context.Context, *Key) (*emptypb.Empty, error) + GetApplications(*ApplicationsRequest, grpc.ServerStreamingServer[Application]) error mustEmbedUnimplementedFeatureServer() } @@ -139,6 +161,9 @@ func (UnimplementedFeatureServer) Get(context.Context, *Key) (*Value, error) { func (UnimplementedFeatureServer) Delete(context.Context, *Key) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method Delete not implemented") } +func (UnimplementedFeatureServer) GetApplications(*ApplicationsRequest, grpc.ServerStreamingServer[Application]) error { + return status.Error(codes.Unimplemented, "method GetApplications not implemented") +} func (UnimplementedFeatureServer) mustEmbedUnimplementedFeatureServer() {} func (UnimplementedFeatureServer) testEmbeddedByValue() {} @@ -243,6 +268,17 @@ func _Feature_Delete_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Feature_GetApplications_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ApplicationsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(FeatureServer).GetApplications(m, &grpc.GenericServerStream[ApplicationsRequest, Application]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Feature_GetApplicationsServer = grpc.ServerStreamingServer[Application] + // Feature_ServiceDesc is the grpc.ServiceDesc for Feature service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -273,6 +309,11 @@ var Feature_ServiceDesc = grpc.ServiceDesc{ Handler: _Feature_GetAll_Handler, ServerStreams: true, }, + { + StreamName: "GetApplications", + Handler: _Feature_GetApplications_Handler, + ServerStreams: true, + }, }, Metadata: "feature.proto", } diff --git a/ui/repository/meta/v1/meta.pb.go b/ui/repository/meta/v1/meta.pb.go index b8d8747..79dcdae 100644 --- a/ui/repository/meta/v1/meta.pb.go +++ b/ui/repository/meta/v1/meta.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: meta.proto package metav1 diff --git a/ui/repository/meta/v1/meta_grpc.pb.go b/ui/repository/meta/v1/meta_grpc.pb.go index 26118ea..95d0f4b 100644 --- a/ui/repository/meta/v1/meta_grpc.pb.go +++ b/ui/repository/meta/v1/meta_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: meta.proto package metav1 diff --git a/ui/repository/workload/v1/workload.pb.go b/ui/repository/workload/v1/workload.pb.go index 9834469..b24e767 100644 --- a/ui/repository/workload/v1/workload.pb.go +++ b/ui/repository/workload/v1/workload.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc v3.21.12 // source: workload.proto package workloadv1 diff --git a/ui/repository/workload/v1/workload_grpc.pb.go b/ui/repository/workload/v1/workload_grpc.pb.go index be06936..837da77 100644 --- a/ui/repository/workload/v1/workload_grpc.pb.go +++ b/ui/repository/workload/v1/workload_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.0 -// - protoc v5.28.3 +// - protoc v3.21.12 // source: workload.proto package workloadv1 From f11b2e5cd42a212329a22d620bc53cdf414536ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:22:11 +0000 Subject: [PATCH 3/6] Update Helm chart for multi-application support Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- charts/feature/templates/cli-configmap.yaml | 5 ++ .../feature/templates/service-configmap.yaml | 32 +++++++++++-- charts/feature/templates/service-rbac.yaml | 48 +++++++++++++++++++ charts/feature/values.yaml | 27 +++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/charts/feature/templates/cli-configmap.yaml b/charts/feature/templates/cli-configmap.yaml index a8dbd20..40b9a81 100644 --- a/charts/feature/templates/cli-configmap.yaml +++ b/charts/feature/templates/cli-configmap.yaml @@ -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 }} diff --git a/charts/feature/templates/service-configmap.yaml b/charts/feature/templates/service-configmap.yaml index ab712b8..a389f88 100644 --- a/charts/feature/templates/service-configmap.yaml +++ b/charts/feature/templates/service-configmap.yaml @@ -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" .Values.service.opentelemetry.enabled | quote }} + 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 }} diff --git a/charts/feature/templates/service-rbac.yaml b/charts/feature/templates/service-rbac.yaml index e04ea96..593003f 100644 --- a/charts/feature/templates/service-rbac.yaml +++ b/charts/feature/templates/service-rbac.yaml @@ -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: @@ -30,3 +77,4 @@ subjects: name: {{ include "feature.serviceAccountName" . }} namespace: {{ .Release.Namespace }} {{- end }} +{{- end }} diff --git a/charts/feature/values.yaml b/charts/feature/values.yaml index 0037c80..6fa6734 100644 --- a/charts/feature/values.yaml +++ b/charts/feature/values.yaml @@ -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: @@ -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 From d09c12852d239e392c9541d98a071e62dd0a53b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:23:35 +0000 Subject: [PATCH 4/6] Add tests and documentation for multi-application support Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/README.md b/README.md index 33decc0..9e7494a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ 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 @@ -58,6 +59,93 @@ graph TD * 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. From 0293cf05f7d7fdc94bd039572845ba818289a067 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:24:03 +0000 Subject: [PATCH 5/6] Add application manager implementation and tests --- service/service/application/application.go | 307 ++++++++++++++++++ .../service/application/application_test.go | 150 +++++++++ 2 files changed, 457 insertions(+) create mode 100644 service/service/application/application.go create mode 100644 service/service/application/application_test.go diff --git a/service/service/application/application.go b/service/service/application/application.go new file mode 100644 index 0000000..8cae2d1 --- /dev/null +++ b/service/service/application/application.go @@ -0,0 +1,307 @@ +package application + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/dkrizic/feature/service/constant" + nf "github.com/dkrizic/feature/service/notifier/factory" + "github.com/dkrizic/feature/service/service/persistence" + "github.com/dkrizic/feature/service/service/persistence/configmap" + "github.com/dkrizic/feature/service/service/persistence/inmemory" + "github.com/dkrizic/feature/service/service/persistence/notifying" + "github.com/urfave/cli/v3" +) + +// Application represents a single application configuration +type Application struct { + Name string + Namespace string + StorageType string + ConfigMap ConfigMapConfig + Workload WorkloadConfig + Persistence persistence.Persistence + EditableList []string +} + +// ConfigMapConfig holds ConfigMap-specific configuration +type ConfigMapConfig struct { + Name string + Preset []string + Editable string +} + +// WorkloadConfig holds workload restart configuration +type WorkloadConfig struct { + Enabled bool + Type string + Name string +} + +// Manager manages multiple applications +type Manager struct { + applications map[string]*Application + defaultApplication string +} + +// NewManager creates a new application manager +func NewManager() *Manager { + return &Manager{ + applications: make(map[string]*Application), + } +} + +// LoadFromConfig loads applications from CLI command configuration +func (m *Manager) LoadFromConfig(ctx context.Context, cmd *cli.Command) error { + // Check if we're using the old single-application config or new multi-application config + applicationsStr := os.Getenv("APPLICATIONS") + + if applicationsStr == "" { + // Legacy single-application mode + return m.loadLegacyConfig(ctx, cmd) + } + + // Multi-application mode + return m.loadMultiApplicationConfig(ctx, cmd, applicationsStr) +} + +// loadLegacyConfig loads a single application from the old configuration format +func (m *Manager) loadLegacyConfig(ctx context.Context, cmd *cli.Command) error { + slog.InfoContext(ctx, "Loading legacy single-application configuration") + + storageType := cmd.String(constant.StorageType) + configMapName := cmd.String(constant.ConfigMapName) + editable := cmd.String(constant.Editable) + preset := cmd.StringSlice(constant.PreSet) + restartEnabled := cmd.Bool(constant.RestartEnabled) + restartType := cmd.String(constant.RestartType) + restartName := cmd.String(constant.RestartName) + + namespace := os.Getenv("POD_NAMESPACE") + if namespace == "" { + namespace = "default" + } + + app := &Application{ + Name: "default", + Namespace: namespace, + StorageType: storageType, + ConfigMap: ConfigMapConfig{ + Name: configMapName, + Preset: preset, + Editable: editable, + }, + Workload: WorkloadConfig{ + Enabled: restartEnabled, + Type: restartType, + Name: restartName, + }, + } + + // Parse editable fields + if editable != "" { + app.EditableList = strings.Split(editable, ",") + for i := range app.EditableList { + app.EditableList[i] = strings.TrimSpace(app.EditableList[i]) + } + } + + // Create persistence + notifier, err := nf.NewNotifier(ctx, cmd) + if err != nil { + return err + } + + switch storageType { + case constant.StorageTypeInMemory: + slog.InfoContext(ctx, "In-memory storage selected for application", "application", app.Name) + app.Persistence = notifying.NewNotifyingPersistence( + inmemory.NewInMemoryPersistence(), notifier, + ) + case constant.StorageTypeConfigMap: + slog.InfoContext(ctx, "ConfigMap storage selected for application", "application", app.Name, "configmap", configMapName) + app.Persistence = notifying.NewNotifyingPersistence( + configmap.NewConfigMapPersistence(configMapName), notifier, + ) + default: + return fmt.Errorf("invalid storage type: %s", storageType) + } + + m.applications[app.Name] = app + m.defaultApplication = app.Name + + slog.InfoContext(ctx, "Loaded application", "name", app.Name, "namespace", app.Namespace, "storage", app.StorageType) + + return nil +} + +// loadMultiApplicationConfig loads multiple applications from environment variables +func (m *Manager) loadMultiApplicationConfig(ctx context.Context, cmd *cli.Command, applicationsStr string) error { + slog.InfoContext(ctx, "Loading multi-application configuration") + + appNames := strings.Split(applicationsStr, ",") + + // Get default application + defaultApp := os.Getenv("DEFAULT_APPLICATION") + if defaultApp == "" && len(appNames) > 0 { + defaultApp = strings.TrimSpace(appNames[0]) + } + m.defaultApplication = defaultApp + + notifier, err := nf.NewNotifier(ctx, cmd) + if err != nil { + return err + } + + for _, appName := range appNames { + appName = strings.TrimSpace(appName) + if appName == "" { + continue + } + + // Load application configuration from environment variables + prefix := strings.ToUpper(strings.ReplaceAll(appName, "-", "_")) + + namespace := os.Getenv(prefix + "_NAMESPACE") + if namespace == "" { + namespace = "default" + } + + storageType := os.Getenv(prefix + "_STORAGE_TYPE") + if storageType == "" { + storageType = constant.StorageTypeInMemory + } + + configMapName := os.Getenv(prefix + "_CONFIGMAP_NAME") + presetStr := os.Getenv(prefix + "_PRESET") + editable := os.Getenv(prefix + "_EDITABLE") + + var preset []string + if presetStr != "" { + preset = strings.Split(presetStr, ",") + } + + restartEnabledStr := os.Getenv(prefix + "_RESTART_ENABLED") + restartEnabled := restartEnabledStr == "true" + restartType := os.Getenv(prefix + "_RESTART_TYPE") + if restartType == "" { + restartType = "deployment" + } + restartName := os.Getenv(prefix + "_RESTART_NAME") + + app := &Application{ + Name: appName, + Namespace: namespace, + StorageType: storageType, + ConfigMap: ConfigMapConfig{ + Name: configMapName, + Preset: preset, + Editable: editable, + }, + Workload: WorkloadConfig{ + Enabled: restartEnabled, + Type: restartType, + Name: restartName, + }, + } + + // Parse editable fields + if editable != "" { + app.EditableList = strings.Split(editable, ",") + for i := range app.EditableList { + app.EditableList[i] = strings.TrimSpace(app.EditableList[i]) + } + } + + // Create persistence for this application + switch storageType { + case constant.StorageTypeInMemory: + slog.InfoContext(ctx, "In-memory storage selected for application", "application", app.Name) + app.Persistence = notifying.NewNotifyingPersistence( + inmemory.NewInMemoryPersistence(), notifier, + ) + case constant.StorageTypeConfigMap: + if configMapName == "" { + return fmt.Errorf("configmap name is required for application %s when using configmap storage", appName) + } + slog.InfoContext(ctx, "ConfigMap storage selected for application", "application", app.Name, "configmap", configMapName, "namespace", namespace) + app.Persistence = notifying.NewNotifyingPersistence( + configmap.NewConfigMapPersistence(configMapName), notifier, + ) + default: + return fmt.Errorf("invalid storage type for application %s: %s", appName, storageType) + } + + m.applications[app.Name] = app + + slog.InfoContext(ctx, "Loaded application", "name", app.Name, "namespace", app.Namespace, "storage", app.StorageType) + } + + if len(m.applications) == 0 { + return fmt.Errorf("no applications configured") + } + + slog.InfoContext(ctx, "Application configuration complete", "count", len(m.applications), "default", m.defaultApplication) + + return nil +} + +// GetApplication returns an application by name +func (m *Manager) GetApplication(name string) (*Application, error) { + if name == "" { + name = m.defaultApplication + } + + app, ok := m.applications[name] + if !ok { + return nil, fmt.Errorf("application not found: %s", name) + } + + return app, nil +} + +// GetDefaultApplication returns the default application name +func (m *Manager) GetDefaultApplication() string { + return m.defaultApplication +} + +// ListApplications returns all configured applications +func (m *Manager) ListApplications() []*Application { + apps := make([]*Application, 0, len(m.applications)) + for _, app := range m.applications { + apps = append(apps, app) + } + return apps +} + +// PreSetApplication applies preset values for a specific application +func (m *Manager) PreSetApplication(ctx context.Context, appName string) error { + app, err := m.GetApplication(appName) + if err != nil { + return err + } + + for _, kv := range app.ConfigMap.Preset { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + slog.WarnContext(ctx, "Invalid preset format, expected key=value", "preset", kv, "application", appName) + continue + } + key := parts[0] + value := parts[1] + slog.InfoContext(ctx, "Pre-setting key-value", "key", key, "value", value, "application", appName) + err := app.Persistence.PreSet(ctx, persistence.KeyValue{ + Key: key, + Value: value, + }) + if err != nil { + slog.ErrorContext(ctx, "Failed to pre-set key-value", "key", key, "value", value, "application", appName, "error", err) + return fmt.Errorf("failed to pre-set key-value for application %s: %w", appName, err) + } + } + + return nil +} diff --git a/service/service/application/application_test.go b/service/service/application/application_test.go new file mode 100644 index 0000000..8540f01 --- /dev/null +++ b/service/service/application/application_test.go @@ -0,0 +1,150 @@ +package application + +import ( + "context" + "os" + "testing" + + "github.com/dkrizic/feature/service/constant" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestManagerLegacyMode(t *testing.T) { + // Test legacy single-application mode + ctx := context.Background() + + // Create a command with legacy configuration + cmd := &cli.Command{} + cmd.Flags = []cli.Flag{ + &cli.StringFlag{Name: constant.StorageType, Value: constant.StorageTypeInMemory}, + &cli.StringFlag{Name: constant.ConfigMapName, Value: ""}, + &cli.StringFlag{Name: constant.Editable, Value: ""}, + &cli.StringSliceFlag{Name: constant.PreSet, Value: []string{"KEY1=value1"}}, + &cli.BoolFlag{Name: constant.RestartEnabled, Value: false}, + &cli.StringFlag{Name: constant.RestartType, Value: "deployment"}, + &cli.StringFlag{Name: constant.RestartName, Value: ""}, + &cli.BoolFlag{Name: constant.NotificationEnabled, Value: false}, + &cli.StringFlag{Name: constant.NotificationType, Value: constant.NotificationTypeLog}, + } + + manager := NewManager() + err := manager.LoadFromConfig(ctx, cmd) + assert.NoError(t, err) + + // Should have one default application + app, err := manager.GetApplication("") + assert.NoError(t, err) + assert.Equal(t, "default", app.Name) + assert.Equal(t, constant.StorageTypeInMemory, app.StorageType) + + // Default application should be "default" + assert.Equal(t, "default", manager.GetDefaultApplication()) + + // Should have one application in the list + apps := manager.ListApplications() + assert.Len(t, apps, 1) +} + +func TestManagerMultiApplicationMode(t *testing.T) { + // Test multi-application mode + ctx := context.Background() + + // Set up environment variables for multi-application mode + os.Setenv("APPLICATIONS", "app1,app2") + os.Setenv("DEFAULT_APPLICATION", "app1") + + // app1 configuration + os.Setenv("APP1_NAMESPACE", "namespace1") + os.Setenv("APP1_STORAGE_TYPE", "inmemory") + os.Setenv("APP1_PRESET", "KEY1=value1") + os.Setenv("APP1_EDITABLE", "KEY1") + + // app2 configuration + os.Setenv("APP2_NAMESPACE", "namespace2") + os.Setenv("APP2_STORAGE_TYPE", "inmemory") + os.Setenv("APP2_PRESET", "KEY2=value2") + os.Setenv("APP2_EDITABLE", "KEY2") + + defer func() { + // Clean up environment variables + os.Unsetenv("APPLICATIONS") + os.Unsetenv("DEFAULT_APPLICATION") + os.Unsetenv("APP1_NAMESPACE") + os.Unsetenv("APP1_STORAGE_TYPE") + os.Unsetenv("APP1_PRESET") + os.Unsetenv("APP1_EDITABLE") + os.Unsetenv("APP2_NAMESPACE") + os.Unsetenv("APP2_STORAGE_TYPE") + os.Unsetenv("APP2_PRESET") + os.Unsetenv("APP2_EDITABLE") + }() + + // Create a command with notification settings + cmd := &cli.Command{} + cmd.Flags = []cli.Flag{ + &cli.BoolFlag{Name: constant.NotificationEnabled, Value: false}, + &cli.StringFlag{Name: constant.NotificationType, Value: constant.NotificationTypeLog}, + } + + manager := NewManager() + err := manager.LoadFromConfig(ctx, cmd) + assert.NoError(t, err) + + // Should have two applications + apps := manager.ListApplications() + assert.Len(t, apps, 2) + + // Check app1 + app1, err := manager.GetApplication("app1") + assert.NoError(t, err) + assert.Equal(t, "app1", app1.Name) + assert.Equal(t, "namespace1", app1.Namespace) + assert.Equal(t, constant.StorageTypeInMemory, app1.StorageType) + assert.Len(t, app1.EditableList, 1) + assert.Equal(t, "KEY1", app1.EditableList[0]) + + // Check app2 + app2, err := manager.GetApplication("app2") + assert.NoError(t, err) + assert.Equal(t, "app2", app2.Name) + assert.Equal(t, "namespace2", app2.Namespace) + assert.Equal(t, constant.StorageTypeInMemory, app2.StorageType) + assert.Len(t, app2.EditableList, 1) + assert.Equal(t, "KEY2", app2.EditableList[0]) + + // Default application should be app1 + assert.Equal(t, "app1", manager.GetDefaultApplication()) + + // Getting application with empty name should return default + app, err := manager.GetApplication("") + assert.NoError(t, err) + assert.Equal(t, "app1", app.Name) +} + +func TestManagerApplicationNotFound(t *testing.T) { + // Test that getting a non-existent application returns an error + ctx := context.Background() + + cmd := &cli.Command{} + cmd.Flags = []cli.Flag{ + &cli.StringFlag{Name: constant.StorageType, Value: constant.StorageTypeInMemory}, + &cli.StringFlag{Name: constant.ConfigMapName, Value: ""}, + &cli.StringFlag{Name: constant.Editable, Value: ""}, + &cli.StringSliceFlag{Name: constant.PreSet, Value: []string{}}, + &cli.BoolFlag{Name: constant.RestartEnabled, Value: false}, + &cli.StringFlag{Name: constant.RestartType, Value: "deployment"}, + &cli.StringFlag{Name: constant.RestartName, Value: ""}, + &cli.BoolFlag{Name: constant.NotificationEnabled, Value: false}, + &cli.StringFlag{Name: constant.NotificationType, Value: constant.NotificationTypeLog}, + } + + manager := NewManager() + err := manager.LoadFromConfig(ctx, cmd) + assert.NoError(t, err) + + // Try to get a non-existent application + _, err = manager.GetApplication("non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "application not found") +} From 21d801b1106fa1a4dc3fc511144e54a8888cb3c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:26:15 +0000 Subject: [PATCH 6/6] Address code review feedback: add isLegacyMode helper and fix opentelemetry config Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- charts/feature/templates/service-configmap.yaml | 4 ++-- service/service/feature/feature.go | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/charts/feature/templates/service-configmap.yaml b/charts/feature/templates/service-configmap.yaml index a389f88..41d9d32 100644 --- a/charts/feature/templates/service-configmap.yaml +++ b/charts/feature/templates/service-configmap.yaml @@ -37,8 +37,8 @@ data: EDITABLE: {{ .Values.service.configMap.editable | quote }} {{- end }} PORT: {{ .Values.service.port | quote }} - OPENTELEMETRY_ENABLED: {{ ternary "true" "false" .Values.service.opentelemetry.enabled | quote }} - OPENTELEMETRY_ENDPOINT: {{ .Values.service.opentelemetry.endpoint | 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 }} diff --git a/service/service/feature/feature.go b/service/service/feature/feature.go index 4dc139b..43f6e6a 100644 --- a/service/service/feature/feature.go +++ b/service/service/feature/feature.go @@ -82,10 +82,15 @@ func (fs *FeatureService) isEditable(key string) bool { return fs.editableFields[key] } +// isLegacyMode returns true if the service is running in legacy single-application mode +func (fs *FeatureService) isLegacyMode() bool { + return fs.appManager == nil +} + // getAppPersistence returns the persistence and editable fields for a specific application func (fs *FeatureService) getAppPersistence(ctx context.Context, appName string) (persistence.Persistence, map[string]bool, error) { // If using legacy mode (single application) - if fs.appManager == nil { + if fs.isLegacyMode() { return fs.persistence, fs.editableFields, nil } @@ -112,7 +117,7 @@ func (fs *FeatureService) GetAll(empty *emptypb.Empty, stream grpc.ServerStreami // use the default persistence. Otherwise, we would need application context from metadata // For now, we'll get all from the default application var appName string - if fs.appManager != nil { + if !fs.isLegacyMode() { appName = fs.appManager.GetDefaultApplication() } @@ -288,7 +293,7 @@ func (fs *FeatureService) GetApplications(req *featurev1.ApplicationsRequest, st ctx, span := otel.Tracer("feature/service").Start(stream.Context(), "GetApplications") defer span.End() - if fs.appManager == nil { + if fs.isLegacyMode() { // Legacy mode - return default application err := stream.Send(&featurev1.Application{ Name: "default",