diff --git a/.gitignore b/.gitignore index b555627b8c..5e00083899 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ go.work go.work.sum docs/ + +.dolt/ +*.db +.beads/ diff --git a/build/testing/integration/environments/copy_test.go b/build/testing/integration/environments/copy_test.go new file mode 100644 index 0000000000..e871f88d27 --- /dev/null +++ b/build/testing/integration/environments/copy_test.go @@ -0,0 +1,157 @@ +package environments + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.flipt.io/build/testing/integration" + "go.flipt.io/flipt/rpc/flipt/core" + "go.flipt.io/flipt/rpc/v2/environments" + "google.golang.org/protobuf/types/known/anypb" +) + +func TestBulkApplyResources_CopyEquivalent(t *testing.T) { + integration.Harness(t, func(t *testing.T, opts integration.TestOpts) { + ctx := context.Background() + + envClient := opts.TokenClientV2(t).Environments() + + const env = integration.DefaultEnvironment + + // Get initial revision. + nl, err := envClient.ListNamespaces(ctx, &environments.ListNamespacesRequest{ + EnvironmentKey: env, + }) + require.NoError(t, err) + revision := nl.Revision + + // Ensure "copy-target" namespace exists. + created, err := envClient.CreateNamespace(ctx, &environments.UpdateNamespaceRequest{ + EnvironmentKey: env, + Key: "copy-target", + Name: "Copy Target", + Revision: revision, + }) + require.NoError(t, err) + revision = created.Revision + + // Create a flag in the default namespace to copy. + flagPayload, err := anypb.New(&core.Flag{ + Key: "copy-me", + Name: "Copy Me", + Enabled: true, + }) + require.NoError(t, err) + + stripAnyTypePrefix(t, flagPayload) + + srcFlag, err := envClient.CreateResource(ctx, &environments.UpdateResourceRequest{ + EnvironmentKey: env, + NamespaceKey: integration.DefaultNamespace, + Key: "copy-me", + Payload: flagPayload, + Revision: revision, + }) + require.NoError(t, err) + revision = srcFlag.Revision + + srcResource, err := envClient.GetResource(ctx, &environments.GetResourceRequest{ + EnvironmentKey: env, + NamespaceKey: integration.DefaultNamespace, + TypeUrl: "flipt.core.Flag", + Key: "copy-me", + }) + require.NoError(t, err) + + t.Run("copy to different namespace", func(t *testing.T) { + resp, err := envClient.BulkApplyResources(ctx, &environments.BulkApplyResourcesRequest{ + EnvironmentKey: env, + NamespaceKeys: []string{"copy-target"}, + Operation: environments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "copy-me", + Payload: srcResource.Resource.Payload, + Revision: revision, + }) + require.NoError(t, err) + + require.Len(t, resp.Results, 1) + assert.Equal(t, environments.OperationStatus_OPERATION_STATUS_SUCCESS, resp.Results[0].Status) + assert.NotEmpty(t, resp.Revision) + + revision = resp.Revision + + // Verify the resource exists in the target namespace. + got, err := envClient.GetResource(ctx, &environments.GetResourceRequest{ + EnvironmentKey: env, + NamespaceKey: "copy-target", + TypeUrl: "flipt.core.Flag", + Key: "copy-me", + }) + require.NoError(t, err) + assert.Equal(t, "copy-me", got.Resource.Key) + }) + + t.Run("conflict strategy FAIL", func(t *testing.T) { + resp, err := envClient.BulkApplyResources(ctx, &environments.BulkApplyResourcesRequest{ + EnvironmentKey: env, + NamespaceKeys: []string{"copy-target"}, + Operation: environments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "copy-me", + Payload: srcResource.Resource.Payload, + OnConflict: environments.ConflictStrategy_CONFLICT_STRATEGY_FAIL, + Revision: revision, + }) + require.NoError(t, err) + require.Len(t, resp.Results, 1) + assert.Equal(t, environments.OperationStatus_OPERATION_STATUS_FAILED, resp.Results[0].Status) + require.NotNil(t, resp.Results[0].Error) + assert.Contains(t, *resp.Results[0].Error, "already exists") + }) + + t.Run("conflict strategy SKIP", func(t *testing.T) { + resp, err := envClient.BulkApplyResources(ctx, &environments.BulkApplyResourcesRequest{ + EnvironmentKey: env, + NamespaceKeys: []string{"copy-target"}, + Operation: environments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "copy-me", + Payload: srcResource.Resource.Payload, + OnConflict: environments.ConflictStrategy_CONFLICT_STRATEGY_SKIP, + Revision: revision, + }) + require.NoError(t, err) + require.Len(t, resp.Results, 1) + assert.Equal(t, environments.OperationStatus_OPERATION_STATUS_SKIPPED, resp.Results[0].Status) + revision = resp.Revision + }) + + t.Run("conflict strategy OVERWRITE", func(t *testing.T) { + resp, err := envClient.BulkApplyResources(ctx, &environments.BulkApplyResourcesRequest{ + EnvironmentKey: env, + NamespaceKeys: []string{"copy-target"}, + Operation: environments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "copy-me", + Payload: srcResource.Resource.Payload, + OnConflict: environments.ConflictStrategy_CONFLICT_STRATEGY_OVERWRITE, + Revision: revision, + }) + require.NoError(t, err) + require.Len(t, resp.Results, 1) + assert.Equal(t, environments.OperationStatus_OPERATION_STATUS_SUCCESS, resp.Results[0].Status) + revision = resp.Revision + }) + + // Cleanup: delete copy-target namespace. + _, err = envClient.DeleteNamespace(ctx, &environments.DeleteNamespaceRequest{ + EnvironmentKey: env, + Key: "copy-target", + Revision: revision, + }) + require.NoError(t, err) + }) +} diff --git a/internal/cmd/grpc.go b/internal/cmd/grpc.go index 0137b71cf3..553764f8dc 100644 --- a/internal/cmd/grpc.go +++ b/internal/cmd/grpc.go @@ -267,7 +267,7 @@ func NewGRPCServer( healthsrv = health.NewServer() ) - envsrv, err := serverenvironments.NewServer(logger, environmentStore) + envsrv, err := serverenvironments.NewServer(logger, licenseManager, environmentStore) if err != nil { return nil, fmt.Errorf("building environments server: %w", err) } diff --git a/internal/server/environments/server.go b/internal/server/environments/server.go index caf0fead8c..e2deb775bf 100644 --- a/internal/server/environments/server.go +++ b/internal/server/environments/server.go @@ -3,28 +3,41 @@ package environments import ( "context" "fmt" + "sort" + "strings" "github.com/samber/lo" "go.flipt.io/flipt/errors" + "go.flipt.io/flipt/internal/coss/license" + "go.flipt.io/flipt/internal/product" "go.flipt.io/flipt/internal/server/authz" "go.flipt.io/flipt/rpc/v2/environments" "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/emptypb" ) var _ environments.EnvironmentsServiceServer = (*Server)(nil) type Server struct { - logger *zap.Logger + logger *zap.Logger + licenseManager license.Manager envs *EnvironmentStore environments.UnimplementedEnvironmentsServiceServer } -func NewServer(logger *zap.Logger, envs *EnvironmentStore) (_ *Server, err error) { - return &Server{logger: logger, envs: envs}, nil +func NewServer(logger *zap.Logger, licenseManager license.Manager, envs *EnvironmentStore) (_ *Server, err error) { + return &Server{logger: logger, licenseManager: licenseManager, envs: envs}, nil +} + +func (s *Server) requirePro() error { + if s.licenseManager == nil || s.licenseManager.Product() != product.Pro { + return errors.ErrUnauthenticatedf("this feature requires a Flipt Pro license") + } + return nil } // RegisterGRPC registers the *Server onto the provided grpc Server. @@ -382,3 +395,487 @@ func (s *Server) DeleteResource(ctx context.Context, req *environments.DeleteRes return resp, nil } + +// knownResourceTypes lists all resource types that should be copied during namespace copy. +var knownResourceTypes = []ResourceType{ + NewResourceType("flipt.core", "Flag"), + NewResourceType("flipt.core", "Segment"), +} + +// CopyNamespace copies all resources from a source namespace/environment to a target. +func (s *Server) CopyNamespace(ctx context.Context, req *environments.CopyNamespaceRequest) (*environments.CopyNamespaceResponse, error) { + if err := s.requirePro(); err != nil { + return nil, err + } + + // Read all resources from source namespace. + srcEnv, err := s.envs.Get(ctx, req.SourceEnvironmentKey) + if err != nil { + return nil, fmt.Errorf("source environment: %w", err) + } + + type typedResources struct { + typ ResourceType + resources []*environments.Resource + } + + var allResources []typedResources + for _, typ := range knownResourceTypes { + var resources []*environments.Resource + if err := srcEnv.View(ctx, typ, func(ctx context.Context, sv ResourceStoreView) error { + resp, err := sv.ListResources(ctx, req.SourceNamespaceKey) + if err != nil { + return err + } + resources = resp.Resources + return nil + }); err != nil { + return nil, fmt.Errorf("listing source %s resources: %w", typ, err) + } + if len(resources) > 0 { + allResources = append(allResources, typedResources{typ: typ, resources: resources}) + } + } + + // Resolve target environment. + tgtEnv, err := s.envs.Get(ctx, req.EnvironmentKey) + if err != nil { + return nil, fmt.Errorf("target environment: %w", err) + } + + // Ensure target namespace exists. + _, err = tgtEnv.GetNamespace(ctx, req.NamespaceKey) + if errors.AsMatch[errors.ErrNotFound](err) { + rev, createErr := tgtEnv.CreateNamespace(ctx, req.Revision, &environments.Namespace{ + Key: req.NamespaceKey, + Name: req.NamespaceKey, + }) + if createErr != nil { + return nil, fmt.Errorf("creating target namespace: %w", createErr) + } + req.Revision = rev + } else if err != nil { + return nil, fmt.Errorf("checking target namespace: %w", err) + } + + resp := &environments.CopyNamespaceResponse{} + + for _, tr := range allResources { + // Pre-check which resources exist in the target to separate + // resources that need writing from those that can be skipped/failed. + existingKeys := make(map[string]bool) + if err := tgtEnv.View(ctx, tr.typ, func(ctx context.Context, sv ResourceStoreView) error { + for _, src := range tr.resources { + _, err := sv.GetResource(ctx, req.NamespaceKey, src.Key) + if err == nil { + existingKeys[src.Key] = true + } else if !errors.AsMatch[errors.ErrNotFound](err) { + msg := err.Error() + resp.Results = append(resp.Results, &environments.CopyNamespaceResourceResult{ + TypeUrl: tr.typ.String(), + Key: src.Key, + Status: environments.OperationStatus_OPERATION_STATUS_FAILED, + Error: &msg, + }) + } + } + return nil + }); err != nil { + return nil, err + } + + // Collect resources that need to be written. + var toWrite []*environments.Resource + for _, src := range tr.resources { + exists := existingKeys[src.Key] + + // Check if already handled as a failed lookup above. + alreadyHandled := false + for _, r := range resp.Results { + if r.TypeUrl == tr.typ.String() && r.Key == src.Key { + alreadyHandled = true + break + } + } + if alreadyHandled { + continue + } + + result := &environments.CopyNamespaceResourceResult{ + TypeUrl: tr.typ.String(), + Key: src.Key, + } + + switch { + case exists && req.OnConflict == environments.ConflictStrategy_CONFLICT_STRATEGY_FAIL: + result.Status = environments.OperationStatus_OPERATION_STATUS_FAILED + msg := fmt.Sprintf("resource already exists: %s/%s", req.NamespaceKey, src.Key) + result.Error = &msg + case exists && req.OnConflict == environments.ConflictStrategy_CONFLICT_STRATEGY_SKIP: + result.Status = environments.OperationStatus_OPERATION_STATUS_SKIPPED + default: + // Needs to be written (create or overwrite). + toWrite = append(toWrite, &environments.Resource{ + NamespaceKey: req.NamespaceKey, + Key: src.Key, + Payload: src.Payload, + }) + } + + // Only append non-write results here; write results are appended after Update. + if result.Status != 0 { + resp.Results = append(resp.Results, result) + } + } + + // Only call Update if there are resources to write. + if len(toWrite) > 0 { + rev, err := tgtEnv.Update(ctx, req.Revision, tr.typ, func(ctx context.Context, sv ResourceStore) error { + for _, tgtResource := range toWrite { + result := &environments.CopyNamespaceResourceResult{ + TypeUrl: tr.typ.String(), + Key: tgtResource.Key, + } + + exists := existingKeys[tgtResource.Key] + var writeErr error + if exists { + writeErr = sv.UpdateResource(ctx, tgtResource) + } else { + writeErr = sv.CreateResource(ctx, tgtResource) + } + + if writeErr != nil { + result.Status = environments.OperationStatus_OPERATION_STATUS_FAILED + msg := writeErr.Error() + result.Error = &msg + } else { + result.Status = environments.OperationStatus_OPERATION_STATUS_SUCCESS + } + + resp.Results = append(resp.Results, result) + } + return nil + }) + if err != nil { + return nil, err + } + req.Revision = rev + } + } + + resp.Revision = req.Revision + return resp, nil +} + +// CompareEnvironments compares resources across source and target env/namespace pairs. +func (s *Server) CompareEnvironments(ctx context.Context, req *environments.CompareEnvironmentsRequest) (*environments.CompareEnvironmentsResponse, error) { + srcEnv, err := s.envs.Get(ctx, req.EnvironmentKey) + if err != nil { + return nil, fmt.Errorf("source environment: %w", err) + } + + tgtEnv, err := s.envs.Get(ctx, req.TargetEnvironmentKey) + if err != nil { + return nil, fmt.Errorf("target environment: %w", err) + } + + types := knownResourceTypes + if len(req.TypeUrls) > 0 { + types = make([]ResourceType, 0, len(req.TypeUrls)) + for _, typeURL := range req.TypeUrls { + typ, parseErr := ParseResourceType(typeURL) + if parseErr != nil { + return nil, parseErr + } + types = append(types, typ) + } + } + + resp := &environments.CompareEnvironmentsResponse{} + for _, typ := range types { + sourceByKey := map[string]*environments.Resource{} + if err := srcEnv.View(ctx, typ, func(ctx context.Context, sv ResourceStoreView) error { + resources, listErr := sv.ListResources(ctx, req.NamespaceKey) + if listErr != nil { + return listErr + } + for _, resource := range resources.Resources { + sourceByKey[resource.Key] = resource + } + return nil + }); err != nil { + return nil, err + } + + targetByKey := map[string]*environments.Resource{} + if err := tgtEnv.View(ctx, typ, func(ctx context.Context, sv ResourceStoreView) error { + resources, listErr := sv.ListResources(ctx, req.TargetNamespaceKey) + if listErr != nil { + return listErr + } + for _, resource := range resources.Resources { + targetByKey[resource.Key] = resource + } + return nil + }); err != nil { + return nil, err + } + + keys := make([]string, 0, len(sourceByKey)+len(targetByKey)) + seen := make(map[string]struct{}, len(sourceByKey)+len(targetByKey)) + for key := range sourceByKey { + seen[key] = struct{}{} + keys = append(keys, key) + } + for key := range targetByKey { + if _, ok := seen[key]; ok { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + source := sourceByKey[key] + target := targetByKey[key] + result := &environments.CompareResourceResult{ + TypeUrl: typ.String(), + Key: key, + Source: source, + Target: target, + } + + switch { + case source != nil && target == nil: + result.Status = environments.CompareStatus_COMPARE_STATUS_SOURCE_ONLY + case source == nil && target != nil: + result.Status = environments.CompareStatus_COMPARE_STATUS_TARGET_ONLY + case source != nil && target != nil && !proto.Equal(source.Payload, target.Payload): + result.Status = environments.CompareStatus_COMPARE_STATUS_DIFFERENT + default: + result.Status = environments.CompareStatus_COMPARE_STATUS_IDENTICAL + } + + resp.Results = append(resp.Results, result) + } + } + + return resp, nil +} + +// BulkApplyResources applies an operation to a resource across multiple namespaces. +func (s *Server) BulkApplyResources(ctx context.Context, req *environments.BulkApplyResourcesRequest) (*environments.BulkApplyResourcesResponse, error) { + if err := s.requirePro(); err != nil { + return nil, err + } + + typeUrl := req.TypeUrl + if typeUrl == "" && req.Payload != nil { + typeUrl = req.Payload.GetTypeUrl() + } + + typ, err := ParseResourceType(typeUrl) + if err != nil { + return nil, err + } + + resp := &environments.BulkApplyResourcesResponse{} + environmentKeys := req.GetEnvironmentKeys() + if len(environmentKeys) == 0 { + environmentKeys = []string{req.EnvironmentKey} + } + + seen := make(map[string]struct{}, len(environmentKeys)) + for _, environmentKey := range environmentKeys { + if _, ok := seen[environmentKey]; ok { + continue + } + + seen[environmentKey] = struct{}{} + env, err := s.envs.Get(ctx, environmentKey) + if err != nil { + for _, nsKey := range req.NamespaceKeys { + msg := err.Error() + resp.Results = append(resp.Results, &environments.BulkApplyNamespaceResult{ + EnvironmentKey: environmentKey, + NamespaceKey: nsKey, + Status: environments.OperationStatus_OPERATION_STATUS_FAILED, + Error: &msg, + }) + } + continue + } + + revision := "" + if environmentKey == req.EnvironmentKey { + revision = req.Revision + } + + wroteAny := false + envResults := make([]*environments.BulkApplyNamespaceResult, 0, len(req.NamespaceKeys)) + envRevision, err := env.Update(ctx, revision, typ, func(ctx context.Context, sv ResourceStore) error { + for _, nsKey := range req.NamespaceKeys { + result := &environments.BulkApplyNamespaceResult{ + EnvironmentKey: environmentKey, + NamespaceKey: nsKey, + } + + status, wrote, opErr := applyBulkOperation(ctx, sv, nsKey, req) + if opErr != nil { + result.Status = environments.OperationStatus_OPERATION_STATUS_FAILED + msg := opErr.Error() + result.Error = &msg + } else { + result.Status = status + wroteAny = wroteAny || wrote + } + + envResults = append(envResults, result) + } + return nil + }) + if err != nil { + // No-op conflict paths can legitimately produce no writes. In that case + // ignore empty commit errors and preserve per-namespace results. + if !wroteAny && strings.Contains(err.Error(), "cannot create empty commit") { + resp.Results = append(resp.Results, envResults...) + if environmentKey == req.EnvironmentKey { + resp.Revision = req.Revision + } + continue + } + + msg := err.Error() + if len(envResults) == 0 { + for _, nsKey := range req.NamespaceKeys { + resp.Results = append(resp.Results, &environments.BulkApplyNamespaceResult{ + EnvironmentKey: environmentKey, + NamespaceKey: nsKey, + Status: environments.OperationStatus_OPERATION_STATUS_FAILED, + Error: &msg, + }) + } + continue + } + + for _, result := range envResults { + result.Status = environments.OperationStatus_OPERATION_STATUS_FAILED + result.Error = &msg + } + + resp.Results = append(resp.Results, envResults...) + continue + } + + resp.Results = append(resp.Results, envResults...) + if environmentKey == req.EnvironmentKey { + resp.Revision = envRevision + } + } + + if resp.Revision == "" { + resp.Revision = req.Revision + } + + return resp, nil +} + +func applyBulkOperation(ctx context.Context, sv ResourceStore, namespaceKey string, req *environments.BulkApplyResourcesRequest) (environments.OperationStatus, bool, error) { + switch req.Operation { + case environments.BulkOperation_BULK_OPERATION_CREATE: + resource := &environments.Resource{ + NamespaceKey: namespaceKey, + Key: req.Key, + Payload: req.Payload, + } + + exists, err := resourceExists(ctx, sv, namespaceKey, req.Key) + if err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + + status, wrote, err := applyCreateWithConflict(ctx, sv, resource, req.OnConflict, exists) + return status, wrote, err + case environments.BulkOperation_BULK_OPERATION_UPDATE: + resource := &environments.Resource{ + NamespaceKey: namespaceKey, + Key: req.Key, + Payload: req.Payload, + } + + if err := sv.UpdateResource(ctx, resource); err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + return environments.OperationStatus_OPERATION_STATUS_SUCCESS, true, nil + case environments.BulkOperation_BULK_OPERATION_DELETE: + if err := sv.DeleteResource(ctx, namespaceKey, req.Key); err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + return environments.OperationStatus_OPERATION_STATUS_SUCCESS, true, nil + case environments.BulkOperation_BULK_OPERATION_UPSERT: + resource := &environments.Resource{ + NamespaceKey: namespaceKey, + Key: req.Key, + Payload: req.Payload, + } + + exists, err := resourceExists(ctx, sv, namespaceKey, req.Key) + if err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + + if exists { + if err := sv.UpdateResource(ctx, resource); err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + return environments.OperationStatus_OPERATION_STATUS_SUCCESS, true, nil + } else if err := sv.CreateResource(ctx, resource); err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + return environments.OperationStatus_OPERATION_STATUS_SUCCESS, true, nil + default: + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, fmt.Errorf("unsupported bulk operation: %s", req.Operation) + } +} + +func applyCreateWithConflict( + ctx context.Context, + sv ResourceStore, + resource *environments.Resource, + onConflict environments.ConflictStrategy, + exists bool, +) (environments.OperationStatus, bool, error) { + if exists { + switch onConflict { + case environments.ConflictStrategy_CONFLICT_STRATEGY_SKIP: + return environments.OperationStatus_OPERATION_STATUS_SKIPPED, false, nil + case environments.ConflictStrategy_CONFLICT_STRATEGY_OVERWRITE: + if err := sv.UpdateResource(ctx, resource); err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + return environments.OperationStatus_OPERATION_STATUS_SUCCESS, true, nil + default: + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, errors.ErrAlreadyExistsf("resource %q in namespace %q already exists", resource.Key, resource.NamespaceKey) + } + } + + if err := sv.CreateResource(ctx, resource); err != nil { + return environments.OperationStatus_OPERATION_STATUS_FAILED, false, err + } + + return environments.OperationStatus_OPERATION_STATUS_SUCCESS, true, nil +} + +func resourceExists(ctx context.Context, sv ResourceStoreView, namespaceKey, key string) (bool, error) { + _, err := sv.GetResource(ctx, namespaceKey, key) + if err == nil { + return true, nil + } + + if errors.AsMatch[errors.ErrNotFound](err) { + return false, nil + } + + return false, err +} diff --git a/internal/server/environments/server_basic_test.go b/internal/server/environments/server_basic_test.go new file mode 100644 index 0000000000..ffdee8e19b --- /dev/null +++ b/internal/server/environments/server_basic_test.go @@ -0,0 +1,54 @@ +package environments + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +func TestNewServerAndRegisterGRPC(t *testing.T) { + s, err := NewServer(zap.NewNop(), nil, &EnvironmentStore{}) + require.NoError(t, err) + require.NotNil(t, s) + + grpcServer := grpc.NewServer() + assert.NotPanics(t, func() { + s.RegisterGRPC(grpcServer) + }) +} + +func TestGetNamespace(t *testing.T) { + ctx := t.Context() + env := NewMockEnvironment(t) + + env.EXPECT(). + GetNamespace(ctx, "default"). + Return(&rpcenvironments.NamespaceResponse{ + Namespace: &rpcenvironments.Namespace{ + Key: "default", + }, + Revision: "rev-1", + }, nil). + Once() + + s := &Server{ + logger: zap.NewNop(), + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": env, + }, + }, + } + + resp, err := s.GetNamespace(ctx, &rpcenvironments.GetNamespaceRequest{ + EnvironmentKey: "env-a", + Key: "default", + }) + require.NoError(t, err) + assert.Equal(t, "default", resp.Namespace.Key) + assert.Equal(t, "rev-1", resp.Revision) +} diff --git a/internal/server/environments/server_bulk_apply_test.go b/internal/server/environments/server_bulk_apply_test.go new file mode 100644 index 0000000000..ba28ee1a36 --- /dev/null +++ b/internal/server/environments/server_bulk_apply_test.go @@ -0,0 +1,214 @@ +package environments + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.flipt.io/flipt/internal/coss/license" + "go.flipt.io/flipt/internal/product" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/anypb" +) + +func TestBulkApplyResources_PartialFailurePerEnvironment(t *testing.T) { + ctx := t.Context() + + licenseManager := license.NewMockManager(t) + licenseManager.EXPECT().Product().Return(product.Pro) + + envA := NewMockEnvironment(t) + envB := NewMockEnvironment(t) + + envA.EXPECT(). + Update(ctx, "rev-1", NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + require.NoError(t, fn(ctx, store)) + return "rev-2", nil + }). + Once() + + envB.EXPECT(). + Update(ctx, "", NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + require.NoError(t, fn(ctx, store)) + return "", errors.New("commit failed") + }). + Once() + + s := &Server{ + logger: zap.NewNop(), + licenseManager: licenseManager, + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": envA, + "env-b": envB, + }, + }, + } + + resp, err := s.BulkApplyResources(ctx, &rpcenvironments.BulkApplyResourcesRequest{ + EnvironmentKey: "env-a", + EnvironmentKeys: []string{ + "env-a", + "missing-env", + "env-b", + }, + NamespaceKeys: []string{"default"}, + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "feature_a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + OnConflict: rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_OVERWRITE, + Revision: "rev-1", + }) + + require.NoError(t, err) + assert.Equal(t, "rev-2", resp.Revision) + require.Len(t, resp.Results, 3) + + byEnv := map[string]*rpcenvironments.BulkApplyNamespaceResult{} + for _, result := range resp.Results { + byEnv[result.EnvironmentKey] = result + } + + require.Contains(t, byEnv, "env-a") + require.Contains(t, byEnv, "missing-env") + require.Contains(t, byEnv, "env-b") + + assert.Equal( + t, + rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, + byEnv["env-a"].Status, + ) + assert.Equal( + t, + rpcenvironments.OperationStatus_OPERATION_STATUS_FAILED, + byEnv["missing-env"].Status, + ) + assert.Contains(t, byEnv["missing-env"].GetError(), "environment") + assert.Equal( + t, + rpcenvironments.OperationStatus_OPERATION_STATUS_FAILED, + byEnv["env-b"].Status, + ) + assert.Contains(t, byEnv["env-b"].GetError(), "commit failed") +} + +func TestBulkApplyResources_NoOpEmptyCommitPreservesSkippedResults(t *testing.T) { + ctx := t.Context() + + licenseManager := license.NewMockManager(t) + licenseManager.EXPECT().Product().Return(product.Pro) + + env := NewMockEnvironment(t) + env.EXPECT(). + Update(ctx, "rev-1", NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "feature_a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "feature_a", + } + require.NoError(t, fn(ctx, store)) + return "", errors.New("cannot create empty commit") + }). + Once() + + s := &Server{ + logger: zap.NewNop(), + licenseManager: licenseManager, + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": env, + }, + }, + } + + resp, err := s.BulkApplyResources(ctx, &rpcenvironments.BulkApplyResourcesRequest{ + EnvironmentKey: "env-a", + NamespaceKeys: []string{"default"}, + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "feature_a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + OnConflict: rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_SKIP, + Revision: "rev-1", + }) + + require.NoError(t, err) + assert.Equal(t, "rev-1", resp.Revision) + require.Len(t, resp.Results, 1) + assert.Equal( + t, + rpcenvironments.OperationStatus_OPERATION_STATUS_SKIPPED, + resp.Results[0].Status, + ) + assert.Equal(t, "env-a", resp.Results[0].EnvironmentKey) + assert.Equal(t, "default", resp.Results[0].NamespaceKey) +} + +func TestBulkApplyResources_DeduplicatesEnvironmentKeys(t *testing.T) { + ctx := t.Context() + + licenseManager := license.NewMockManager(t) + licenseManager.EXPECT().Product().Return(product.Pro) + + envA := NewMockEnvironment(t) + envB := NewMockEnvironment(t) + + envA.EXPECT(). + Update(ctx, "rev-1", NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + require.NoError(t, fn(ctx, store)) + return "rev-2", nil + }). + Once() + + envB.EXPECT(). + Update(ctx, "", NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + require.NoError(t, fn(ctx, store)) + return "rev-b", nil + }). + Once() + + s := &Server{ + logger: zap.NewNop(), + licenseManager: licenseManager, + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": envA, + "env-b": envB, + }, + }, + } + + resp, err := s.BulkApplyResources(ctx, &rpcenvironments.BulkApplyResourcesRequest{ + EnvironmentKey: "env-a", + EnvironmentKeys: []string{ + "env-a", + "env-a", + "env-b", + }, + NamespaceKeys: []string{"default"}, + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_CREATE, + TypeUrl: "flipt.core.Flag", + Key: "feature_a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + OnConflict: rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_OVERWRITE, + Revision: "rev-1", + }) + + require.NoError(t, err) + assert.Equal(t, "rev-2", resp.Revision) + require.Len(t, resp.Results, 2) +} diff --git a/internal/server/environments/server_compare_test.go b/internal/server/environments/server_compare_test.go new file mode 100644 index 0000000000..d1f105d7ac --- /dev/null +++ b/internal/server/environments/server_compare_test.go @@ -0,0 +1,99 @@ +package environments + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.flipt.io/flipt/internal/coss/license" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/anypb" +) + +func TestCompareEnvironments_ClassifiesResourceStates(t *testing.T) { + ctx := t.Context() + + sourceEnv := NewMockEnvironment(t) + targetEnv := NewMockEnvironment(t) + + sourceEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-same")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-same", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag", Value: []byte("same")}, + } + store.resources[testResourceKey("default", "flag-diff")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-diff", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag", Value: []byte("source")}, + } + store.resources[testResourceKey("default", "flag-source")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-source", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag", Value: []byte("source-only")}, + } + return fn(ctx, store) + }). + Once() + + targetEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-same")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-same", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag", Value: []byte("same")}, + } + store.resources[testResourceKey("default", "flag-diff")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-diff", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag", Value: []byte("target")}, + } + store.resources[testResourceKey("default", "flag-target")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-target", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag", Value: []byte("target-only")}, + } + return fn(ctx, store) + }). + Once() + + s := &Server{ + logger: zap.NewNop(), + licenseManager: license.NewMockManager(t), + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "source": sourceEnv, + "target": targetEnv, + }, + }, + } + + resp, err := s.CompareEnvironments(ctx, &rpcenvironments.CompareEnvironmentsRequest{ + EnvironmentKey: "source", + NamespaceKey: "default", + TargetEnvironmentKey: "target", + TargetNamespaceKey: "default", + TypeUrls: []string{"flipt.core.Flag"}, + }) + + require.NoError(t, err) + require.Len(t, resp.Results, 4) + + results := map[string]rpcenvironments.CompareStatus{} + for _, result := range resp.Results { + results[result.Key] = result.Status + } + + assert.Equal(t, rpcenvironments.CompareStatus_COMPARE_STATUS_IDENTICAL, results["flag-same"]) + assert.Equal(t, rpcenvironments.CompareStatus_COMPARE_STATUS_DIFFERENT, results["flag-diff"]) + assert.Equal(t, rpcenvironments.CompareStatus_COMPARE_STATUS_SOURCE_ONLY, results["flag-source"]) + assert.Equal(t, rpcenvironments.CompareStatus_COMPARE_STATUS_TARGET_ONLY, results["flag-target"]) +} diff --git a/internal/server/environments/server_copy_namespace_test.go b/internal/server/environments/server_copy_namespace_test.go new file mode 100644 index 0000000000..0803b12c4b --- /dev/null +++ b/internal/server/environments/server_copy_namespace_test.go @@ -0,0 +1,177 @@ +package environments + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.flipt.io/flipt/internal/coss/license" + "go.flipt.io/flipt/internal/product" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/anypb" +) + +func TestCopyNamespace_RequiresPro(t *testing.T) { + ctx := t.Context() + licenseManager := license.NewMockManager(t) + licenseManager.EXPECT().Product().Return(product.OSS) + + s := &Server{ + logger: zap.NewNop(), + licenseManager: licenseManager, + envs: &EnvironmentStore{byKey: map[string]Environment{}}, + } + + _, err := s.CopyNamespace(ctx, &rpcenvironments.CopyNamespaceRequest{ + EnvironmentKey: "target", + NamespaceKey: "default", + SourceEnvironmentKey: "source", + SourceNamespaceKey: "default", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a Flipt Pro license") +} + +func TestCopyNamespace_SuccessCreatesResource(t *testing.T) { + ctx := t.Context() + licenseManager := license.NewMockManager(t) + licenseManager.EXPECT().Product().Return(product.Pro) + + sourceEnv := NewMockEnvironment(t) + targetEnv := NewMockEnvironment(t) + + sourceEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + return fn(ctx, store) + }). + Once() + sourceEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Segment"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + return fn(ctx, newTestResourceStore()) + }). + Once() + + targetEnv.EXPECT(). + GetNamespace(ctx, "default"). + Return(&rpcenvironments.NamespaceResponse{}, nil). + Once() + targetEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + return fn(ctx, newTestResourceStore()) + }). + Once() + targetEnv.EXPECT(). + Update(ctx, "rev-1", NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + require.NoError(t, fn(ctx, store)) + assert.Equal(t, 1, store.createCalls) + return "rev-2", nil + }). + Once() + + s := &Server{ + logger: zap.NewNop(), + licenseManager: licenseManager, + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "source": sourceEnv, + "target": targetEnv, + }, + }, + } + + resp, err := s.CopyNamespace(ctx, &rpcenvironments.CopyNamespaceRequest{ + EnvironmentKey: "target", + NamespaceKey: "default", + SourceEnvironmentKey: "source", + SourceNamespaceKey: "default", + OnConflict: rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_FAIL, + Revision: "rev-1", + }) + require.NoError(t, err) + assert.Equal(t, "rev-2", resp.Revision) + require.Len(t, resp.Results, 1) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, resp.Results[0].Status) +} + +func TestCopyNamespace_SkipExistingResourceOnConflictSkip(t *testing.T) { + ctx := t.Context() + licenseManager := license.NewMockManager(t) + licenseManager.EXPECT().Product().Return(product.Pro) + + sourceEnv := NewMockEnvironment(t) + targetEnv := NewMockEnvironment(t) + + sourceEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + return fn(ctx, store) + }). + Once() + sourceEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Segment"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + return fn(ctx, newTestResourceStore()) + }). + Once() + + targetEnv.EXPECT(). + GetNamespace(ctx, "default"). + Return(&rpcenvironments.NamespaceResponse{}, nil). + Once() + targetEnv.EXPECT(). + View(ctx, NewResourceType("flipt.core", "Flag"), mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + return fn(ctx, store) + }). + Once() + + s := &Server{ + logger: zap.NewNop(), + licenseManager: licenseManager, + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "source": sourceEnv, + "target": targetEnv, + }, + }, + } + + resp, err := s.CopyNamespace(ctx, &rpcenvironments.CopyNamespaceRequest{ + EnvironmentKey: "target", + NamespaceKey: "default", + SourceEnvironmentKey: "source", + SourceNamespaceKey: "default", + OnConflict: rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_SKIP, + Revision: "rev-1", + }) + require.NoError(t, err) + assert.Equal(t, "rev-1", resp.Revision) + require.Len(t, resp.Results, 1) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SKIPPED, resp.Results[0].Status) +} diff --git a/internal/server/environments/server_crud_test.go b/internal/server/environments/server_crud_test.go new file mode 100644 index 0000000000..0499e40442 --- /dev/null +++ b/internal/server/environments/server_crud_test.go @@ -0,0 +1,303 @@ +package environments + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + errs "go.flipt.io/flipt/errors" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/anypb" +) + +func TestNamespaceCRUDMethods(t *testing.T) { + ctx := t.Context() + env := NewMockEnvironment(t) + + s := &Server{ + logger: zap.NewNop(), + envs: &EnvironmentStore{ + byKey: map[string]Environment{"env-a": env}, + }, + } + + env.EXPECT(). + GetNamespace(ctx, "default"). + Return(nil, errs.ErrNotFoundf("namespace")). + Once() + env.EXPECT(). + CreateNamespace(ctx, "rev-1", mock.MatchedBy(func(ns *rpcenvironments.Namespace) bool { + return ns.Key == "default" + })). + Return("rev-2", nil). + Once() + + createResp, err := s.CreateNamespace(ctx, &rpcenvironments.UpdateNamespaceRequest{ + EnvironmentKey: "env-a", + Key: "default", + Name: "Default", + Revision: "rev-1", + }) + require.NoError(t, err) + assert.Equal(t, "rev-2", createResp.Revision) + + env.EXPECT(). + GetNamespace(ctx, "default"). + Return(&rpcenvironments.NamespaceResponse{}, nil). + Once() + env.EXPECT(). + UpdateNamespace(ctx, "rev-2", mock.MatchedBy(func(ns *rpcenvironments.Namespace) bool { + return ns.Key == "default" + })). + Return("rev-3", nil). + Once() + + updateResp, err := s.UpdateNamespace(ctx, &rpcenvironments.UpdateNamespaceRequest{ + EnvironmentKey: "env-a", + Key: "default", + Name: "Default", + Revision: "rev-2", + }) + require.NoError(t, err) + assert.Equal(t, "rev-3", updateResp.Revision) + + env.EXPECT(). + DeleteNamespace(ctx, "rev-3", "default"). + Return("rev-4", nil). + Once() + + deleteResp, err := s.DeleteNamespace(ctx, &rpcenvironments.DeleteNamespaceRequest{ + EnvironmentKey: "env-a", + Key: "default", + Revision: "rev-3", + }) + require.NoError(t, err) + assert.Equal(t, "rev-4", deleteResp.Revision) +} + +func TestResourceCRUDMethods(t *testing.T) { + ctx := t.Context() + env := NewMockEnvironment(t) + typ := NewResourceType("flipt.core", "Flag") + + s := &Server{ + logger: zap.NewNop(), + envs: &EnvironmentStore{ + byKey: map[string]Environment{"env-a": env}, + }, + } + + env.EXPECT(). + View(ctx, typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + return fn(ctx, store) + }). + Once() + + getResp, err := s.GetResource(ctx, &rpcenvironments.GetResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + TypeUrl: "flipt.core.Flag", + Key: "flag-a", + }) + require.NoError(t, err) + assert.Equal(t, "flag-a", getResp.Resource.Key) + + env.EXPECT(). + View(ctx, typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ ResourceType, fn ViewFunc) error { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + return fn(ctx, store) + }). + Once() + + listResp, err := s.ListResources(ctx, &rpcenvironments.ListResourcesRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + TypeUrl: "flipt.core.Flag", + }) + require.NoError(t, err) + require.Len(t, listResp.Resources, 1) + + env.EXPECT(). + Update(ctx, "rev-1", typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + require.NoError(t, fn(ctx, store)) + assert.Equal(t, 1, store.createCalls) + return "rev-2", nil + }). + Once() + + createResp, err := s.CreateResource(ctx, &rpcenvironments.UpdateResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + Key: "flag-new", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + Revision: "rev-1", + }) + require.NoError(t, err) + assert.Equal(t, "rev-2", createResp.Revision) + + env.EXPECT(). + Update(ctx, "rev-2", typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + require.NoError(t, fn(ctx, store)) + assert.Equal(t, 1, store.updateCalls) + return "rev-3", nil + }). + Once() + + updateResp, err := s.UpdateResource(ctx, &rpcenvironments.UpdateResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + Revision: "rev-2", + }) + require.NoError(t, err) + assert.Equal(t, "rev-3", updateResp.Revision) + + env.EXPECT(). + Update(ctx, "rev-3", typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + require.NoError(t, fn(ctx, store)) + _, exists := store.resources[testResourceKey("default", "flag-a")] + assert.False(t, exists) + return "rev-4", nil + }). + Once() + + deleteResp, err := s.DeleteResource(ctx, &rpcenvironments.DeleteResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + TypeUrl: "flipt.core.Flag", + Key: "flag-a", + Revision: "rev-3", + }) + require.NoError(t, err) + assert.Equal(t, "rev-4", deleteResp.Revision) +} + +func TestNamespaceAndResourceErrorPaths(t *testing.T) { + ctx := t.Context() + env := NewMockEnvironment(t) + typ := NewResourceType("flipt.core", "Flag") + + s := &Server{ + logger: zap.NewNop(), + envs: &EnvironmentStore{ + byKey: map[string]Environment{"env-a": env}, + }, + } + + env.EXPECT(). + GetNamespace(ctx, "default"). + Return(&rpcenvironments.NamespaceResponse{}, nil). + Once() + + _, err := s.CreateNamespace(ctx, &rpcenvironments.UpdateNamespaceRequest{ + EnvironmentKey: "env-a", + Key: "default", + Name: "Default", + Revision: "rev-1", + }) + require.Error(t, err) + assert.True(t, errs.AsMatch[errs.ErrAlreadyExists](err)) + + env.EXPECT(). + GetNamespace(ctx, "missing"). + Return(nil, errs.ErrNotFoundf("namespace")). + Once() + + _, err = s.UpdateNamespace(ctx, &rpcenvironments.UpdateNamespaceRequest{ + EnvironmentKey: "env-a", + Key: "missing", + Name: "Missing", + Revision: "rev-1", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "update namespace") + + env.EXPECT(). + Update(ctx, "rev-1", typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag-a")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + } + return "", fn(ctx, store) + }). + Once() + + _, err = s.CreateResource(ctx, &rpcenvironments.UpdateResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + Key: "flag-a", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + Revision: "rev-1", + }) + require.Error(t, err) + assert.True(t, errs.AsMatch[errs.ErrAlreadyExists](err)) + + env.EXPECT(). + Update(ctx, "rev-2", typ, mock.Anything). + RunAndReturn(func(ctx context.Context, _ string, _ ResourceType, fn UpdateFunc) (string, error) { + store := newTestResourceStore() + return "", fn(ctx, store) + }). + Once() + + _, err = s.UpdateResource(ctx, &rpcenvironments.UpdateResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + Key: "missing", + Payload: &anypb.Any{TypeUrl: "flipt.core.Flag"}, + Revision: "rev-2", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "update resource") + + env.EXPECT(). + Update(ctx, "rev-3", typ, mock.Anything). + Return("", errs.ErrInvalid("bad revision")). + Once() + + _, err = s.DeleteResource(ctx, &rpcenvironments.DeleteResourceRequest{ + EnvironmentKey: "env-a", + NamespaceKey: "default", + TypeUrl: "flipt.core.Flag", + Key: "flag-a", + Revision: "rev-3", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "bad revision") +} diff --git a/internal/server/environments/server_environment_methods_test.go b/internal/server/environments/server_environment_methods_test.go new file mode 100644 index 0000000000..9cab4f91e1 --- /dev/null +++ b/internal/server/environments/server_environment_methods_test.go @@ -0,0 +1,196 @@ +package environments + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.flipt.io/flipt/internal/server/authz" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" + "go.uber.org/zap" +) + +func TestListEnvironmentBranchesAndChanges(t *testing.T) { + ctx := t.Context() + + env := NewMockEnvironment(t) + branchEnv := NewMockEnvironment(t) + + env.EXPECT(). + ListBranches(ctx). + Return(&rpcenvironments.ListEnvironmentBranchesResponse{ + Branches: []*rpcenvironments.BranchEnvironment{ + {EnvironmentKey: "env-a", Key: "feature-1"}, + }, + }, nil). + Once() + + env.EXPECT(). + Branch(ctx, "feature-1"). + Return(branchEnv, nil). + Twice() + + env.EXPECT(). + ListBranchedChanges(ctx, branchEnv). + Return(&rpcenvironments.ListBranchedEnvironmentChangesResponse{ + Changes: []*rpcenvironments.Change{{Revision: "abc123"}}, + }, nil). + Once() + + env.EXPECT(). + Propose(ctx, branchEnv, ProposalOptions{ + Title: "t", + Body: "b", + Draft: true, + }). + Return(&rpcenvironments.EnvironmentProposalDetails{ + Url: "http://example.local", + }, nil). + Once() + + s := &Server{ + logger: zap.NewNop(), + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": env, + }, + }, + } + + branches, err := s.ListEnvironmentBranches(ctx, &rpcenvironments.ListEnvironmentBranchesRequest{ + EnvironmentKey: "env-a", + }) + require.NoError(t, err) + require.Len(t, branches.Branches, 1) + + changes, err := s.ListBranchedEnvironmentChanges(ctx, &rpcenvironments.ListBranchedEnvironmentChangesRequest{ + EnvironmentKey: "env-a", + Key: "feature-1", + }) + require.NoError(t, err) + require.Len(t, changes.Changes, 1) + + proposal, err := s.ProposeEnvironment(ctx, &rpcenvironments.ProposeEnvironmentRequest{ + EnvironmentKey: "env-a", + Key: "feature-1", + Title: ptr("t"), + Body: ptr("b"), + Draft: ptr(true), + }) + require.NoError(t, err) + assert.Equal(t, "http://example.local", proposal.Url) +} + +func TestListEnvironmentsAndBranchLifecycle(t *testing.T) { + ctx := t.Context() + + baseEnv := NewMockEnvironment(t) + otherEnv := NewMockEnvironment(t) + branchEnv := NewMockEnvironment(t) + + baseEnv.EXPECT().Key().Return("env-a").Maybe() + baseEnv.EXPECT().Default().Return(true).Maybe() + baseEnv.EXPECT().Configuration().Return(nil).Maybe() + baseEnv.EXPECT().Branch(ctx, "feature-1").Return(branchEnv, nil).Once() + baseEnv.EXPECT().DeleteBranch(ctx, "feature-1").Return(nil).Once() + + otherEnv.EXPECT().Key().Return("env-b").Maybe() + otherEnv.EXPECT().Default().Return(false).Maybe() + otherEnv.EXPECT().Configuration().Return(nil).Maybe() + + branchEnv.EXPECT().Key().Return("feature-1").Maybe() + branchEnv.EXPECT().Default().Return(false).Maybe() + branchEnv.EXPECT().Configuration().Return(nil).Maybe() + + store := &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": baseEnv, + "env-b": otherEnv, + }, + defaultEnv: baseEnv, + } + + s := &Server{ + logger: zap.NewNop(), + envs: store, + } + + filteredCtx := contextWithEnvironments(ctx, []string{"env-a"}) + filtered, err := s.ListEnvironments(filteredCtx, &rpcenvironments.ListEnvironmentsRequest{}) + require.NoError(t, err) + require.Len(t, filtered.Environments, 1) + assert.Equal(t, "env-a", filtered.Environments[0].Key) + + allCtx := contextWithEnvironments(ctx, []string{"*"}) + all, err := s.ListEnvironments(allCtx, &rpcenvironments.ListEnvironmentsRequest{}) + require.NoError(t, err) + assert.Len(t, all.Environments, 2) + + branchResp, err := s.BranchEnvironment(ctx, &rpcenvironments.BranchEnvironmentRequest{ + EnvironmentKey: "env-a", + Key: "feature-1", + }) + require.NoError(t, err) + assert.Equal(t, "feature-1", branchResp.Key) + + _, err = s.DeleteBranchEnvironment(ctx, &rpcenvironments.DeleteBranchEnvironmentRequest{ + EnvironmentKey: "env-a", + Key: "feature-1", + }) + require.NoError(t, err) +} + +func TestListNamespaces_RespectsAuthFilter(t *testing.T) { + ctx := t.Context() + env := NewMockEnvironment(t) + + env.EXPECT(). + ListNamespaces(mock.Anything). + RunAndReturn(func(context.Context) (*rpcenvironments.ListNamespacesResponse, error) { + return &rpcenvironments.ListNamespacesResponse{ + Items: []*rpcenvironments.Namespace{ + {Key: "ns-a"}, + {Key: "ns-b"}, + }, + }, nil + }). + Twice() + + s := &Server{ + logger: zap.NewNop(), + envs: &EnvironmentStore{ + byKey: map[string]Environment{ + "env-a": env, + }, + }, + } + + filteredCtx := contextWithNamespaces(ctx, []string{"ns-a"}) + filtered, err := s.ListNamespaces(filteredCtx, &rpcenvironments.ListNamespacesRequest{ + EnvironmentKey: "env-a", + }) + require.NoError(t, err) + require.Len(t, filtered.Items, 1) + assert.Equal(t, "ns-a", filtered.Items[0].Key) + + allCtx := contextWithNamespaces(ctx, []string{"*"}) + unfiltered, err := s.ListNamespaces(allCtx, &rpcenvironments.ListNamespacesRequest{ + EnvironmentKey: "env-a", + }) + require.NoError(t, err) + require.Len(t, unfiltered.Items, 2) +} + +func contextWithNamespaces(ctx context.Context, namespaces []string) context.Context { + return context.WithValue(ctx, authz.NamespacesKey, namespaces) +} + +func contextWithEnvironments(ctx context.Context, environments []string) context.Context { + return context.WithValue(ctx, authz.EnvironmentsKey, environments) +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/internal/server/environments/server_operations_test.go b/internal/server/environments/server_operations_test.go new file mode 100644 index 0000000000..55a9889127 --- /dev/null +++ b/internal/server/environments/server_operations_test.go @@ -0,0 +1,296 @@ +package environments + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + errs "go.flipt.io/flipt/errors" + rpcenvironments "go.flipt.io/flipt/rpc/v2/environments" +) + +func TestApplyCreateWithConflict(t *testing.T) { + ctx := t.Context() + + t.Run("skip when resource exists and strategy is skip", func(t *testing.T) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + } + + status, wrote, err := applyCreateWithConflict(ctx, store, &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + }, rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_SKIP, true) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SKIPPED, status) + assert.False(t, wrote) + }) + + t.Run("fails when resource exists and strategy is fail", func(t *testing.T) { + store := newTestResourceStore() + + status, wrote, err := applyCreateWithConflict(ctx, store, &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + }, rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_FAIL, true) + + require.Error(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_FAILED, status) + assert.False(t, wrote) + assert.True(t, errs.AsMatch[errs.ErrAlreadyExists](err)) + }) + + t.Run("overwrites when resource exists and strategy is overwrite", func(t *testing.T) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + } + + status, wrote, err := applyCreateWithConflict(ctx, store, &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + }, rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_OVERWRITE, true) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, status) + assert.True(t, wrote) + assert.Equal(t, 1, store.updateCalls) + assert.Equal(t, 0, store.createCalls) + }) + + t.Run("creates when resource does not exist", func(t *testing.T) { + store := newTestResourceStore() + + status, wrote, err := applyCreateWithConflict(ctx, store, &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + }, rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_FAIL, false) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, status) + assert.True(t, wrote) + assert.Equal(t, 1, store.createCalls) + assert.Equal(t, 0, store.updateCalls) + }) +} + +func TestApplyBulkOperation(t *testing.T) { + ctx := t.Context() + + t.Run("create with skip returns skipped when target exists", func(t *testing.T) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + } + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_CREATE, + Key: "flag", + OnConflict: rpcenvironments.ConflictStrategy_CONFLICT_STRATEGY_SKIP, + }) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SKIPPED, status) + assert.False(t, wrote) + }) + + t.Run("upsert surfaces get errors that are not not found", func(t *testing.T) { + store := newTestResourceStore() + store.getErrors[testResourceKey("default", "flag")] = fmt.Errorf("read failed") + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_UPSERT, + Key: "flag", + }) + + require.Error(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_FAILED, status) + assert.False(t, wrote) + assert.Contains(t, err.Error(), "read failed") + }) + + t.Run("update succeeds when resource exists", func(t *testing.T) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + } + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_UPDATE, + Key: "flag", + }) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, status) + assert.True(t, wrote) + assert.Equal(t, 1, store.updateCalls) + }) + + t.Run("update returns failed status when update fails", func(t *testing.T) { + store := newTestResourceStore() + store.updateErrors[testResourceKey("default", "flag")] = fmt.Errorf("update failed") + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_UPDATE, + Key: "flag", + }) + + require.Error(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_FAILED, status) + assert.False(t, wrote) + assert.Contains(t, err.Error(), "update failed") + }) + + t.Run("delete succeeds", func(t *testing.T) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + } + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_DELETE, + Key: "flag", + }) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, status) + assert.True(t, wrote) + }) + + t.Run("delete returns failed status when delete fails", func(t *testing.T) { + store := newTestResourceStore() + store.deleteErrors[testResourceKey("default", "flag")] = fmt.Errorf("delete failed") + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_DELETE, + Key: "flag", + }) + + require.Error(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_FAILED, status) + assert.False(t, wrote) + assert.Contains(t, err.Error(), "delete failed") + }) + + t.Run("upsert updates existing resources", func(t *testing.T) { + store := newTestResourceStore() + store.resources[testResourceKey("default", "flag")] = &rpcenvironments.Resource{ + NamespaceKey: "default", + Key: "flag", + } + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_UPSERT, + Key: "flag", + }) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, status) + assert.True(t, wrote) + assert.Equal(t, 1, store.updateCalls) + assert.Equal(t, 0, store.createCalls) + }) + + t.Run("upsert creates missing resources", func(t *testing.T) { + store := newTestResourceStore() + + status, wrote, err := applyBulkOperation(ctx, store, "default", &rpcenvironments.BulkApplyResourcesRequest{ + Operation: rpcenvironments.BulkOperation_BULK_OPERATION_UPSERT, + Key: "flag", + }) + + require.NoError(t, err) + assert.Equal(t, rpcenvironments.OperationStatus_OPERATION_STATUS_SUCCESS, status) + assert.True(t, wrote) + assert.Equal(t, 0, store.updateCalls) + assert.Equal(t, 1, store.createCalls) + }) +} + +type testResourceStore struct { + resources map[string]*rpcenvironments.Resource + getErrors map[string]error + createErrors map[string]error + updateErrors map[string]error + deleteErrors map[string]error + createCalls int + updateCalls int +} + +func newTestResourceStore() *testResourceStore { + return &testResourceStore{ + resources: make(map[string]*rpcenvironments.Resource), + getErrors: make(map[string]error), + createErrors: make(map[string]error), + updateErrors: make(map[string]error), + deleteErrors: make(map[string]error), + } +} + +func (s *testResourceStore) GetResource(_ context.Context, namespace, key string) (*rpcenvironments.ResourceResponse, error) { + compositeKey := testResourceKey(namespace, key) + if err, ok := s.getErrors[compositeKey]; ok { + return nil, err + } + + resource, ok := s.resources[compositeKey] + if !ok { + return nil, errs.ErrNotFoundf("resource %q/%q", namespace, key) + } + + return &rpcenvironments.ResourceResponse{Resource: resource}, nil +} + +func (s *testResourceStore) ListResources(_ context.Context, namespace string) (*rpcenvironments.ListResourcesResponse, error) { + var resources []*rpcenvironments.Resource + + for _, resource := range s.resources { + if resource.NamespaceKey == namespace { + resources = append(resources, resource) + } + } + + return &rpcenvironments.ListResourcesResponse{Resources: resources}, nil +} + +func (s *testResourceStore) CreateResource(_ context.Context, resource *rpcenvironments.Resource) error { + if err, ok := s.createErrors[testResourceKey(resource.NamespaceKey, resource.Key)]; ok { + return err + } + + s.createCalls++ + s.resources[testResourceKey(resource.NamespaceKey, resource.Key)] = resource + return nil +} + +func (s *testResourceStore) UpdateResource(_ context.Context, resource *rpcenvironments.Resource) error { + if err, ok := s.updateErrors[testResourceKey(resource.NamespaceKey, resource.Key)]; ok { + return err + } + + s.updateCalls++ + s.resources[testResourceKey(resource.NamespaceKey, resource.Key)] = resource + return nil +} + +func (s *testResourceStore) DeleteResource(_ context.Context, namespace, key string) error { + if err, ok := s.deleteErrors[testResourceKey(namespace, key)]; ok { + return err + } + + delete(s.resources, testResourceKey(namespace, key)) + return nil +} + +func testResourceKey(namespace, key string) string { + return namespace + "/" + key +} diff --git a/rpc/v2/environments/environments.pb.go b/rpc/v2/environments/environments.pb.go index 08496a782f..09b3a53c31 100644 --- a/rpc/v2/environments/environments.pb.go +++ b/rpc/v2/environments/environments.pb.go @@ -148,6 +148,212 @@ func (ProposalState) EnumDescriptor() ([]byte, []int) { return file_environments_environments_proto_rawDescGZIP(), []int{1} } +// Conflict resolution strategy for copy/bulk operations. +type ConflictStrategy int32 + +const ( + ConflictStrategy_CONFLICT_STRATEGY_FAIL ConflictStrategy = 0 + ConflictStrategy_CONFLICT_STRATEGY_OVERWRITE ConflictStrategy = 1 + ConflictStrategy_CONFLICT_STRATEGY_SKIP ConflictStrategy = 2 +) + +// Enum value maps for ConflictStrategy. +var ( + ConflictStrategy_name = map[int32]string{ + 0: "CONFLICT_STRATEGY_FAIL", + 1: "CONFLICT_STRATEGY_OVERWRITE", + 2: "CONFLICT_STRATEGY_SKIP", + } + ConflictStrategy_value = map[string]int32{ + "CONFLICT_STRATEGY_FAIL": 0, + "CONFLICT_STRATEGY_OVERWRITE": 1, + "CONFLICT_STRATEGY_SKIP": 2, + } +) + +func (x ConflictStrategy) Enum() *ConflictStrategy { + p := new(ConflictStrategy) + *p = x + return p +} + +func (x ConflictStrategy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ConflictStrategy) Descriptor() protoreflect.EnumDescriptor { + return file_environments_environments_proto_enumTypes[2].Descriptor() +} + +func (ConflictStrategy) Type() protoreflect.EnumType { + return &file_environments_environments_proto_enumTypes[2] +} + +func (x ConflictStrategy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ConflictStrategy.Descriptor instead. +func (ConflictStrategy) EnumDescriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{2} +} + +// Bulk operation type. +type BulkOperation int32 + +const ( + BulkOperation_BULK_OPERATION_CREATE BulkOperation = 0 + BulkOperation_BULK_OPERATION_UPDATE BulkOperation = 1 + BulkOperation_BULK_OPERATION_DELETE BulkOperation = 2 + BulkOperation_BULK_OPERATION_UPSERT BulkOperation = 3 +) + +// Enum value maps for BulkOperation. +var ( + BulkOperation_name = map[int32]string{ + 0: "BULK_OPERATION_CREATE", + 1: "BULK_OPERATION_UPDATE", + 2: "BULK_OPERATION_DELETE", + 3: "BULK_OPERATION_UPSERT", + } + BulkOperation_value = map[string]int32{ + "BULK_OPERATION_CREATE": 0, + "BULK_OPERATION_UPDATE": 1, + "BULK_OPERATION_DELETE": 2, + "BULK_OPERATION_UPSERT": 3, + } +) + +func (x BulkOperation) Enum() *BulkOperation { + p := new(BulkOperation) + *p = x + return p +} + +func (x BulkOperation) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (BulkOperation) Descriptor() protoreflect.EnumDescriptor { + return file_environments_environments_proto_enumTypes[3].Descriptor() +} + +func (BulkOperation) Type() protoreflect.EnumType { + return &file_environments_environments_proto_enumTypes[3] +} + +func (x BulkOperation) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use BulkOperation.Descriptor instead. +func (BulkOperation) EnumDescriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{3} +} + +// Per-resource/namespace operation result status. +type OperationStatus int32 + +const ( + OperationStatus_OPERATION_STATUS_SUCCESS OperationStatus = 0 + OperationStatus_OPERATION_STATUS_SKIPPED OperationStatus = 1 + OperationStatus_OPERATION_STATUS_FAILED OperationStatus = 2 +) + +// Enum value maps for OperationStatus. +var ( + OperationStatus_name = map[int32]string{ + 0: "OPERATION_STATUS_SUCCESS", + 1: "OPERATION_STATUS_SKIPPED", + 2: "OPERATION_STATUS_FAILED", + } + OperationStatus_value = map[string]int32{ + "OPERATION_STATUS_SUCCESS": 0, + "OPERATION_STATUS_SKIPPED": 1, + "OPERATION_STATUS_FAILED": 2, + } +) + +func (x OperationStatus) Enum() *OperationStatus { + p := new(OperationStatus) + *p = x + return p +} + +func (x OperationStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OperationStatus) Descriptor() protoreflect.EnumDescriptor { + return file_environments_environments_proto_enumTypes[4].Descriptor() +} + +func (OperationStatus) Type() protoreflect.EnumType { + return &file_environments_environments_proto_enumTypes[4] +} + +func (x OperationStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use OperationStatus.Descriptor instead. +func (OperationStatus) EnumDescriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{4} +} + +// Comparison status for a resource key across two env/namespace pairs. +type CompareStatus int32 + +const ( + CompareStatus_COMPARE_STATUS_IDENTICAL CompareStatus = 0 + CompareStatus_COMPARE_STATUS_DIFFERENT CompareStatus = 1 + CompareStatus_COMPARE_STATUS_SOURCE_ONLY CompareStatus = 2 + CompareStatus_COMPARE_STATUS_TARGET_ONLY CompareStatus = 3 +) + +// Enum value maps for CompareStatus. +var ( + CompareStatus_name = map[int32]string{ + 0: "COMPARE_STATUS_IDENTICAL", + 1: "COMPARE_STATUS_DIFFERENT", + 2: "COMPARE_STATUS_SOURCE_ONLY", + 3: "COMPARE_STATUS_TARGET_ONLY", + } + CompareStatus_value = map[string]int32{ + "COMPARE_STATUS_IDENTICAL": 0, + "COMPARE_STATUS_DIFFERENT": 1, + "COMPARE_STATUS_SOURCE_ONLY": 2, + "COMPARE_STATUS_TARGET_ONLY": 3, + } +) + +func (x CompareStatus) Enum() *CompareStatus { + p := new(CompareStatus) + *p = x + return p +} + +func (x CompareStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CompareStatus) Descriptor() protoreflect.EnumDescriptor { + return file_environments_environments_proto_enumTypes[5].Descriptor() +} + +func (CompareStatus) Type() protoreflect.EnumType { + return &file_environments_environments_proto_enumTypes[5] +} + +func (x CompareStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CompareStatus.Descriptor instead. +func (CompareStatus) EnumDescriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{5} +} + // The Environment represents a environment. type Environment struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2015,149 +2221,863 @@ func (x *DeleteResourceResponse) GetRevision() string { return "" } -var File_environments_environments_proto protoreflect.FileDescriptor +// Request to copy all resources from one namespace to another. +type CopyNamespaceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Target environment. + EnvironmentKey string `protobuf:"bytes,1,opt,name=environment_key,json=environmentKey,proto3" json:"environment_key,omitempty"` + // Target namespace key (created if it doesn't exist). + NamespaceKey string `protobuf:"bytes,2,opt,name=namespace_key,json=namespaceKey,proto3" json:"namespace_key,omitempty"` + // Source coordinates. + SourceEnvironmentKey string `protobuf:"bytes,3,opt,name=source_environment_key,json=sourceEnvironmentKey,proto3" json:"source_environment_key,omitempty"` + SourceNamespaceKey string `protobuf:"bytes,4,opt,name=source_namespace_key,json=sourceNamespaceKey,proto3" json:"source_namespace_key,omitempty"` + // Conflict strategy. + OnConflict ConflictStrategy `protobuf:"varint,5,opt,name=on_conflict,json=onConflict,proto3,enum=environments.ConflictStrategy" json:"on_conflict,omitempty"` + // Revision for optimistic concurrency on the target environment. + Revision string `protobuf:"bytes,100,opt,name=revision,proto3" json:"revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} -const file_environments_environments_proto_rawDesc = "" + - "\n" + - "\x1fenvironments/environments.proto\x12\fenvironments\x1a$gnostic/openapi/v3/annotations.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1bgoogle/api/visibility.proto\x1a\x19google/protobuf/any.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xc3\x01\n" + - "\vEnvironment\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" + - "\adefault\x18\x03 \x01(\bH\x00R\adefault\x88\x01\x01\x12Q\n" + - "\rconfiguration\x18\x04 \x01(\v2&.environments.EnvironmentConfigurationH\x01R\rconfiguration\x88\x01\x01B\n" + - "\n" + - "\b_defaultB\x10\n" + - "\x0e_configuration\"\xd9\x01\n" + - "\x18EnvironmentConfiguration\x12\x10\n" + - "\x03ref\x18\x01 \x01(\tR\x03ref\x12!\n" + - "\tdirectory\x18\x02 \x01(\tH\x00R\tdirectory\x88\x01\x01\x12\x1b\n" + - "\x06remote\x18\x03 \x01(\tH\x01R\x06remote\x88\x01\x01\x12\x17\n" + - "\x04base\x18\x04 \x01(\tH\x02R\x04base\x88\x01\x01\x12(\n" + - "\x03scm\x18\x05 \x01(\x0e2\x11.environments.SCMH\x03R\x03scm\x88\x01\x01B\f\n" + - "\n" + - "_directoryB\t\n" + - "\a_remoteB\a\n" + - "\x05_baseB\x06\n" + - "\x04_scm\"\x19\n" + - "\x17ListEnvironmentsRequest\"Y\n" + - "\x18ListEnvironmentsResponse\x12=\n" + - "\fenvironments\x18\x01 \x03(\v2\x19.environments.EnvironmentR\fenvironments\"U\n" + - "\x18BranchEnvironmentRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\"[\n" + - "\x1eDeleteBranchEnvironmentRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\"\xb8\x01\n" + - "\x11BranchEnvironment\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x10\n" + - "\x03ref\x18\x03 \x01(\tR\x03ref\x12I\n" + - "\bproposal\x18\x04 \x01(\v2(.environments.EnvironmentProposalDetailsH\x00R\bproposal\x88\x01\x01B\v\n" + - "\t_proposal\"I\n" + - "\x1eListEnvironmentBranchesRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\"^\n" + - "\x1fListEnvironmentBranchesResponse\x12;\n" + - "\bbranches\x18\x01 \x03(\v2\x1f.environments.BranchEnvironmentR\bbranches\"\xc2\x01\n" + - "\x19ProposeEnvironmentRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x19\n" + - "\x05title\x18\x03 \x01(\tH\x00R\x05title\x88\x01\x01\x12\x17\n" + - "\x04body\x18\x04 \x01(\tH\x01R\x04body\x88\x01\x01\x12\x19\n" + - "\x05draft\x18\x05 \x01(\bH\x02R\x05draft\x88\x01\x01B\b\n" + - "\x06_titleB\a\n" + - "\x05_bodyB\b\n" + - "\x06_draft\"a\n" + - "\x1aEnvironmentProposalDetails\x12\x10\n" + - "\x03url\x18\x01 \x01(\tR\x03url\x121\n" + - "\x05state\x18\x02 \x01(\x0e2\x1b.environments.ProposalStateR\x05state\"\xf5\x01\n" + - "\x06Change\x12\x1a\n" + - "\brevision\x18\x01 \x01(\tR\brevision\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12$\n" + - "\vauthor_name\x18\x03 \x01(\tH\x00R\n" + - "authorName\x88\x01\x01\x12&\n" + - "\fauthor_email\x18\x04 \x01(\tH\x01R\vauthorEmail\x88\x01\x01\x12\x1c\n" + - "\ttimestamp\x18\x05 \x01(\tR\ttimestamp\x12\x1c\n" + - "\ascm_url\x18\x06 \x01(\tH\x02R\x06scmUrl\x88\x01\x01B\x0e\n" + - "\f_author_nameB\x0f\n" + - "\r_author_emailB\n" + - "\n" + - "\b_scm_url\"\xa9\x01\n" + - "%ListBranchedEnvironmentChangesRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x17\n" + - "\x04from\x18\x03 \x01(\tH\x00R\x04from\x88\x01\x01\x12\x19\n" + - "\x05limit\x18\x04 \x01(\x05H\x01R\x05limit\x88\x01\x01B\a\n" + - "\x05_fromB\b\n" + - "\x06_limit\"X\n" + - "&ListBranchedEnvironmentChangesResponse\x12.\n" + - "\achanges\x18\x01 \x03(\v2\x14.environments.ChangeR\achanges\"\x99\x01\n" + - "\tNamespace\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12%\n" + - "\vdescription\x18\x03 \x01(\tH\x00R\vdescription\x88\x01\x01\x12!\n" + - "\tprotected\x18\x04 \x01(\bH\x01R\tprotected\x88\x01\x01B\x0e\n" + - "\f_descriptionB\f\n" + - "\n" + - "_protected\"P\n" + - "\x13GetNamespaceRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\"f\n" + - "\x11NamespaceResponse\x125\n" + - "\tnamespace\x18\x01 \x01(\v2\x17.environments.NamespaceR\tnamespace\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"@\n" + - "\x15ListNamespacesRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\"c\n" + - "\x16ListNamespacesResponse\x12-\n" + - "\x05items\x18\x01 \x03(\v2\x17.environments.NamespaceR\x05items\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"\xeb\x01\n" + - "\x16UpdateNamespaceRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x12\n" + - "\x04name\x18\x03 \x01(\tR\x04name\x12%\n" + - "\vdescription\x18\x04 \x01(\tH\x00R\vdescription\x88\x01\x01\x12!\n" + - "\tprotected\x18\x05 \x01(\bH\x01R\tprotected\x88\x01\x01\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevisionB\x0e\n" + - "\f_descriptionB\f\n" + - "\n" + - "_protected\"o\n" + - "\x16DeleteNamespaceRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"5\n" + - "\x17DeleteNamespaceResponse\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"\x8f\x01\n" + - "\x12GetResourceRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + - "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x19\n" + - "\btype_url\x18\x03 \x01(\tR\atypeUrl\x12\x10\n" + - "\x03key\x18\x04 \x01(\tR\x03key\"q\n" + - "\bResource\x12#\n" + - "\rnamespace_key\x18\x01 \x01(\tR\fnamespaceKey\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12.\n" + - "\apayload\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\apayload\"b\n" + - "\x10ResourceResponse\x122\n" + - "\bresource\x18\x01 \x01(\v2\x16.environments.ResourceR\bresource\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"\x7f\n" + - "\x14ListResourcesRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + - "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x19\n" + - "\btype_url\x18\x03 \x01(\tR\atypeUrl\"i\n" + - "\x15ListResourcesResponse\x124\n" + - "\tresources\x18\x01 \x03(\v2\x16.environments.ResourceR\tresources\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"\xc3\x01\n" + - "\x15UpdateResourceRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + - "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x10\n" + - "\x03key\x18\x03 \x01(\tR\x03key\x12.\n" + - "\apayload\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\apayload\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"\xae\x01\n" + - "\x15DeleteResourceRequest\x12'\n" + - "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + - "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x19\n" + - "\btype_url\x18\x03 \x01(\tR\atypeUrl\x12\x10\n" + - "\x03key\x18\x04 \x01(\tR\x03key\x12\x1a\n" + - "\brevision\x18d \x01(\tR\brevision\"4\n" + - "\x16DeleteResourceResponse\x12\x1a\n" + +func (x *CopyNamespaceRequest) Reset() { + *x = CopyNamespaceRequest{} + mi := &file_environments_environments_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopyNamespaceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopyNamespaceRequest) ProtoMessage() {} + +func (x *CopyNamespaceRequest) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[30] + 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 CopyNamespaceRequest.ProtoReflect.Descriptor instead. +func (*CopyNamespaceRequest) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{30} +} + +func (x *CopyNamespaceRequest) GetEnvironmentKey() string { + if x != nil { + return x.EnvironmentKey + } + return "" +} + +func (x *CopyNamespaceRequest) GetNamespaceKey() string { + if x != nil { + return x.NamespaceKey + } + return "" +} + +func (x *CopyNamespaceRequest) GetSourceEnvironmentKey() string { + if x != nil { + return x.SourceEnvironmentKey + } + return "" +} + +func (x *CopyNamespaceRequest) GetSourceNamespaceKey() string { + if x != nil { + return x.SourceNamespaceKey + } + return "" +} + +func (x *CopyNamespaceRequest) GetOnConflict() ConflictStrategy { + if x != nil { + return x.OnConflict + } + return ConflictStrategy_CONFLICT_STRATEGY_FAIL +} + +func (x *CopyNamespaceRequest) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +// Per-resource result from a namespace copy. +type CopyNamespaceResourceResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TypeUrl string `protobuf:"bytes,1,opt,name=type_url,json=typeUrl,proto3" json:"type_url,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + Status OperationStatus `protobuf:"varint,3,opt,name=status,proto3,enum=environments.OperationStatus" json:"status,omitempty"` + // Error message if status is FAILED. + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopyNamespaceResourceResult) Reset() { + *x = CopyNamespaceResourceResult{} + mi := &file_environments_environments_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopyNamespaceResourceResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopyNamespaceResourceResult) ProtoMessage() {} + +func (x *CopyNamespaceResourceResult) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[31] + 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 CopyNamespaceResourceResult.ProtoReflect.Descriptor instead. +func (*CopyNamespaceResourceResult) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{31} +} + +func (x *CopyNamespaceResourceResult) GetTypeUrl() string { + if x != nil { + return x.TypeUrl + } + return "" +} + +func (x *CopyNamespaceResourceResult) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *CopyNamespaceResourceResult) GetStatus() OperationStatus { + if x != nil { + return x.Status + } + return OperationStatus_OPERATION_STATUS_SUCCESS +} + +func (x *CopyNamespaceResourceResult) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +// Response after copying a namespace. +type CopyNamespaceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*CopyNamespaceResourceResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + Revision string `protobuf:"bytes,100,opt,name=revision,proto3" json:"revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopyNamespaceResponse) Reset() { + *x = CopyNamespaceResponse{} + mi := &file_environments_environments_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopyNamespaceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopyNamespaceResponse) ProtoMessage() {} + +func (x *CopyNamespaceResponse) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[32] + 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 CopyNamespaceResponse.ProtoReflect.Descriptor instead. +func (*CopyNamespaceResponse) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{32} +} + +func (x *CopyNamespaceResponse) GetResults() []*CopyNamespaceResourceResult { + if x != nil { + return x.Results + } + return nil +} + +func (x *CopyNamespaceResponse) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +// Request to compare resources across source and target environment/namespace. +type CompareEnvironmentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Source environment key. + EnvironmentKey string `protobuf:"bytes,1,opt,name=environment_key,json=environmentKey,proto3" json:"environment_key,omitempty"` + // Source namespace key. + NamespaceKey string `protobuf:"bytes,2,opt,name=namespace_key,json=namespaceKey,proto3" json:"namespace_key,omitempty"` + // Target environment key. + TargetEnvironmentKey string `protobuf:"bytes,3,opt,name=target_environment_key,json=targetEnvironmentKey,proto3" json:"target_environment_key,omitempty"` + // Target namespace key. + TargetNamespaceKey string `protobuf:"bytes,4,opt,name=target_namespace_key,json=targetNamespaceKey,proto3" json:"target_namespace_key,omitempty"` + // Optional list of resource type urls (e.g. flipt.core.Flag). Defaults to known resource types. + TypeUrls []string `protobuf:"bytes,5,rep,name=type_urls,json=typeUrls,proto3" json:"type_urls,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompareEnvironmentsRequest) Reset() { + *x = CompareEnvironmentsRequest{} + mi := &file_environments_environments_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompareEnvironmentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompareEnvironmentsRequest) ProtoMessage() {} + +func (x *CompareEnvironmentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[33] + 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 CompareEnvironmentsRequest.ProtoReflect.Descriptor instead. +func (*CompareEnvironmentsRequest) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{33} +} + +func (x *CompareEnvironmentsRequest) GetEnvironmentKey() string { + if x != nil { + return x.EnvironmentKey + } + return "" +} + +func (x *CompareEnvironmentsRequest) GetNamespaceKey() string { + if x != nil { + return x.NamespaceKey + } + return "" +} + +func (x *CompareEnvironmentsRequest) GetTargetEnvironmentKey() string { + if x != nil { + return x.TargetEnvironmentKey + } + return "" +} + +func (x *CompareEnvironmentsRequest) GetTargetNamespaceKey() string { + if x != nil { + return x.TargetNamespaceKey + } + return "" +} + +func (x *CompareEnvironmentsRequest) GetTypeUrls() []string { + if x != nil { + return x.TypeUrls + } + return nil +} + +// Per-resource comparison result. +type CompareResourceResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TypeUrl string `protobuf:"bytes,1,opt,name=type_url,json=typeUrl,proto3" json:"type_url,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + Status CompareStatus `protobuf:"varint,3,opt,name=status,proto3,enum=environments.CompareStatus" json:"status,omitempty"` + Source *Resource `protobuf:"bytes,4,opt,name=source,proto3,oneof" json:"source,omitempty"` + Target *Resource `protobuf:"bytes,5,opt,name=target,proto3,oneof" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompareResourceResult) Reset() { + *x = CompareResourceResult{} + mi := &file_environments_environments_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompareResourceResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompareResourceResult) ProtoMessage() {} + +func (x *CompareResourceResult) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[34] + 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 CompareResourceResult.ProtoReflect.Descriptor instead. +func (*CompareResourceResult) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{34} +} + +func (x *CompareResourceResult) GetTypeUrl() string { + if x != nil { + return x.TypeUrl + } + return "" +} + +func (x *CompareResourceResult) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *CompareResourceResult) GetStatus() CompareStatus { + if x != nil { + return x.Status + } + return CompareStatus_COMPARE_STATUS_IDENTICAL +} + +func (x *CompareResourceResult) GetSource() *Resource { + if x != nil { + return x.Source + } + return nil +} + +func (x *CompareResourceResult) GetTarget() *Resource { + if x != nil { + return x.Target + } + return nil +} + +// Response for environment comparison. +type CompareEnvironmentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*CompareResourceResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompareEnvironmentsResponse) Reset() { + *x = CompareEnvironmentsResponse{} + mi := &file_environments_environments_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompareEnvironmentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompareEnvironmentsResponse) ProtoMessage() {} + +func (x *CompareEnvironmentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[35] + 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 CompareEnvironmentsResponse.ProtoReflect.Descriptor instead. +func (*CompareEnvironmentsResponse) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{35} +} + +func (x *CompareEnvironmentsResponse) GetResults() []*CompareResourceResult { + if x != nil { + return x.Results + } + return nil +} + +// Request to apply an operation to a resource across multiple namespaces. +type BulkApplyResourcesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Primary environment key (also used for request path binding). + EnvironmentKey string `protobuf:"bytes,1,opt,name=environment_key,json=environmentKey,proto3" json:"environment_key,omitempty"` + // Explicit list of target namespace keys. + NamespaceKeys []string `protobuf:"bytes,2,rep,name=namespace_keys,json=namespaceKeys,proto3" json:"namespace_keys,omitempty"` + // Operation to apply. + Operation BulkOperation `protobuf:"varint,3,opt,name=operation,proto3,enum=environments.BulkOperation" json:"operation,omitempty"` + // Resource type. + TypeUrl string `protobuf:"bytes,4,opt,name=type_url,json=typeUrl,proto3" json:"type_url,omitempty"` + // Resource key (for UPDATE, DELETE, UPSERT). + Key string `protobuf:"bytes,5,opt,name=key,proto3" json:"key,omitempty"` + // Resource payload (for CREATE, UPDATE, UPSERT). + Payload *anypb.Any `protobuf:"bytes,6,opt,name=payload,proto3,oneof" json:"payload,omitempty"` + // Conflict strategy (for CREATE, UPSERT). + OnConflict ConflictStrategy `protobuf:"varint,7,opt,name=on_conflict,json=onConflict,proto3,enum=environments.ConflictStrategy" json:"on_conflict,omitempty"` + // Optional list of target environment keys. + // If empty, only `environment_key` is targeted. + EnvironmentKeys []string `protobuf:"bytes,8,rep,name=environment_keys,json=environmentKeys,proto3" json:"environment_keys,omitempty"` + // Revision for optimistic concurrency. + Revision string `protobuf:"bytes,100,opt,name=revision,proto3" json:"revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BulkApplyResourcesRequest) Reset() { + *x = BulkApplyResourcesRequest{} + mi := &file_environments_environments_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BulkApplyResourcesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BulkApplyResourcesRequest) ProtoMessage() {} + +func (x *BulkApplyResourcesRequest) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[36] + 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 BulkApplyResourcesRequest.ProtoReflect.Descriptor instead. +func (*BulkApplyResourcesRequest) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{36} +} + +func (x *BulkApplyResourcesRequest) GetEnvironmentKey() string { + if x != nil { + return x.EnvironmentKey + } + return "" +} + +func (x *BulkApplyResourcesRequest) GetNamespaceKeys() []string { + if x != nil { + return x.NamespaceKeys + } + return nil +} + +func (x *BulkApplyResourcesRequest) GetOperation() BulkOperation { + if x != nil { + return x.Operation + } + return BulkOperation_BULK_OPERATION_CREATE +} + +func (x *BulkApplyResourcesRequest) GetTypeUrl() string { + if x != nil { + return x.TypeUrl + } + return "" +} + +func (x *BulkApplyResourcesRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *BulkApplyResourcesRequest) GetPayload() *anypb.Any { + if x != nil { + return x.Payload + } + return nil +} + +func (x *BulkApplyResourcesRequest) GetOnConflict() ConflictStrategy { + if x != nil { + return x.OnConflict + } + return ConflictStrategy_CONFLICT_STRATEGY_FAIL +} + +func (x *BulkApplyResourcesRequest) GetEnvironmentKeys() []string { + if x != nil { + return x.EnvironmentKeys + } + return nil +} + +func (x *BulkApplyResourcesRequest) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +// Per-namespace result from a bulk apply. +type BulkApplyNamespaceResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + EnvironmentKey string `protobuf:"bytes,1,opt,name=environment_key,json=environmentKey,proto3" json:"environment_key,omitempty"` + NamespaceKey string `protobuf:"bytes,2,opt,name=namespace_key,json=namespaceKey,proto3" json:"namespace_key,omitempty"` + Status OperationStatus `protobuf:"varint,3,opt,name=status,proto3,enum=environments.OperationStatus" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BulkApplyNamespaceResult) Reset() { + *x = BulkApplyNamespaceResult{} + mi := &file_environments_environments_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BulkApplyNamespaceResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BulkApplyNamespaceResult) ProtoMessage() {} + +func (x *BulkApplyNamespaceResult) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[37] + 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 BulkApplyNamespaceResult.ProtoReflect.Descriptor instead. +func (*BulkApplyNamespaceResult) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{37} +} + +func (x *BulkApplyNamespaceResult) GetEnvironmentKey() string { + if x != nil { + return x.EnvironmentKey + } + return "" +} + +func (x *BulkApplyNamespaceResult) GetNamespaceKey() string { + if x != nil { + return x.NamespaceKey + } + return "" +} + +func (x *BulkApplyNamespaceResult) GetStatus() OperationStatus { + if x != nil { + return x.Status + } + return OperationStatus_OPERATION_STATUS_SUCCESS +} + +func (x *BulkApplyNamespaceResult) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +// Response after bulk applying resources. +type BulkApplyResourcesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*BulkApplyNamespaceResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + // Revision for `environment_key` from the request. + Revision string `protobuf:"bytes,100,opt,name=revision,proto3" json:"revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BulkApplyResourcesResponse) Reset() { + *x = BulkApplyResourcesResponse{} + mi := &file_environments_environments_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BulkApplyResourcesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BulkApplyResourcesResponse) ProtoMessage() {} + +func (x *BulkApplyResourcesResponse) ProtoReflect() protoreflect.Message { + mi := &file_environments_environments_proto_msgTypes[38] + 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 BulkApplyResourcesResponse.ProtoReflect.Descriptor instead. +func (*BulkApplyResourcesResponse) Descriptor() ([]byte, []int) { + return file_environments_environments_proto_rawDescGZIP(), []int{38} +} + +func (x *BulkApplyResourcesResponse) GetResults() []*BulkApplyNamespaceResult { + if x != nil { + return x.Results + } + return nil +} + +func (x *BulkApplyResourcesResponse) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +var File_environments_environments_proto protoreflect.FileDescriptor + +const file_environments_environments_proto_rawDesc = "" + + "\n" + + "\x1fenvironments/environments.proto\x12\fenvironments\x1a$gnostic/openapi/v3/annotations.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1bgoogle/api/visibility.proto\x1a\x19google/protobuf/any.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xc3\x01\n" + + "\vEnvironment\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" + + "\adefault\x18\x03 \x01(\bH\x00R\adefault\x88\x01\x01\x12Q\n" + + "\rconfiguration\x18\x04 \x01(\v2&.environments.EnvironmentConfigurationH\x01R\rconfiguration\x88\x01\x01B\n" + + "\n" + + "\b_defaultB\x10\n" + + "\x0e_configuration\"\xd9\x01\n" + + "\x18EnvironmentConfiguration\x12\x10\n" + + "\x03ref\x18\x01 \x01(\tR\x03ref\x12!\n" + + "\tdirectory\x18\x02 \x01(\tH\x00R\tdirectory\x88\x01\x01\x12\x1b\n" + + "\x06remote\x18\x03 \x01(\tH\x01R\x06remote\x88\x01\x01\x12\x17\n" + + "\x04base\x18\x04 \x01(\tH\x02R\x04base\x88\x01\x01\x12(\n" + + "\x03scm\x18\x05 \x01(\x0e2\x11.environments.SCMH\x03R\x03scm\x88\x01\x01B\f\n" + + "\n" + + "_directoryB\t\n" + + "\a_remoteB\a\n" + + "\x05_baseB\x06\n" + + "\x04_scm\"\x19\n" + + "\x17ListEnvironmentsRequest\"Y\n" + + "\x18ListEnvironmentsResponse\x12=\n" + + "\fenvironments\x18\x01 \x03(\v2\x19.environments.EnvironmentR\fenvironments\"U\n" + + "\x18BranchEnvironmentRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\"[\n" + + "\x1eDeleteBranchEnvironmentRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\"\xb8\x01\n" + + "\x11BranchEnvironment\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x10\n" + + "\x03ref\x18\x03 \x01(\tR\x03ref\x12I\n" + + "\bproposal\x18\x04 \x01(\v2(.environments.EnvironmentProposalDetailsH\x00R\bproposal\x88\x01\x01B\v\n" + + "\t_proposal\"I\n" + + "\x1eListEnvironmentBranchesRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\"^\n" + + "\x1fListEnvironmentBranchesResponse\x12;\n" + + "\bbranches\x18\x01 \x03(\v2\x1f.environments.BranchEnvironmentR\bbranches\"\xc2\x01\n" + + "\x19ProposeEnvironmentRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x19\n" + + "\x05title\x18\x03 \x01(\tH\x00R\x05title\x88\x01\x01\x12\x17\n" + + "\x04body\x18\x04 \x01(\tH\x01R\x04body\x88\x01\x01\x12\x19\n" + + "\x05draft\x18\x05 \x01(\bH\x02R\x05draft\x88\x01\x01B\b\n" + + "\x06_titleB\a\n" + + "\x05_bodyB\b\n" + + "\x06_draft\"a\n" + + "\x1aEnvironmentProposalDetails\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\x121\n" + + "\x05state\x18\x02 \x01(\x0e2\x1b.environments.ProposalStateR\x05state\"\xf5\x01\n" + + "\x06Change\x12\x1a\n" + + "\brevision\x18\x01 \x01(\tR\brevision\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12$\n" + + "\vauthor_name\x18\x03 \x01(\tH\x00R\n" + + "authorName\x88\x01\x01\x12&\n" + + "\fauthor_email\x18\x04 \x01(\tH\x01R\vauthorEmail\x88\x01\x01\x12\x1c\n" + + "\ttimestamp\x18\x05 \x01(\tR\ttimestamp\x12\x1c\n" + + "\ascm_url\x18\x06 \x01(\tH\x02R\x06scmUrl\x88\x01\x01B\x0e\n" + + "\f_author_nameB\x0f\n" + + "\r_author_emailB\n" + + "\n" + + "\b_scm_url\"\xa9\x01\n" + + "%ListBranchedEnvironmentChangesRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x17\n" + + "\x04from\x18\x03 \x01(\tH\x00R\x04from\x88\x01\x01\x12\x19\n" + + "\x05limit\x18\x04 \x01(\x05H\x01R\x05limit\x88\x01\x01B\a\n" + + "\x05_fromB\b\n" + + "\x06_limit\"X\n" + + "&ListBranchedEnvironmentChangesResponse\x12.\n" + + "\achanges\x18\x01 \x03(\v2\x14.environments.ChangeR\achanges\"\x99\x01\n" + + "\tNamespace\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12%\n" + + "\vdescription\x18\x03 \x01(\tH\x00R\vdescription\x88\x01\x01\x12!\n" + + "\tprotected\x18\x04 \x01(\bH\x01R\tprotected\x88\x01\x01B\x0e\n" + + "\f_descriptionB\f\n" + + "\n" + + "_protected\"P\n" + + "\x13GetNamespaceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\"f\n" + + "\x11NamespaceResponse\x125\n" + + "\tnamespace\x18\x01 \x01(\v2\x17.environments.NamespaceR\tnamespace\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"@\n" + + "\x15ListNamespacesRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\"c\n" + + "\x16ListNamespacesResponse\x12-\n" + + "\x05items\x18\x01 \x03(\v2\x17.environments.NamespaceR\x05items\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\xeb\x01\n" + + "\x16UpdateNamespaceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12%\n" + + "\vdescription\x18\x04 \x01(\tH\x00R\vdescription\x88\x01\x01\x12!\n" + + "\tprotected\x18\x05 \x01(\bH\x01R\tprotected\x88\x01\x01\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevisionB\x0e\n" + + "\f_descriptionB\f\n" + + "\n" + + "_protected\"o\n" + + "\x16DeleteNamespaceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"5\n" + + "\x17DeleteNamespaceResponse\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\x8f\x01\n" + + "\x12GetResourceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x19\n" + + "\btype_url\x18\x03 \x01(\tR\atypeUrl\x12\x10\n" + + "\x03key\x18\x04 \x01(\tR\x03key\"q\n" + + "\bResource\x12#\n" + + "\rnamespace_key\x18\x01 \x01(\tR\fnamespaceKey\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12.\n" + + "\apayload\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\apayload\"b\n" + + "\x10ResourceResponse\x122\n" + + "\bresource\x18\x01 \x01(\v2\x16.environments.ResourceR\bresource\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\x7f\n" + + "\x14ListResourcesRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x19\n" + + "\btype_url\x18\x03 \x01(\tR\atypeUrl\"i\n" + + "\x15ListResourcesResponse\x124\n" + + "\tresources\x18\x01 \x03(\v2\x16.environments.ResourceR\tresources\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\xc3\x01\n" + + "\x15UpdateResourceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x10\n" + + "\x03key\x18\x03 \x01(\tR\x03key\x12.\n" + + "\apayload\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\apayload\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\xae\x01\n" + + "\x15DeleteResourceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x12\x19\n" + + "\btype_url\x18\x03 \x01(\tR\atypeUrl\x12\x10\n" + + "\x03key\x18\x04 \x01(\tR\x03key\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"4\n" + + "\x16DeleteResourceResponse\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\xa9\x02\n" + + "\x14CopyNamespaceRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x124\n" + + "\x16source_environment_key\x18\x03 \x01(\tR\x14sourceEnvironmentKey\x120\n" + + "\x14source_namespace_key\x18\x04 \x01(\tR\x12sourceNamespaceKey\x12?\n" + + "\von_conflict\x18\x05 \x01(\x0e2\x1e.environments.ConflictStrategyR\n" + + "onConflict\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\xa6\x01\n" + + "\x1bCopyNamespaceResourceResult\x12\x19\n" + + "\btype_url\x18\x01 \x01(\tR\atypeUrl\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x125\n" + + "\x06status\x18\x03 \x01(\x0e2\x1d.environments.OperationStatusR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\"x\n" + + "\x15CopyNamespaceResponse\x12C\n" + + "\aresults\x18\x01 \x03(\v2).environments.CopyNamespaceResourceResultR\aresults\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevision\"\xef\x01\n" + + "\x1aCompareEnvironmentsRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x124\n" + + "\x16target_environment_key\x18\x03 \x01(\tR\x14targetEnvironmentKey\x120\n" + + "\x14target_namespace_key\x18\x04 \x01(\tR\x12targetNamespaceKey\x12\x1b\n" + + "\ttype_urls\x18\x05 \x03(\tR\btypeUrls\"\xf9\x01\n" + + "\x15CompareResourceResult\x12\x19\n" + + "\btype_url\x18\x01 \x01(\tR\atypeUrl\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x123\n" + + "\x06status\x18\x03 \x01(\x0e2\x1b.environments.CompareStatusR\x06status\x123\n" + + "\x06source\x18\x04 \x01(\v2\x16.environments.ResourceH\x00R\x06source\x88\x01\x01\x123\n" + + "\x06target\x18\x05 \x01(\v2\x16.environments.ResourceH\x01R\x06target\x88\x01\x01B\t\n" + + "\a_sourceB\t\n" + + "\a_target\"\\\n" + + "\x1bCompareEnvironmentsResponse\x12=\n" + + "\aresults\x18\x01 \x03(\v2#.environments.CompareResourceResultR\aresults\"\x9c\x03\n" + + "\x19BulkApplyResourcesRequest\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12%\n" + + "\x0enamespace_keys\x18\x02 \x03(\tR\rnamespaceKeys\x129\n" + + "\toperation\x18\x03 \x01(\x0e2\x1b.environments.BulkOperationR\toperation\x12\x19\n" + + "\btype_url\x18\x04 \x01(\tR\atypeUrl\x12\x10\n" + + "\x03key\x18\x05 \x01(\tR\x03key\x123\n" + + "\apayload\x18\x06 \x01(\v2\x14.google.protobuf.AnyH\x00R\apayload\x88\x01\x01\x12?\n" + + "\von_conflict\x18\a \x01(\x0e2\x1e.environments.ConflictStrategyR\n" + + "onConflict\x12)\n" + + "\x10environment_keys\x18\b \x03(\tR\x0fenvironmentKeys\x12\x1a\n" + + "\brevision\x18d \x01(\tR\brevisionB\n" + + "\n" + + "\b_payload\"\xc4\x01\n" + + "\x18BulkApplyNamespaceResult\x12'\n" + + "\x0fenvironment_key\x18\x01 \x01(\tR\x0eenvironmentKey\x12#\n" + + "\rnamespace_key\x18\x02 \x01(\tR\fnamespaceKey\x125\n" + + "\x06status\x18\x03 \x01(\x0e2\x1d.environments.OperationStatusR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\"z\n" + + "\x1aBulkApplyResourcesResponse\x12@\n" + + "\aresults\x18\x01 \x03(\v2&.environments.BulkApplyNamespaceResultR\aresults\x12\x1a\n" + "\brevision\x18d \x01(\tR\brevision*g\n" + "\x03SCM\x12\x0f\n" + "\vSCM_UNKNOWN\x10\x00\x12\x0e\n" + @@ -2172,7 +3092,25 @@ const file_environments_environments_proto_rawDesc = "" + "\x16PROPOSAL_STATE_UNKNOWN\x10\x00\x12\x17\n" + "\x13PROPOSAL_STATE_OPEN\x10\x01\x12\x19\n" + "\x15PROPOSAL_STATE_MERGED\x10\x02\x12\x19\n" + - "\x15PROPOSAL_STATE_CLOSED\x10\x032\xf1\x18\n" + + "\x15PROPOSAL_STATE_CLOSED\x10\x03*k\n" + + "\x10ConflictStrategy\x12\x1a\n" + + "\x16CONFLICT_STRATEGY_FAIL\x10\x00\x12\x1f\n" + + "\x1bCONFLICT_STRATEGY_OVERWRITE\x10\x01\x12\x1a\n" + + "\x16CONFLICT_STRATEGY_SKIP\x10\x02*{\n" + + "\rBulkOperation\x12\x19\n" + + "\x15BULK_OPERATION_CREATE\x10\x00\x12\x19\n" + + "\x15BULK_OPERATION_UPDATE\x10\x01\x12\x19\n" + + "\x15BULK_OPERATION_DELETE\x10\x02\x12\x19\n" + + "\x15BULK_OPERATION_UPSERT\x10\x03*j\n" + + "\x0fOperationStatus\x12\x1c\n" + + "\x18OPERATION_STATUS_SUCCESS\x10\x00\x12\x1c\n" + + "\x18OPERATION_STATUS_SKIPPED\x10\x01\x12\x1b\n" + + "\x17OPERATION_STATUS_FAILED\x10\x02*\x8b\x01\n" + + "\rCompareStatus\x12\x1c\n" + + "\x18COMPARE_STATUS_IDENTICAL\x10\x00\x12\x1c\n" + + "\x18COMPARE_STATUS_DIFFERENT\x10\x01\x12\x1e\n" + + "\x1aCOMPARE_STATUS_SOURCE_ONLY\x10\x02\x12\x1e\n" + + "\x1aCOMPARE_STATUS_TARGET_ONLY\x10\x032\xa4\x1d\n" + "\x13EnvironmentsService\x12\x94\x01\n" + "\x10ListEnvironments\x12%.environments.ListEnvironmentsRequest\x1a&.environments.ListEnvironmentsResponse\"1\xbaG\x12*\x10listEnvironments\x82\xd3\xe4\x93\x02\x16\x12\x14/api/v2/environments\x12\xc6\x01\n" + "\x11BranchEnvironment\x12&.environments.BranchEnvironmentRequest\x1a\x19.environments.Environment\"n\xbaG\x19*\x17createBranchEnvironment\xfa\xd2\xe4\x93\x02\x12\x12\x10flipt:sdk:ignore\x82\xd3\xe4\x93\x024:\x01*\"//api/v2/environments/{environment_key}/branches\x12\xd2\x01\n" + @@ -2189,7 +3127,10 @@ const file_environments_environments_proto_rawDesc = "" + "\rListResources\x12\".environments.ListResourcesRequest\x1a#.environments.ListResourcesResponse\"p\xbaG\x0f*\rlistResources\x82\xd3\xe4\x93\x02X\x12V/api/v2/environments/{environment_key}/namespaces/{namespace_key}/resources/{type_url}\x12\xc0\x01\n" + "\x0eCreateResource\x12#.environments.UpdateResourceRequest\x1a\x1e.environments.ResourceResponse\"i\xbaG\x10*\x0ecreateResource\x82\xd3\xe4\x93\x02P:\x01*\"K/api/v2/environments/{environment_key}/namespaces/{namespace_key}/resources\x12\xc0\x01\n" + "\x0eUpdateResource\x12#.environments.UpdateResourceRequest\x1a\x1e.environments.ResourceResponse\"i\xbaG\x10*\x0eupdateResource\x82\xd3\xe4\x93\x02P:\x01*\x1aK/api/v2/environments/{environment_key}/namespaces/{namespace_key}/resources\x12\xd4\x01\n" + - "\x0eDeleteResource\x12#.environments.DeleteResourceRequest\x1a$.environments.DeleteResourceResponse\"w\xbaG\x10*\x0edeleteResource\x82\xd3\xe4\x93\x02^*\\/api/v2/environments/{environment_key}/namespaces/{namespace_key}/resources/{type_url}/{key}B'Z%go.flipt.io/flipt/rpc/v2/environmentsb\x06proto3" + "\x0eDeleteResource\x12#.environments.DeleteResourceRequest\x1a$.environments.DeleteResourceResponse\"w\xbaG\x10*\x0edeleteResource\x82\xd3\xe4\x93\x02^*\\/api/v2/environments/{environment_key}/namespaces/{namespace_key}/resources/{type_url}/{key}\x12\xad\x01\n" + + "\rCopyNamespace\x12\".environments.CopyNamespaceRequest\x1a#.environments.CopyNamespaceResponse\"S\xbaG\x0f*\rcopyNamespace\x82\xd3\xe4\x93\x02;:\x01*\"6/api/v2/environments/{environment_key}/namespaces/copy\x12\xbd\x01\n" + + "\x13CompareEnvironments\x12(.environments.CompareEnvironmentsRequest\x1a).environments.CompareEnvironmentsResponse\"Q\xbaG\x15*\x13compareEnvironments\x82\xd3\xe4\x93\x023:\x01*\"./api/v2/environments/{environment_key}/compare\x12\xc0\x01\n" + + "\x12BulkApplyResources\x12'.environments.BulkApplyResourcesRequest\x1a(.environments.BulkApplyResourcesResponse\"W\xbaG\x14*\x12bulkApplyResources\x82\xd3\xe4\x93\x02::\x01*\"5/api/v2/environments/{environment_key}/resources/bulkB'Z%go.flipt.io/flipt/rpc/v2/environmentsb\x06proto3" var ( file_environments_environments_proto_rawDescOnce sync.Once @@ -2203,95 +3144,126 @@ func file_environments_environments_proto_rawDescGZIP() []byte { return file_environments_environments_proto_rawDescData } -var file_environments_environments_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_environments_environments_proto_msgTypes = make([]protoimpl.MessageInfo, 30) +var file_environments_environments_proto_enumTypes = make([]protoimpl.EnumInfo, 6) +var file_environments_environments_proto_msgTypes = make([]protoimpl.MessageInfo, 39) var file_environments_environments_proto_goTypes = []any{ (SCM)(0), // 0: environments.SCM (ProposalState)(0), // 1: environments.ProposalState - (*Environment)(nil), // 2: environments.Environment - (*EnvironmentConfiguration)(nil), // 3: environments.EnvironmentConfiguration - (*ListEnvironmentsRequest)(nil), // 4: environments.ListEnvironmentsRequest - (*ListEnvironmentsResponse)(nil), // 5: environments.ListEnvironmentsResponse - (*BranchEnvironmentRequest)(nil), // 6: environments.BranchEnvironmentRequest - (*DeleteBranchEnvironmentRequest)(nil), // 7: environments.DeleteBranchEnvironmentRequest - (*BranchEnvironment)(nil), // 8: environments.BranchEnvironment - (*ListEnvironmentBranchesRequest)(nil), // 9: environments.ListEnvironmentBranchesRequest - (*ListEnvironmentBranchesResponse)(nil), // 10: environments.ListEnvironmentBranchesResponse - (*ProposeEnvironmentRequest)(nil), // 11: environments.ProposeEnvironmentRequest - (*EnvironmentProposalDetails)(nil), // 12: environments.EnvironmentProposalDetails - (*Change)(nil), // 13: environments.Change - (*ListBranchedEnvironmentChangesRequest)(nil), // 14: environments.ListBranchedEnvironmentChangesRequest - (*ListBranchedEnvironmentChangesResponse)(nil), // 15: environments.ListBranchedEnvironmentChangesResponse - (*Namespace)(nil), // 16: environments.Namespace - (*GetNamespaceRequest)(nil), // 17: environments.GetNamespaceRequest - (*NamespaceResponse)(nil), // 18: environments.NamespaceResponse - (*ListNamespacesRequest)(nil), // 19: environments.ListNamespacesRequest - (*ListNamespacesResponse)(nil), // 20: environments.ListNamespacesResponse - (*UpdateNamespaceRequest)(nil), // 21: environments.UpdateNamespaceRequest - (*DeleteNamespaceRequest)(nil), // 22: environments.DeleteNamespaceRequest - (*DeleteNamespaceResponse)(nil), // 23: environments.DeleteNamespaceResponse - (*GetResourceRequest)(nil), // 24: environments.GetResourceRequest - (*Resource)(nil), // 25: environments.Resource - (*ResourceResponse)(nil), // 26: environments.ResourceResponse - (*ListResourcesRequest)(nil), // 27: environments.ListResourcesRequest - (*ListResourcesResponse)(nil), // 28: environments.ListResourcesResponse - (*UpdateResourceRequest)(nil), // 29: environments.UpdateResourceRequest - (*DeleteResourceRequest)(nil), // 30: environments.DeleteResourceRequest - (*DeleteResourceResponse)(nil), // 31: environments.DeleteResourceResponse - (*anypb.Any)(nil), // 32: google.protobuf.Any - (*emptypb.Empty)(nil), // 33: google.protobuf.Empty + (ConflictStrategy)(0), // 2: environments.ConflictStrategy + (BulkOperation)(0), // 3: environments.BulkOperation + (OperationStatus)(0), // 4: environments.OperationStatus + (CompareStatus)(0), // 5: environments.CompareStatus + (*Environment)(nil), // 6: environments.Environment + (*EnvironmentConfiguration)(nil), // 7: environments.EnvironmentConfiguration + (*ListEnvironmentsRequest)(nil), // 8: environments.ListEnvironmentsRequest + (*ListEnvironmentsResponse)(nil), // 9: environments.ListEnvironmentsResponse + (*BranchEnvironmentRequest)(nil), // 10: environments.BranchEnvironmentRequest + (*DeleteBranchEnvironmentRequest)(nil), // 11: environments.DeleteBranchEnvironmentRequest + (*BranchEnvironment)(nil), // 12: environments.BranchEnvironment + (*ListEnvironmentBranchesRequest)(nil), // 13: environments.ListEnvironmentBranchesRequest + (*ListEnvironmentBranchesResponse)(nil), // 14: environments.ListEnvironmentBranchesResponse + (*ProposeEnvironmentRequest)(nil), // 15: environments.ProposeEnvironmentRequest + (*EnvironmentProposalDetails)(nil), // 16: environments.EnvironmentProposalDetails + (*Change)(nil), // 17: environments.Change + (*ListBranchedEnvironmentChangesRequest)(nil), // 18: environments.ListBranchedEnvironmentChangesRequest + (*ListBranchedEnvironmentChangesResponse)(nil), // 19: environments.ListBranchedEnvironmentChangesResponse + (*Namespace)(nil), // 20: environments.Namespace + (*GetNamespaceRequest)(nil), // 21: environments.GetNamespaceRequest + (*NamespaceResponse)(nil), // 22: environments.NamespaceResponse + (*ListNamespacesRequest)(nil), // 23: environments.ListNamespacesRequest + (*ListNamespacesResponse)(nil), // 24: environments.ListNamespacesResponse + (*UpdateNamespaceRequest)(nil), // 25: environments.UpdateNamespaceRequest + (*DeleteNamespaceRequest)(nil), // 26: environments.DeleteNamespaceRequest + (*DeleteNamespaceResponse)(nil), // 27: environments.DeleteNamespaceResponse + (*GetResourceRequest)(nil), // 28: environments.GetResourceRequest + (*Resource)(nil), // 29: environments.Resource + (*ResourceResponse)(nil), // 30: environments.ResourceResponse + (*ListResourcesRequest)(nil), // 31: environments.ListResourcesRequest + (*ListResourcesResponse)(nil), // 32: environments.ListResourcesResponse + (*UpdateResourceRequest)(nil), // 33: environments.UpdateResourceRequest + (*DeleteResourceRequest)(nil), // 34: environments.DeleteResourceRequest + (*DeleteResourceResponse)(nil), // 35: environments.DeleteResourceResponse + (*CopyNamespaceRequest)(nil), // 36: environments.CopyNamespaceRequest + (*CopyNamespaceResourceResult)(nil), // 37: environments.CopyNamespaceResourceResult + (*CopyNamespaceResponse)(nil), // 38: environments.CopyNamespaceResponse + (*CompareEnvironmentsRequest)(nil), // 39: environments.CompareEnvironmentsRequest + (*CompareResourceResult)(nil), // 40: environments.CompareResourceResult + (*CompareEnvironmentsResponse)(nil), // 41: environments.CompareEnvironmentsResponse + (*BulkApplyResourcesRequest)(nil), // 42: environments.BulkApplyResourcesRequest + (*BulkApplyNamespaceResult)(nil), // 43: environments.BulkApplyNamespaceResult + (*BulkApplyResourcesResponse)(nil), // 44: environments.BulkApplyResourcesResponse + (*anypb.Any)(nil), // 45: google.protobuf.Any + (*emptypb.Empty)(nil), // 46: google.protobuf.Empty } var file_environments_environments_proto_depIdxs = []int32{ - 3, // 0: environments.Environment.configuration:type_name -> environments.EnvironmentConfiguration + 7, // 0: environments.Environment.configuration:type_name -> environments.EnvironmentConfiguration 0, // 1: environments.EnvironmentConfiguration.scm:type_name -> environments.SCM - 2, // 2: environments.ListEnvironmentsResponse.environments:type_name -> environments.Environment - 12, // 3: environments.BranchEnvironment.proposal:type_name -> environments.EnvironmentProposalDetails - 8, // 4: environments.ListEnvironmentBranchesResponse.branches:type_name -> environments.BranchEnvironment + 6, // 2: environments.ListEnvironmentsResponse.environments:type_name -> environments.Environment + 16, // 3: environments.BranchEnvironment.proposal:type_name -> environments.EnvironmentProposalDetails + 12, // 4: environments.ListEnvironmentBranchesResponse.branches:type_name -> environments.BranchEnvironment 1, // 5: environments.EnvironmentProposalDetails.state:type_name -> environments.ProposalState - 13, // 6: environments.ListBranchedEnvironmentChangesResponse.changes:type_name -> environments.Change - 16, // 7: environments.NamespaceResponse.namespace:type_name -> environments.Namespace - 16, // 8: environments.ListNamespacesResponse.items:type_name -> environments.Namespace - 32, // 9: environments.Resource.payload:type_name -> google.protobuf.Any - 25, // 10: environments.ResourceResponse.resource:type_name -> environments.Resource - 25, // 11: environments.ListResourcesResponse.resources:type_name -> environments.Resource - 32, // 12: environments.UpdateResourceRequest.payload:type_name -> google.protobuf.Any - 4, // 13: environments.EnvironmentsService.ListEnvironments:input_type -> environments.ListEnvironmentsRequest - 6, // 14: environments.EnvironmentsService.BranchEnvironment:input_type -> environments.BranchEnvironmentRequest - 7, // 15: environments.EnvironmentsService.DeleteBranchEnvironment:input_type -> environments.DeleteBranchEnvironmentRequest - 9, // 16: environments.EnvironmentsService.ListEnvironmentBranches:input_type -> environments.ListEnvironmentBranchesRequest - 14, // 17: environments.EnvironmentsService.ListBranchedEnvironmentChanges:input_type -> environments.ListBranchedEnvironmentChangesRequest - 11, // 18: environments.EnvironmentsService.ProposeEnvironment:input_type -> environments.ProposeEnvironmentRequest - 17, // 19: environments.EnvironmentsService.GetNamespace:input_type -> environments.GetNamespaceRequest - 19, // 20: environments.EnvironmentsService.ListNamespaces:input_type -> environments.ListNamespacesRequest - 21, // 21: environments.EnvironmentsService.CreateNamespace:input_type -> environments.UpdateNamespaceRequest - 21, // 22: environments.EnvironmentsService.UpdateNamespace:input_type -> environments.UpdateNamespaceRequest - 22, // 23: environments.EnvironmentsService.DeleteNamespace:input_type -> environments.DeleteNamespaceRequest - 24, // 24: environments.EnvironmentsService.GetResource:input_type -> environments.GetResourceRequest - 27, // 25: environments.EnvironmentsService.ListResources:input_type -> environments.ListResourcesRequest - 29, // 26: environments.EnvironmentsService.CreateResource:input_type -> environments.UpdateResourceRequest - 29, // 27: environments.EnvironmentsService.UpdateResource:input_type -> environments.UpdateResourceRequest - 30, // 28: environments.EnvironmentsService.DeleteResource:input_type -> environments.DeleteResourceRequest - 5, // 29: environments.EnvironmentsService.ListEnvironments:output_type -> environments.ListEnvironmentsResponse - 2, // 30: environments.EnvironmentsService.BranchEnvironment:output_type -> environments.Environment - 33, // 31: environments.EnvironmentsService.DeleteBranchEnvironment:output_type -> google.protobuf.Empty - 10, // 32: environments.EnvironmentsService.ListEnvironmentBranches:output_type -> environments.ListEnvironmentBranchesResponse - 15, // 33: environments.EnvironmentsService.ListBranchedEnvironmentChanges:output_type -> environments.ListBranchedEnvironmentChangesResponse - 12, // 34: environments.EnvironmentsService.ProposeEnvironment:output_type -> environments.EnvironmentProposalDetails - 18, // 35: environments.EnvironmentsService.GetNamespace:output_type -> environments.NamespaceResponse - 20, // 36: environments.EnvironmentsService.ListNamespaces:output_type -> environments.ListNamespacesResponse - 18, // 37: environments.EnvironmentsService.CreateNamespace:output_type -> environments.NamespaceResponse - 18, // 38: environments.EnvironmentsService.UpdateNamespace:output_type -> environments.NamespaceResponse - 23, // 39: environments.EnvironmentsService.DeleteNamespace:output_type -> environments.DeleteNamespaceResponse - 26, // 40: environments.EnvironmentsService.GetResource:output_type -> environments.ResourceResponse - 28, // 41: environments.EnvironmentsService.ListResources:output_type -> environments.ListResourcesResponse - 26, // 42: environments.EnvironmentsService.CreateResource:output_type -> environments.ResourceResponse - 26, // 43: environments.EnvironmentsService.UpdateResource:output_type -> environments.ResourceResponse - 31, // 44: environments.EnvironmentsService.DeleteResource:output_type -> environments.DeleteResourceResponse - 29, // [29:45] is the sub-list for method output_type - 13, // [13:29] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 17, // 6: environments.ListBranchedEnvironmentChangesResponse.changes:type_name -> environments.Change + 20, // 7: environments.NamespaceResponse.namespace:type_name -> environments.Namespace + 20, // 8: environments.ListNamespacesResponse.items:type_name -> environments.Namespace + 45, // 9: environments.Resource.payload:type_name -> google.protobuf.Any + 29, // 10: environments.ResourceResponse.resource:type_name -> environments.Resource + 29, // 11: environments.ListResourcesResponse.resources:type_name -> environments.Resource + 45, // 12: environments.UpdateResourceRequest.payload:type_name -> google.protobuf.Any + 2, // 13: environments.CopyNamespaceRequest.on_conflict:type_name -> environments.ConflictStrategy + 4, // 14: environments.CopyNamespaceResourceResult.status:type_name -> environments.OperationStatus + 37, // 15: environments.CopyNamespaceResponse.results:type_name -> environments.CopyNamespaceResourceResult + 5, // 16: environments.CompareResourceResult.status:type_name -> environments.CompareStatus + 29, // 17: environments.CompareResourceResult.source:type_name -> environments.Resource + 29, // 18: environments.CompareResourceResult.target:type_name -> environments.Resource + 40, // 19: environments.CompareEnvironmentsResponse.results:type_name -> environments.CompareResourceResult + 3, // 20: environments.BulkApplyResourcesRequest.operation:type_name -> environments.BulkOperation + 45, // 21: environments.BulkApplyResourcesRequest.payload:type_name -> google.protobuf.Any + 2, // 22: environments.BulkApplyResourcesRequest.on_conflict:type_name -> environments.ConflictStrategy + 4, // 23: environments.BulkApplyNamespaceResult.status:type_name -> environments.OperationStatus + 43, // 24: environments.BulkApplyResourcesResponse.results:type_name -> environments.BulkApplyNamespaceResult + 8, // 25: environments.EnvironmentsService.ListEnvironments:input_type -> environments.ListEnvironmentsRequest + 10, // 26: environments.EnvironmentsService.BranchEnvironment:input_type -> environments.BranchEnvironmentRequest + 11, // 27: environments.EnvironmentsService.DeleteBranchEnvironment:input_type -> environments.DeleteBranchEnvironmentRequest + 13, // 28: environments.EnvironmentsService.ListEnvironmentBranches:input_type -> environments.ListEnvironmentBranchesRequest + 18, // 29: environments.EnvironmentsService.ListBranchedEnvironmentChanges:input_type -> environments.ListBranchedEnvironmentChangesRequest + 15, // 30: environments.EnvironmentsService.ProposeEnvironment:input_type -> environments.ProposeEnvironmentRequest + 21, // 31: environments.EnvironmentsService.GetNamespace:input_type -> environments.GetNamespaceRequest + 23, // 32: environments.EnvironmentsService.ListNamespaces:input_type -> environments.ListNamespacesRequest + 25, // 33: environments.EnvironmentsService.CreateNamespace:input_type -> environments.UpdateNamespaceRequest + 25, // 34: environments.EnvironmentsService.UpdateNamespace:input_type -> environments.UpdateNamespaceRequest + 26, // 35: environments.EnvironmentsService.DeleteNamespace:input_type -> environments.DeleteNamespaceRequest + 28, // 36: environments.EnvironmentsService.GetResource:input_type -> environments.GetResourceRequest + 31, // 37: environments.EnvironmentsService.ListResources:input_type -> environments.ListResourcesRequest + 33, // 38: environments.EnvironmentsService.CreateResource:input_type -> environments.UpdateResourceRequest + 33, // 39: environments.EnvironmentsService.UpdateResource:input_type -> environments.UpdateResourceRequest + 34, // 40: environments.EnvironmentsService.DeleteResource:input_type -> environments.DeleteResourceRequest + 36, // 41: environments.EnvironmentsService.CopyNamespace:input_type -> environments.CopyNamespaceRequest + 39, // 42: environments.EnvironmentsService.CompareEnvironments:input_type -> environments.CompareEnvironmentsRequest + 42, // 43: environments.EnvironmentsService.BulkApplyResources:input_type -> environments.BulkApplyResourcesRequest + 9, // 44: environments.EnvironmentsService.ListEnvironments:output_type -> environments.ListEnvironmentsResponse + 6, // 45: environments.EnvironmentsService.BranchEnvironment:output_type -> environments.Environment + 46, // 46: environments.EnvironmentsService.DeleteBranchEnvironment:output_type -> google.protobuf.Empty + 14, // 47: environments.EnvironmentsService.ListEnvironmentBranches:output_type -> environments.ListEnvironmentBranchesResponse + 19, // 48: environments.EnvironmentsService.ListBranchedEnvironmentChanges:output_type -> environments.ListBranchedEnvironmentChangesResponse + 16, // 49: environments.EnvironmentsService.ProposeEnvironment:output_type -> environments.EnvironmentProposalDetails + 22, // 50: environments.EnvironmentsService.GetNamespace:output_type -> environments.NamespaceResponse + 24, // 51: environments.EnvironmentsService.ListNamespaces:output_type -> environments.ListNamespacesResponse + 22, // 52: environments.EnvironmentsService.CreateNamespace:output_type -> environments.NamespaceResponse + 22, // 53: environments.EnvironmentsService.UpdateNamespace:output_type -> environments.NamespaceResponse + 27, // 54: environments.EnvironmentsService.DeleteNamespace:output_type -> environments.DeleteNamespaceResponse + 30, // 55: environments.EnvironmentsService.GetResource:output_type -> environments.ResourceResponse + 32, // 56: environments.EnvironmentsService.ListResources:output_type -> environments.ListResourcesResponse + 30, // 57: environments.EnvironmentsService.CreateResource:output_type -> environments.ResourceResponse + 30, // 58: environments.EnvironmentsService.UpdateResource:output_type -> environments.ResourceResponse + 35, // 59: environments.EnvironmentsService.DeleteResource:output_type -> environments.DeleteResourceResponse + 38, // 60: environments.EnvironmentsService.CopyNamespace:output_type -> environments.CopyNamespaceResponse + 41, // 61: environments.EnvironmentsService.CompareEnvironments:output_type -> environments.CompareEnvironmentsResponse + 44, // 62: environments.EnvironmentsService.BulkApplyResources:output_type -> environments.BulkApplyResourcesResponse + 44, // [44:63] is the sub-list for method output_type + 25, // [25:44] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name } func init() { file_environments_environments_proto_init() } @@ -2307,13 +3279,17 @@ func file_environments_environments_proto_init() { file_environments_environments_proto_msgTypes[12].OneofWrappers = []any{} file_environments_environments_proto_msgTypes[14].OneofWrappers = []any{} file_environments_environments_proto_msgTypes[19].OneofWrappers = []any{} + file_environments_environments_proto_msgTypes[31].OneofWrappers = []any{} + file_environments_environments_proto_msgTypes[34].OneofWrappers = []any{} + file_environments_environments_proto_msgTypes[36].OneofWrappers = []any{} + file_environments_environments_proto_msgTypes[37].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_environments_environments_proto_rawDesc), len(file_environments_environments_proto_rawDesc)), - NumEnums: 2, - NumMessages: 30, + NumEnums: 6, + NumMessages: 39, NumExtensions: 0, NumServices: 1, }, diff --git a/rpc/v2/environments/environments.pb.gw.go b/rpc/v2/environments/environments.pb.gw.go index cdfa72ab65..285facb2a6 100644 --- a/rpc/v2/environments/environments.pb.gw.go +++ b/rpc/v2/environments/environments.pb.gw.go @@ -959,6 +959,141 @@ func local_request_EnvironmentsService_DeleteResource_0(ctx context.Context, mar return msg, metadata, err } +func request_EnvironmentsService_CopyNamespace_0(ctx context.Context, marshaler runtime.Marshaler, client EnvironmentsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CopyNamespaceRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["environment_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "environment_key") + } + protoReq.EnvironmentKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "environment_key", err) + } + msg, err := client.CopyNamespace(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_EnvironmentsService_CopyNamespace_0(ctx context.Context, marshaler runtime.Marshaler, server EnvironmentsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CopyNamespaceRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["environment_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "environment_key") + } + protoReq.EnvironmentKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "environment_key", err) + } + msg, err := server.CopyNamespace(ctx, &protoReq) + return msg, metadata, err +} + +func request_EnvironmentsService_CompareEnvironments_0(ctx context.Context, marshaler runtime.Marshaler, client EnvironmentsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CompareEnvironmentsRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["environment_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "environment_key") + } + protoReq.EnvironmentKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "environment_key", err) + } + msg, err := client.CompareEnvironments(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_EnvironmentsService_CompareEnvironments_0(ctx context.Context, marshaler runtime.Marshaler, server EnvironmentsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CompareEnvironmentsRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["environment_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "environment_key") + } + protoReq.EnvironmentKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "environment_key", err) + } + msg, err := server.CompareEnvironments(ctx, &protoReq) + return msg, metadata, err +} + +func request_EnvironmentsService_BulkApplyResources_0(ctx context.Context, marshaler runtime.Marshaler, client EnvironmentsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq BulkApplyResourcesRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["environment_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "environment_key") + } + protoReq.EnvironmentKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "environment_key", err) + } + msg, err := client.BulkApplyResources(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_EnvironmentsService_BulkApplyResources_0(ctx context.Context, marshaler runtime.Marshaler, server EnvironmentsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq BulkApplyResourcesRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["environment_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "environment_key") + } + protoReq.EnvironmentKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "environment_key", err) + } + msg, err := server.BulkApplyResources(ctx, &protoReq) + return msg, metadata, err +} + // RegisterEnvironmentsServiceHandlerServer registers the http handlers for service EnvironmentsService to "mux". // UnaryRPC :call EnvironmentsServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -1285,6 +1420,66 @@ func RegisterEnvironmentsServiceHandlerServer(ctx context.Context, mux *runtime. } forward_EnvironmentsService_DeleteResource_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_EnvironmentsService_CopyNamespace_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/environments.EnvironmentsService/CopyNamespace", runtime.WithHTTPPathPattern("/api/v2/environments/{environment_key}/namespaces/copy")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_EnvironmentsService_CopyNamespace_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EnvironmentsService_CopyNamespace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_EnvironmentsService_CompareEnvironments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/environments.EnvironmentsService/CompareEnvironments", runtime.WithHTTPPathPattern("/api/v2/environments/{environment_key}/compare")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_EnvironmentsService_CompareEnvironments_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EnvironmentsService_CompareEnvironments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_EnvironmentsService_BulkApplyResources_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/environments.EnvironmentsService/BulkApplyResources", runtime.WithHTTPPathPattern("/api/v2/environments/{environment_key}/resources/bulk")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_EnvironmentsService_BulkApplyResources_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EnvironmentsService_BulkApplyResources_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1597,6 +1792,57 @@ func RegisterEnvironmentsServiceHandlerClient(ctx context.Context, mux *runtime. } forward_EnvironmentsService_DeleteResource_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_EnvironmentsService_CopyNamespace_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/environments.EnvironmentsService/CopyNamespace", runtime.WithHTTPPathPattern("/api/v2/environments/{environment_key}/namespaces/copy")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_EnvironmentsService_CopyNamespace_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EnvironmentsService_CopyNamespace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_EnvironmentsService_CompareEnvironments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/environments.EnvironmentsService/CompareEnvironments", runtime.WithHTTPPathPattern("/api/v2/environments/{environment_key}/compare")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_EnvironmentsService_CompareEnvironments_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EnvironmentsService_CompareEnvironments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_EnvironmentsService_BulkApplyResources_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/environments.EnvironmentsService/BulkApplyResources", runtime.WithHTTPPathPattern("/api/v2/environments/{environment_key}/resources/bulk")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_EnvironmentsService_BulkApplyResources_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EnvironmentsService_BulkApplyResources_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1617,6 +1863,9 @@ var ( pattern_EnvironmentsService_CreateResource_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v2", "environments", "environment_key", "namespaces", "namespace_key", "resources"}, "")) pattern_EnvironmentsService_UpdateResource_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6}, []string{"api", "v2", "environments", "environment_key", "namespaces", "namespace_key", "resources"}, "")) pattern_EnvironmentsService_DeleteResource_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5, 2, 6, 1, 0, 4, 1, 5, 7, 1, 0, 4, 1, 5, 8}, []string{"api", "v2", "environments", "environment_key", "namespaces", "namespace_key", "resources", "type_url", "key"}, "")) + pattern_EnvironmentsService_CopyNamespace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 2, 5}, []string{"api", "v2", "environments", "environment_key", "namespaces", "copy"}, "")) + pattern_EnvironmentsService_CompareEnvironments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v2", "environments", "environment_key", "compare"}, "")) + pattern_EnvironmentsService_BulkApplyResources_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 2, 5}, []string{"api", "v2", "environments", "environment_key", "resources", "bulk"}, "")) ) var ( @@ -1636,4 +1885,7 @@ var ( forward_EnvironmentsService_CreateResource_0 = runtime.ForwardResponseMessage forward_EnvironmentsService_UpdateResource_0 = runtime.ForwardResponseMessage forward_EnvironmentsService_DeleteResource_0 = runtime.ForwardResponseMessage + forward_EnvironmentsService_CopyNamespace_0 = runtime.ForwardResponseMessage + forward_EnvironmentsService_CompareEnvironments_0 = runtime.ForwardResponseMessage + forward_EnvironmentsService_BulkApplyResources_0 = runtime.ForwardResponseMessage ) diff --git a/rpc/v2/environments/environments.proto b/rpc/v2/environments/environments.proto index 68fc72312f..90fdecbb36 100644 --- a/rpc/v2/environments/environments.proto +++ b/rpc/v2/environments/environments.proto @@ -129,6 +129,33 @@ service EnvironmentsService { option (google.api.http) = {delete: "/api/v2/environments/{environment_key}/namespaces/{namespace_key}/resources/{type_url}/{key}"}; option (gnostic.openapi.v3.operation) = {operation_id: "deleteResource"}; } + + // Copy all resources from one namespace to another. + rpc CopyNamespace(CopyNamespaceRequest) returns (CopyNamespaceResponse) { + option (google.api.http) = { + post: "/api/v2/environments/{environment_key}/namespaces/copy" + body: "*" + }; + option (gnostic.openapi.v3.operation) = {operation_id: "copyNamespace"}; + } + + // Compare resources between source and target environment namespaces. + rpc CompareEnvironments(CompareEnvironmentsRequest) returns (CompareEnvironmentsResponse) { + option (google.api.http) = { + post: "/api/v2/environments/{environment_key}/compare" + body: "*" + }; + option (gnostic.openapi.v3.operation) = {operation_id: "compareEnvironments"}; + } + + // Apply an operation to a resource across multiple namespaces. + rpc BulkApplyResources(BulkApplyResourcesRequest) returns (BulkApplyResourcesResponse) { + option (google.api.http) = { + post: "/api/v2/environments/{environment_key}/resources/bulk" + body: "*" + }; + option (gnostic.openapi.v3.operation) = {operation_id: "bulkApplyResources"}; + } } /* Environments */ @@ -452,3 +479,131 @@ message DeleteResourceResponse { // The resource revision for optimistic concurrency. string revision = 100; } + +/* Cross-Environment Operations */ + +// Conflict resolution strategy for copy/bulk operations. +enum ConflictStrategy { + CONFLICT_STRATEGY_FAIL = 0; + CONFLICT_STRATEGY_OVERWRITE = 1; + CONFLICT_STRATEGY_SKIP = 2; +} + +// Bulk operation type. +enum BulkOperation { + BULK_OPERATION_CREATE = 0; + BULK_OPERATION_UPDATE = 1; + BULK_OPERATION_DELETE = 2; + BULK_OPERATION_UPSERT = 3; +} + +// Per-resource/namespace operation result status. +enum OperationStatus { + OPERATION_STATUS_SUCCESS = 0; + OPERATION_STATUS_SKIPPED = 1; + OPERATION_STATUS_FAILED = 2; +} + +// Request to copy all resources from one namespace to another. +message CopyNamespaceRequest { + // Target environment. + string environment_key = 1; + // Target namespace key (created if it doesn't exist). + string namespace_key = 2; + // Source coordinates. + string source_environment_key = 3; + string source_namespace_key = 4; + // Conflict strategy. + ConflictStrategy on_conflict = 5; + // Revision for optimistic concurrency on the target environment. + string revision = 100; +} + +// Per-resource result from a namespace copy. +message CopyNamespaceResourceResult { + string type_url = 1; + string key = 2; + OperationStatus status = 3; + // Error message if status is FAILED. + optional string error = 4; +} + +// Response after copying a namespace. +message CopyNamespaceResponse { + repeated CopyNamespaceResourceResult results = 1; + string revision = 100; +} + +// Comparison status for a resource key across two env/namespace pairs. +enum CompareStatus { + COMPARE_STATUS_IDENTICAL = 0; + COMPARE_STATUS_DIFFERENT = 1; + COMPARE_STATUS_SOURCE_ONLY = 2; + COMPARE_STATUS_TARGET_ONLY = 3; +} + +// Request to compare resources across source and target environment/namespace. +message CompareEnvironmentsRequest { + // Source environment key. + string environment_key = 1; + // Source namespace key. + string namespace_key = 2; + // Target environment key. + string target_environment_key = 3; + // Target namespace key. + string target_namespace_key = 4; + // Optional list of resource type urls (e.g. flipt.core.Flag). Defaults to known resource types. + repeated string type_urls = 5; +} + +// Per-resource comparison result. +message CompareResourceResult { + string type_url = 1; + string key = 2; + CompareStatus status = 3; + optional Resource source = 4; + optional Resource target = 5; +} + +// Response for environment comparison. +message CompareEnvironmentsResponse { + repeated CompareResourceResult results = 1; +} + +// Request to apply an operation to a resource across multiple namespaces. +message BulkApplyResourcesRequest { + // Primary environment key (also used for request path binding). + string environment_key = 1; + // Explicit list of target namespace keys. + repeated string namespace_keys = 2; + // Operation to apply. + BulkOperation operation = 3; + // Resource type. + string type_url = 4; + // Resource key (for UPDATE, DELETE, UPSERT). + string key = 5; + // Resource payload (for CREATE, UPDATE, UPSERT). + optional google.protobuf.Any payload = 6; + // Conflict strategy (for CREATE, UPSERT). + ConflictStrategy on_conflict = 7; + // Optional list of target environment keys. + // If empty, only `environment_key` is targeted. + repeated string environment_keys = 8; + // Revision for optimistic concurrency. + string revision = 100; +} + +// Per-namespace result from a bulk apply. +message BulkApplyNamespaceResult { + string environment_key = 1; + string namespace_key = 2; + OperationStatus status = 3; + optional string error = 4; +} + +// Response after bulk applying resources. +message BulkApplyResourcesResponse { + repeated BulkApplyNamespaceResult results = 1; + // Revision for `environment_key` from the request. + string revision = 100; +} diff --git a/rpc/v2/environments/environments_grpc.pb.go b/rpc/v2/environments/environments_grpc.pb.go index 20bbc3cff0..4a2fe99ee9 100644 --- a/rpc/v2/environments/environments_grpc.pb.go +++ b/rpc/v2/environments/environments_grpc.pb.go @@ -36,6 +36,9 @@ const ( EnvironmentsService_CreateResource_FullMethodName = "/environments.EnvironmentsService/CreateResource" EnvironmentsService_UpdateResource_FullMethodName = "/environments.EnvironmentsService/UpdateResource" EnvironmentsService_DeleteResource_FullMethodName = "/environments.EnvironmentsService/DeleteResource" + EnvironmentsService_CopyNamespace_FullMethodName = "/environments.EnvironmentsService/CopyNamespace" + EnvironmentsService_CompareEnvironments_FullMethodName = "/environments.EnvironmentsService/CompareEnvironments" + EnvironmentsService_BulkApplyResources_FullMethodName = "/environments.EnvironmentsService/BulkApplyResources" ) // EnvironmentsServiceClient is the client API for EnvironmentsService service. @@ -74,6 +77,12 @@ type EnvironmentsServiceClient interface { UpdateResource(ctx context.Context, in *UpdateResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error) // Delete a resource within a given namespace. DeleteResource(ctx context.Context, in *DeleteResourceRequest, opts ...grpc.CallOption) (*DeleteResourceResponse, error) + // Copy all resources from one namespace to another. + CopyNamespace(ctx context.Context, in *CopyNamespaceRequest, opts ...grpc.CallOption) (*CopyNamespaceResponse, error) + // Compare resources between source and target environment namespaces. + CompareEnvironments(ctx context.Context, in *CompareEnvironmentsRequest, opts ...grpc.CallOption) (*CompareEnvironmentsResponse, error) + // Apply an operation to a resource across multiple namespaces. + BulkApplyResources(ctx context.Context, in *BulkApplyResourcesRequest, opts ...grpc.CallOption) (*BulkApplyResourcesResponse, error) } type environmentsServiceClient struct { @@ -244,6 +253,36 @@ func (c *environmentsServiceClient) DeleteResource(ctx context.Context, in *Dele return out, nil } +func (c *environmentsServiceClient) CopyNamespace(ctx context.Context, in *CopyNamespaceRequest, opts ...grpc.CallOption) (*CopyNamespaceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CopyNamespaceResponse) + err := c.cc.Invoke(ctx, EnvironmentsService_CopyNamespace_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *environmentsServiceClient) CompareEnvironments(ctx context.Context, in *CompareEnvironmentsRequest, opts ...grpc.CallOption) (*CompareEnvironmentsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CompareEnvironmentsResponse) + err := c.cc.Invoke(ctx, EnvironmentsService_CompareEnvironments_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *environmentsServiceClient) BulkApplyResources(ctx context.Context, in *BulkApplyResourcesRequest, opts ...grpc.CallOption) (*BulkApplyResourcesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BulkApplyResourcesResponse) + err := c.cc.Invoke(ctx, EnvironmentsService_BulkApplyResources_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // EnvironmentsServiceServer is the server API for EnvironmentsService service. // All implementations must embed UnimplementedEnvironmentsServiceServer // for forward compatibility. @@ -280,6 +319,12 @@ type EnvironmentsServiceServer interface { UpdateResource(context.Context, *UpdateResourceRequest) (*ResourceResponse, error) // Delete a resource within a given namespace. DeleteResource(context.Context, *DeleteResourceRequest) (*DeleteResourceResponse, error) + // Copy all resources from one namespace to another. + CopyNamespace(context.Context, *CopyNamespaceRequest) (*CopyNamespaceResponse, error) + // Compare resources between source and target environment namespaces. + CompareEnvironments(context.Context, *CompareEnvironmentsRequest) (*CompareEnvironmentsResponse, error) + // Apply an operation to a resource across multiple namespaces. + BulkApplyResources(context.Context, *BulkApplyResourcesRequest) (*BulkApplyResourcesResponse, error) mustEmbedUnimplementedEnvironmentsServiceServer() } @@ -338,6 +383,15 @@ func (UnimplementedEnvironmentsServiceServer) UpdateResource(context.Context, *U func (UnimplementedEnvironmentsServiceServer) DeleteResource(context.Context, *DeleteResourceRequest) (*DeleteResourceResponse, error) { return nil, status.Error(codes.Unimplemented, "method DeleteResource not implemented") } +func (UnimplementedEnvironmentsServiceServer) CopyNamespace(context.Context, *CopyNamespaceRequest) (*CopyNamespaceResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CopyNamespace not implemented") +} +func (UnimplementedEnvironmentsServiceServer) CompareEnvironments(context.Context, *CompareEnvironmentsRequest) (*CompareEnvironmentsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CompareEnvironments not implemented") +} +func (UnimplementedEnvironmentsServiceServer) BulkApplyResources(context.Context, *BulkApplyResourcesRequest) (*BulkApplyResourcesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method BulkApplyResources not implemented") +} func (UnimplementedEnvironmentsServiceServer) mustEmbedUnimplementedEnvironmentsServiceServer() {} func (UnimplementedEnvironmentsServiceServer) testEmbeddedByValue() {} @@ -647,6 +701,60 @@ func _EnvironmentsService_DeleteResource_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } +func _EnvironmentsService_CopyNamespace_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CopyNamespaceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EnvironmentsServiceServer).CopyNamespace(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EnvironmentsService_CopyNamespace_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EnvironmentsServiceServer).CopyNamespace(ctx, req.(*CopyNamespaceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EnvironmentsService_CompareEnvironments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CompareEnvironmentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EnvironmentsServiceServer).CompareEnvironments(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EnvironmentsService_CompareEnvironments_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EnvironmentsServiceServer).CompareEnvironments(ctx, req.(*CompareEnvironmentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _EnvironmentsService_BulkApplyResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BulkApplyResourcesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EnvironmentsServiceServer).BulkApplyResources(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EnvironmentsService_BulkApplyResources_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EnvironmentsServiceServer).BulkApplyResources(ctx, req.(*BulkApplyResourcesRequest)) + } + return interceptor(ctx, in, info, handler) +} + // EnvironmentsService_ServiceDesc is the grpc.ServiceDesc for EnvironmentsService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -718,6 +826,18 @@ var EnvironmentsService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteResource", Handler: _EnvironmentsService_DeleteResource_Handler, }, + { + MethodName: "CopyNamespace", + Handler: _EnvironmentsService_CopyNamespace_Handler, + }, + { + MethodName: "CompareEnvironments", + Handler: _EnvironmentsService_CompareEnvironments_Handler, + }, + { + MethodName: "BulkApplyResources", + Handler: _EnvironmentsService_BulkApplyResources_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "environments/environments.proto", diff --git a/rpc/v2/environments/openapi.yaml b/rpc/v2/environments/openapi.yaml index cc9bd8172e..8539732396 100644 --- a/rpc/v2/environments/openapi.yaml +++ b/rpc/v2/environments/openapi.yaml @@ -155,6 +155,32 @@ paths: application/json: schema: $ref: '#/components/schemas/ListBranchedEnvironmentChangesResponse' + /api/v2/environments/{environmentKey}/compare: + post: + tags: + - EnvironmentsService + description: Compare resources between source and target environment namespaces. + operationId: compareEnvironments + parameters: + - name: environmentKey + in: path + description: Source environment key. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompareEnvironmentsRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CompareEnvironmentsResponse' /api/v2/environments/{environmentKey}/namespaces: get: tags: @@ -225,6 +251,32 @@ paths: application/json: schema: $ref: '#/components/schemas/NamespaceResponse' + /api/v2/environments/{environmentKey}/namespaces/copy: + post: + tags: + - EnvironmentsService + description: Copy all resources from one namespace to another. + operationId: copyNamespace + parameters: + - name: environmentKey + in: path + description: Target environment. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CopyNamespaceRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CopyNamespaceResponse' /api/v2/environments/{environmentKey}/namespaces/{key}: get: tags: @@ -455,6 +507,32 @@ paths: application/json: schema: $ref: '#/components/schemas/DeleteResourceResponse' + /api/v2/environments/{environmentKey}/resources/bulk: + post: + tags: + - EnvironmentsService + description: Apply an operation to a resource across multiple namespaces. + operationId: bulkApplyResources + parameters: + - name: environmentKey + in: path + description: Primary environment key (also used for request path binding). + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BulkApplyResourcesRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BulkApplyResourcesResponse' components: schemas: BranchEnvironment: @@ -484,6 +562,83 @@ components: type: string description: The branch key. description: The request message for creating a branch environment. + BulkApplyNamespaceResult: + type: object + properties: + environmentKey: + type: string + namespaceKey: + type: string + status: + enum: + - OPERATION_STATUS_SUCCESS + - OPERATION_STATUS_SKIPPED + - OPERATION_STATUS_FAILED + type: string + format: enum + error: + type: string + description: Per-namespace result from a bulk apply. + BulkApplyResourcesRequest: + type: object + properties: + environmentKey: + type: string + description: Primary environment key (also used for request path binding). + namespaceKeys: + type: array + items: + type: string + description: Explicit list of target namespace keys. + operation: + enum: + - BULK_OPERATION_CREATE + - BULK_OPERATION_UPDATE + - BULK_OPERATION_DELETE + - BULK_OPERATION_UPSERT + type: string + description: Operation to apply. + format: enum + typeUrl: + type: string + description: Resource type. + key: + type: string + description: Resource key (for UPDATE, DELETE, UPSERT). + payload: + allOf: + - $ref: '#/components/schemas/GoogleProtobufAny' + description: Resource payload (for CREATE, UPDATE, UPSERT). + onConflict: + enum: + - CONFLICT_STRATEGY_FAIL + - CONFLICT_STRATEGY_OVERWRITE + - CONFLICT_STRATEGY_SKIP + type: string + description: Conflict strategy (for CREATE, UPSERT). + format: enum + environmentKeys: + type: array + items: + type: string + description: |- + Optional list of target environment keys. + If empty, only `environment_key` is targeted. + revision: + type: string + description: Revision for optimistic concurrency. + description: Request to apply an operation to a resource across multiple namespaces. + BulkApplyResourcesResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/BulkApplyNamespaceResult' + revision: + type: string + description: Revision for `environment_key` from the request. + description: Response after bulk applying resources. Change: type: object properties: @@ -506,6 +661,109 @@ components: type: string description: The URL of the commit in the source control management system. description: The change represents a single commit in the source control management system. + CompareEnvironmentsRequest: + type: object + properties: + environmentKey: + type: string + description: Source environment key. + namespaceKey: + type: string + description: Source namespace key. + targetEnvironmentKey: + type: string + description: Target environment key. + targetNamespaceKey: + type: string + description: Target namespace key. + typeUrls: + type: array + items: + type: string + description: Optional list of resource type urls (e.g. flipt.core.Flag). Defaults to known resource types. + description: Request to compare resources across source and target environment/namespace. + CompareEnvironmentsResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/CompareResourceResult' + description: Response for environment comparison. + CompareResourceResult: + type: object + properties: + typeUrl: + type: string + key: + type: string + status: + enum: + - COMPARE_STATUS_IDENTICAL + - COMPARE_STATUS_DIFFERENT + - COMPARE_STATUS_SOURCE_ONLY + - COMPARE_STATUS_TARGET_ONLY + type: string + format: enum + source: + $ref: '#/components/schemas/Resource' + target: + $ref: '#/components/schemas/Resource' + description: Per-resource comparison result. + CopyNamespaceRequest: + type: object + properties: + environmentKey: + type: string + description: Target environment. + namespaceKey: + type: string + description: Target namespace key (created if it doesn't exist). + sourceEnvironmentKey: + type: string + description: Source coordinates. + sourceNamespaceKey: + type: string + onConflict: + enum: + - CONFLICT_STRATEGY_FAIL + - CONFLICT_STRATEGY_OVERWRITE + - CONFLICT_STRATEGY_SKIP + type: string + description: Conflict strategy. + format: enum + revision: + type: string + description: Revision for optimistic concurrency on the target environment. + description: Request to copy all resources from one namespace to another. + CopyNamespaceResourceResult: + type: object + properties: + typeUrl: + type: string + key: + type: string + status: + enum: + - OPERATION_STATUS_SUCCESS + - OPERATION_STATUS_SKIPPED + - OPERATION_STATUS_FAILED + type: string + format: enum + error: + type: string + description: Error message if status is FAILED. + description: Per-resource result from a namespace copy. + CopyNamespaceResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/CopyNamespaceResourceResult' + revision: + type: string + description: Response after copying a namespace. DeleteNamespaceResponse: type: object properties: diff --git a/rpc/v2/environments/request.go b/rpc/v2/environments/request.go index 4ae67a2f40..d74ea8013e 100644 --- a/rpc/v2/environments/request.go +++ b/rpc/v2/environments/request.go @@ -59,3 +59,38 @@ func (r *UpdateResourceRequest) Request() []flipt.Request { func (r *DeleteResourceRequest) Request() []flipt.Request { return []flipt.Request{flipt.NewRequest(flipt.ScopeNamespace, flipt.ActionDelete, flipt.WithEnvironment(r.EnvironmentKey), flipt.WithNamespace(r.NamespaceKey))} } + +func (r *CopyNamespaceRequest) Request() []flipt.Request { + return []flipt.Request{ + flipt.NewRequest(flipt.ScopeEnvironment, flipt.ActionRead, flipt.WithEnvironment(r.SourceEnvironmentKey)), + flipt.NewRequest(flipt.ScopeEnvironment, flipt.ActionUpdate, flipt.WithEnvironment(r.EnvironmentKey)), + } +} + +func (r *CompareEnvironmentsRequest) Request() []flipt.Request { + return []flipt.Request{ + flipt.NewRequest(flipt.ScopeEnvironment, flipt.ActionRead, flipt.WithEnvironment(r.EnvironmentKey)), + flipt.NewRequest(flipt.ScopeEnvironment, flipt.ActionRead, flipt.WithEnvironment(r.TargetEnvironmentKey)), + } +} + +func (r *BulkApplyResourcesRequest) Request() []flipt.Request { + environmentKeys := r.GetEnvironmentKeys() + if len(environmentKeys) == 0 { + environmentKeys = []string{r.EnvironmentKey} + } + + requests := make([]flipt.Request, 0, len(environmentKeys)) + seen := make(map[string]struct{}, len(environmentKeys)) + + for _, key := range environmentKeys { + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + requests = append(requests, flipt.NewRequest(flipt.ScopeEnvironment, flipt.ActionUpdate, flipt.WithEnvironment(key))) + } + + return requests +} diff --git a/rpc/v2/environments/validation.go b/rpc/v2/environments/validation.go index 3dc8564d3b..d48a9ca48d 100644 --- a/rpc/v2/environments/validation.go +++ b/rpc/v2/environments/validation.go @@ -2,6 +2,8 @@ package environments import "go.flipt.io/flipt/errors" +const maxBulkApplyTargetPairs = 100 + func (r *GetNamespaceRequest) Validate() error { return requireKey(r) } @@ -26,6 +28,56 @@ func (r *DeleteResourceRequest) Validate() error { return requireKey(r) } +func (r *CopyNamespaceRequest) Validate() error { + if r.GetNamespaceKey() == "" { + return errors.ErrInvalid("namespace_key must not be empty") + } + if r.GetSourceEnvironmentKey() == "" { + return errors.ErrInvalid("source_environment_key must not be empty") + } + if r.GetSourceNamespaceKey() == "" { + return errors.ErrInvalid("source_namespace_key must not be empty") + } + return nil +} + +func (r *CompareEnvironmentsRequest) Validate() error { + if r.GetNamespaceKey() == "" { + return errors.ErrInvalid("namespace_key must not be empty") + } + if r.GetTargetEnvironmentKey() == "" { + return errors.ErrInvalid("target_environment_key must not be empty") + } + if r.GetTargetNamespaceKey() == "" { + return errors.ErrInvalid("target_namespace_key must not be empty") + } + return nil +} + +func (r *BulkApplyResourcesRequest) Validate() error { + if r.GetEnvironmentKey() == "" && len(r.GetEnvironmentKeys()) == 0 { + return errors.ErrInvalid("environment_key or environment_keys must not be empty") + } + + if len(r.GetNamespaceKeys()) == 0 { + return errors.ErrInvalid("namespace_keys must not be empty") + } + + environmentCount := len(r.GetEnvironmentKeys()) + if environmentCount == 0 { + environmentCount = 1 + } + + if len(r.GetNamespaceKeys())*environmentCount > maxBulkApplyTargetPairs { + return errors.ErrInvalidf( + "bulk apply exceeds max target pairs (%d)", + maxBulkApplyTargetPairs, + ) + } + + return nil +} + func requireKey(k keyed) error { if k.GetKey() == "" { return errors.ErrInvalid("key must not be empty") diff --git a/rpc/v2/environments/validation_test.go b/rpc/v2/environments/validation_test.go new file mode 100644 index 0000000000..d1df7f5803 --- /dev/null +++ b/rpc/v2/environments/validation_test.go @@ -0,0 +1,39 @@ +package environments + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBulkApplyResourcesRequestValidate(t *testing.T) { + t.Run("allows request within target pair limit", func(t *testing.T) { + req := &BulkApplyResourcesRequest{ + EnvironmentKeys: []string{"dev", "prod"}, + NamespaceKeys: make([]string, 50), + } + + require.NoError(t, req.Validate()) + }) + + t.Run("rejects request above target pair limit", func(t *testing.T) { + req := &BulkApplyResourcesRequest{ + EnvironmentKeys: []string{"dev", "prod"}, + NamespaceKeys: make([]string, 51), + } + + err := req.Validate() + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "bulk apply exceeds max target pairs")) + }) + + t.Run("uses singular environment_key when environment_keys is empty", func(t *testing.T) { + req := &BulkApplyResourcesRequest{ + EnvironmentKey: "default", + NamespaceKeys: make([]string, maxBulkApplyTargetPairs), + } + + require.NoError(t, req.Validate()) + }) +} diff --git a/sdk/go/AGENTS.md b/sdk/go/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/sdk/go/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/sdk/go/CLAUDE.md b/sdk/go/CLAUDE.md new file mode 100644 index 0000000000..473f175d64 --- /dev/null +++ b/sdk/go/CLAUDE.md @@ -0,0 +1,173 @@ +# AGENTS — Go SDK + +## Overview + +The Go SDK provides a typed client for the Flipt API with two transport implementations (gRPC and HTTP). There are two separate SDK modules: + +- **v1 SDK** (`sdk/go/`) — Wraps the v1 API (`rpc/flipt/`): flags, segments, evaluation, authentication +- **v2 SDK** (`sdk/go/v2/`) — Wraps the v2 API (`rpc/v2/environments/`): environments, namespaces, resources + +Each module is a separate Go module with its own `go.mod`. + +## Important: No Code Generation + +The `.sdk.gen.go` files in this directory were **originally generated** by `protoc-gen-go-flipt-sdk` but are **now maintained by hand**. The `// Code generated` header is historical — do not attempt to regenerate these files. When new RPCs are added to the proto definitions, the corresponding SDK methods must be added manually. + +## Architecture + +Each SDK module has three layers: + +``` +sdk/go/[v2/] +├── *.sdk.gen.go # SDK wrapper — authenticates and delegates to transport +├── sdk.gen.go # Transport interface + SDK constructor +├── grpc/ +│ └── grpc.sdk.gen.go # gRPC transport — wraps the generated gRPC client +└── http/ + ├── http.sdk.gen.go # HTTP transport — shared utilities (checkResponse, etc.) + └── *.sdk.gen.go # HTTP transport — per-service HTTP client methods +``` + +### Layer 1: SDK Wrapper (`environments.sdk.gen.go`, `flipt.sdk.gen.go`, etc.) + +Thin wrapper that handles authentication and delegates to the transport: + +```go +func (x *Environments) CopyResource(ctx context.Context, v *environments.CopyResourceRequest) (*environments.CopyResourceResponse, error) { + ctx, err := authenticate(ctx, x.authenticationProvider) + if err != nil { + return nil, err + } + return x.transport.CopyResource(ctx, v) +} +``` + +Every method follows this exact pattern: authenticate, then delegate. + +### Layer 2: gRPC Transport (`grpc/grpc.sdk.gen.go`) + +Minimal — just creates the gRPC client from a connection. No per-method code needed because the generated gRPC client already implements the full interface: + +```go +func (t Transport) EnvironmentsClient() environments.EnvironmentsServiceClient { + return environments.NewEnvironmentsServiceClient(t.cc) +} +``` + +**You do not need to modify gRPC transport files when adding new RPCs.** The generated gRPC client interface (`EnvironmentsServiceClient`) is updated automatically by `buf generate`. + +### Layer 3: HTTP Transport (`http/environments.sdk.gen.go`, etc.) + +Each RPC needs an explicit HTTP method mapping. Follow the existing pattern: + +- **GET** endpoints: Build URL with path parameters, no request body +- **POST/PUT** endpoints: Marshal request to JSON, send as body +- **DELETE** endpoints: Set query parameters (e.g., `revision`), no body + +```go +func (x *EnvironmentsServiceClient) CopyResource(ctx context.Context, v *environments.CopyResourceRequest, _ ...grpc.CallOption) (*environments.CopyResourceResponse, error) { + var body io.Reader + var values url.Values + reqData, err := protojson.Marshal(v) + if err != nil { + return nil, err + } + body = bytes.NewReader(reqData) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, x.addr+fmt.Sprintf("/api/v2/environments/%v/namespaces/%v/resources/copy", v.EnvironmentKey, v.NamespaceKey), body) + if err != nil { + return nil, err + } + req.URL.RawQuery = values.Encode() + resp, err := x.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var output environments.CopyResourceResponse + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if err := checkResponse(resp, respData); err != nil { + return nil, err + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(respData, &output); err != nil { + return nil, err + } + return &output, nil +} +``` + +The URL path must match the `google.api.http` annotation in the proto file and the HTTP method must match (`post` = `http.MethodPost`, `get` = `http.MethodGet`, etc.). + +## Adding a New RPC + +When a new RPC is added to a proto service: + +### Step 1: Regenerate proto/gRPC code + +```bash +mise run proto +``` + +This updates the generated gRPC client interface — no SDK changes needed for gRPC transport. + +### Step 2: Add SDK wrapper method + +**v2 example:** Edit `sdk/go/v2/environments.sdk.gen.go` — add a method to the `Environments` struct following the authenticate-then-delegate pattern. + +**v1 example:** Edit the appropriate file in `sdk/go/` (e.g., `flipt.sdk.gen.go` for flag operations). + +### Step 3: Add HTTP transport method + +**v2:** Edit `sdk/go/v2/http/environments.sdk.gen.go` +**v1:** Edit the appropriate file in `sdk/go/http/` + +Build the URL from the proto's `google.api.http` annotation. Use the correct HTTP method. + +### Step 4: Verify + +```bash +# v2 +cd sdk/go/v2 && go build ./... + +# v1 +cd sdk/go && go build ./... +``` + +## File Reference + +### v1 SDK (`sdk/go/`) + +| File | Purpose | +|------|---------| +| `sdk.gen.go` | `Transport` interface, `SDK` struct, `New()` constructor | +| `flipt.sdk.gen.go` | `Flipt` struct — flag/segment CRUD methods | +| `evaluation.sdk.gen.go` | `Evaluation` struct — flag evaluation methods | +| `auth.sdk.gen.go` | `Auth` struct — authentication methods | +| `grpc/grpc.sdk.gen.go` | gRPC transport implementation | +| `http/http.sdk.gen.go` | HTTP transport utilities | +| `http/flipt.sdk.gen.go` | HTTP transport for flag/segment operations | +| `http/evaluation.sdk.gen.go` | HTTP transport for evaluation | +| `http/auth.sdk.gen.go` | HTTP transport for authentication | + +### v2 SDK (`sdk/go/v2/`) + +| File | Purpose | +|------|---------| +| `sdk.gen.go` | `Transport` interface, `SDK` struct, `New()` constructor | +| `environments.sdk.gen.go` | `Environments` struct — all environment/namespace/resource methods | +| `grpc/grpc.sdk.gen.go` | gRPC transport implementation | +| `http/http.sdk.gen.go` | HTTP transport utilities | +| `http/environments.sdk.gen.go` | HTTP transport for all environment operations | + +### Adding a new v2 service + +If a completely new v2 service is added (not just new RPCs on `EnvironmentsService`): + +1. Add a new `*Client()` method to the `Transport` interface in `sdk/go/v2/sdk.gen.go` +2. Create a new `.sdk.gen.go` with the wrapper struct and methods +3. Add a factory method on `SDK` (e.g., `func (s SDK) NewService() *NewService`) +4. Add the client creation to `grpc/grpc.sdk.gen.go` +5. Create `http/.sdk.gen.go` with HTTP transport methods +6. Add client creation to `http/http.sdk.gen.go` diff --git a/sdk/go/v2/environments.sdk.gen.go b/sdk/go/v2/environments.sdk.gen.go index c22e5dbedf..7c2ab191d5 100644 --- a/sdk/go/v2/environments.sdk.gen.go +++ b/sdk/go/v2/environments.sdk.gen.go @@ -99,3 +99,27 @@ func (x *Environments) DeleteResource(ctx context.Context, v *environments.Delet } return x.transport.DeleteResource(ctx, v) } + +func (x *Environments) CopyNamespace(ctx context.Context, v *environments.CopyNamespaceRequest) (*environments.CopyNamespaceResponse, error) { + ctx, err := authenticate(ctx, x.authenticationProvider) + if err != nil { + return nil, err + } + return x.transport.CopyNamespace(ctx, v) +} + +func (x *Environments) CompareEnvironments(ctx context.Context, v *environments.CompareEnvironmentsRequest) (*environments.CompareEnvironmentsResponse, error) { + ctx, err := authenticate(ctx, x.authenticationProvider) + if err != nil { + return nil, err + } + return x.transport.CompareEnvironments(ctx, v) +} + +func (x *Environments) BulkApplyResources(ctx context.Context, v *environments.BulkApplyResourcesRequest) (*environments.BulkApplyResourcesResponse, error) { + ctx, err := authenticate(ctx, x.authenticationProvider) + if err != nil { + return nil, err + } + return x.transport.BulkApplyResources(ctx, v) +} diff --git a/sdk/go/v2/http/environments.sdk.gen.go b/sdk/go/v2/http/environments.sdk.gen.go index 560c0e4e4f..265739c46c 100644 --- a/sdk/go/v2/http/environments.sdk.gen.go +++ b/sdk/go/v2/http/environments.sdk.gen.go @@ -342,6 +342,105 @@ func (x *EnvironmentsServiceClient) DeleteResource(ctx context.Context, v *envir return &output, nil } +func (x *EnvironmentsServiceClient) CopyNamespace(ctx context.Context, v *environments.CopyNamespaceRequest, _ ...grpc.CallOption) (*environments.CopyNamespaceResponse, error) { + var body io.Reader + var values url.Values + reqData, err := protojson.Marshal(v) + if err != nil { + return nil, err + } + body = bytes.NewReader(reqData) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, x.addr+fmt.Sprintf("/api/v2/environments/%v/namespaces/copy", v.EnvironmentKey), body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.URL.RawQuery = values.Encode() + resp, err := x.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var output environments.CopyNamespaceResponse + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if err := checkResponse(resp, respData); err != nil { + return nil, err + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(respData, &output); err != nil { + return nil, err + } + return &output, nil +} + +func (x *EnvironmentsServiceClient) CompareEnvironments(ctx context.Context, v *environments.CompareEnvironmentsRequest, _ ...grpc.CallOption) (*environments.CompareEnvironmentsResponse, error) { + var body io.Reader + var values url.Values + reqData, err := protojson.Marshal(v) + if err != nil { + return nil, err + } + body = bytes.NewReader(reqData) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, x.addr+fmt.Sprintf("/api/v2/environments/%v/compare", v.EnvironmentKey), body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.URL.RawQuery = values.Encode() + resp, err := x.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var output environments.CompareEnvironmentsResponse + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if err := checkResponse(resp, respData); err != nil { + return nil, err + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(respData, &output); err != nil { + return nil, err + } + return &output, nil +} + +func (x *EnvironmentsServiceClient) BulkApplyResources(ctx context.Context, v *environments.BulkApplyResourcesRequest, _ ...grpc.CallOption) (*environments.BulkApplyResourcesResponse, error) { + var body io.Reader + var values url.Values + reqData, err := protojson.Marshal(v) + if err != nil { + return nil, err + } + body = bytes.NewReader(reqData) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, x.addr+fmt.Sprintf("/api/v2/environments/%v/resources/bulk", v.EnvironmentKey), body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.URL.RawQuery = values.Encode() + resp, err := x.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var output environments.BulkApplyResourcesResponse + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if err := checkResponse(resp, respData); err != nil { + return nil, err + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(respData, &output); err != nil { + return nil, err + } + return &output, nil +} + func (t Transport) EnvironmentsClient() environments.EnvironmentsServiceClient { return &EnvironmentsServiceClient{client: t.client, addr: t.addr} } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 34e04fa25c..cefd00e820 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -21,6 +21,7 @@ import { Theme } from './types/Preferences'; const Flags = lazy(() => import('./app/flags/Flags')); const Segments = lazy(() => import('./app/segments/Segments')); const Console = lazy(() => import('./app/console/Console')); +const Compare = lazy(() => import('./app/compare/Compare')); const Login = lazy(() => import('./app/auth/Login')); const Settings = lazy(() => import('./app/Settings')); const Support = lazy(() => import('./app/Support')); @@ -77,6 +78,13 @@ const namespacedRoutes = [ path: 'segments/:segmentKey', element: }, + { + path: 'compare', + element: , + handle: { + namespaced: true + } + }, { path: 'playground', element: , diff --git a/ui/src/app/compare/Compare.tsx b/ui/src/app/compare/Compare.tsx new file mode 100644 index 0000000000..558055239e --- /dev/null +++ b/ui/src/app/compare/Compare.tsx @@ -0,0 +1,547 @@ +import { + CheckCircle2Icon, + CircleAlertIcon, + CopyIcon, + MinusCircleIcon +} from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { + selectAllEnvironments, + selectCurrentEnvironment, + useBulkApplyResourcesMutation, + useCompareEnvironmentsQuery +} from '~/app/environments/environmentsApi'; +import { selectInfo } from '~/app/meta/metaSlice'; +import { + selectCurrentNamespace, + useListNamespacesQuery +} from '~/app/namespaces/namespacesApi'; + +import { Badge } from '~/components/Badge'; +import { Button } from '~/components/Button'; +import { PageHeader } from '~/components/Page'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '~/components/Select'; + +import { Product } from '~/types/Meta'; +import { INamespace } from '~/types/Namespace'; + +import { useError } from '~/data/hooks/error'; +import { useNotification } from '~/data/hooks/notification'; +import { + type CompareStatus, + diffFlag, + diffSegment, + toResourceType, + toStatus +} from '~/utils/compare'; +import { getRevision } from '~/utils/helpers'; + +type CompareFilter = 'all' | 'different' | 'missing'; + +type CompareRow = { + id: string; + resourceType: 'flag' | 'segment'; + key: string; + status: CompareStatus; + differences: string[]; + sourcePayload: Record | undefined; +}; + +const TYPE_URL_BY_RESOURCE = { + flag: 'flipt.core.Flag', + segment: 'flipt.core.Segment' +} as const; + +function statusBadge(status: CompareStatus) { + if (status === 'same') { + return ( + + Same + + ); + } + + if (status === 'different') { + return ( + + Different + + ); + } + + if (status === 'source_only') { + return ( + + Missing In Target + + ); + } + + return ( + + Source Missing + + ); +} + +function defaultNamespaceKey( + namespaces: INamespace[] | undefined, + fallback: string +) { + if (!namespaces || namespaces.length === 0) { + return fallback; + } + + const matched = namespaces.find((ns) => ns.key === fallback); + return matched?.key || namespaces[0].key; +} + +export default function Compare() { + const info = useSelector(selectInfo); + const currentEnvironment = useSelector(selectCurrentEnvironment); + const currentNamespace = useSelector(selectCurrentNamespace); + const environments = useSelector(selectAllEnvironments); + + const [sourceEnvironmentKey, setSourceEnvironmentKey] = useState( + currentEnvironment.key + ); + const [targetEnvironmentKey, setTargetEnvironmentKey] = useState(() => { + const other = environments.find( + (env) => env.key !== currentEnvironment.key + ); + return other?.key || currentEnvironment.key; + }); + const [sourceNamespaceKey, setSourceNamespaceKey] = useState( + currentNamespace.key + ); + const [targetNamespaceKey, setTargetNamespaceKey] = useState( + currentNamespace.key + ); + const [filter, setFilter] = useState('all'); + const [selectedRows, setSelectedRows] = useState>({}); + const [conflictStrategy, setConflictStrategy] = useState('OVERWRITE'); + + const sourceNamespacesQuery = useListNamespacesQuery({ + environmentKey: sourceEnvironmentKey + }); + const targetNamespacesQuery = useListNamespacesQuery({ + environmentKey: targetEnvironmentKey + }); + + const sourceNamespaces = useMemo( + () => sourceNamespacesQuery.data?.items || [], + [sourceNamespacesQuery.data?.items] + ); + const targetNamespaces = useMemo( + () => targetNamespacesQuery.data?.items || [], + [targetNamespacesQuery.data?.items] + ); + + useEffect(() => { + setSourceNamespaceKey((current) => + defaultNamespaceKey(sourceNamespaces, current) + ); + }, [sourceEnvironmentKey, sourceNamespaces]); + + useEffect(() => { + setTargetNamespaceKey((current) => + defaultNamespaceKey(targetNamespaces, current) + ); + }, [targetEnvironmentKey, targetNamespaces]); + + const compareQuery = useCompareEnvironmentsQuery( + { + environmentKey: sourceEnvironmentKey, + namespaceKey: sourceNamespaceKey, + targetEnvironmentKey, + targetNamespaceKey + }, + { skip: !sourceNamespaceKey || !targetNamespaceKey } + ); + + const { setNotification } = useNotification(); + const { setError } = useError(); + const [bulkApplyResources, { isLoading: isCopying }] = + useBulkApplyResourcesMutation(); + + const rows = useMemo(() => { + const items = compareQuery.data?.results || []; + const mapped: CompareRow[] = []; + + for (const result of items) { + const resourceType = toResourceType(result.typeUrl); + if (!resourceType) { + continue; + } + + const status = toStatus(result.status); + const sourcePayload = result.source?.payload as + | Record + | undefined; + const targetPayload = result.target?.payload as + | Record + | undefined; + + let differences: string[] = []; + if (status === 'different' && sourcePayload && targetPayload) { + differences = + resourceType === 'flag' + ? diffFlag(sourcePayload, targetPayload) + : diffSegment(sourcePayload, targetPayload); + } else if (status === 'source_only') { + differences = ['missing in target']; + } else if (status === 'target_only') { + differences = ['missing in source']; + } + + mapped.push({ + id: `${resourceType}:${result.key}`, + resourceType, + key: result.key, + status, + differences, + sourcePayload + }); + } + + return mapped; + }, [compareQuery.data?.results]); + + const visibleRows = useMemo(() => { + if (filter === 'different') { + return rows.filter((row) => row.status === 'different'); + } + if (filter === 'missing') { + return rows.filter((row) => row.status === 'source_only'); + } + return rows; + }, [filter, rows]); + + useEffect(() => { + setSelectedRows((current) => { + const next: Record = {}; + for (const row of rows) { + if (current[row.id] && row.status !== 'target_only') { + next[row.id] = true; + } + } + return next; + }); + }, [rows]); + + const selected = useMemo( + () => rows.filter((row) => selectedRows[row.id] && row.sourcePayload), + [rows, selectedRows] + ); + + const statusCounts = useMemo(() => { + return rows.reduce( + (acc, row) => { + acc[row.status] += 1; + return acc; + }, + { + same: 0, + different: 0, + source_only: 0, + target_only: 0 + } as Record + ); + }, [rows]); + + const isLoading = + sourceNamespacesQuery.isLoading || + targetNamespacesQuery.isLoading || + compareQuery.isLoading; + + const hasErrors = + sourceNamespacesQuery.error || + targetNamespacesQuery.error || + compareQuery.error; + + useEffect(() => { + if (hasErrors) { + setError(hasErrors); + } + }, [hasErrors, setError]); + + const toggleSelect = (id: string, checked: boolean) => { + setSelectedRows((current) => ({ + ...current, + [id]: checked + })); + }; + + const copySelected = async () => { + if (selected.length === 0) { + return; + } + + if (info.product !== Product.PRO) { + setNotification('Selective copy from compare view requires Flipt Pro.'); + return; + } + + let revision = + targetNamespacesQuery.data?.revision || + sourceNamespacesQuery.data?.revision || + getRevision(); + + try { + for (const row of selected) { + if (!row.sourcePayload) { + continue; + } + + const response = await bulkApplyResources({ + environmentKey: targetEnvironmentKey, + namespaceKeys: [targetNamespaceKey], + operation: 'BULK_OPERATION_CREATE', + typeUrl: TYPE_URL_BY_RESOURCE[row.resourceType], + key: row.key, + payload: { + '@type': TYPE_URL_BY_RESOURCE[row.resourceType], + ...row.sourcePayload + }, + onConflict: conflictStrategy, + revision + }).unwrap(); + + revision = response.revision; + } + + setNotification( + `Copied ${selected.length} resource${selected.length === 1 ? '' : 's'} to ${targetEnvironmentKey}/${targetNamespaceKey}.` + ); + setSelectedRows({}); + compareQuery.refetch(); + } catch (error) { + setError(error); + } + }; + + return ( +
+ + +
+
+
Source
+
+ + +
+
+ +
+
Target
+
+ + +
+
+
+ +
+ + + +
+ +
+
+
Same
+
{statusCounts.same}
+
+
+
Different
+
+ {statusCounts.different} +
+
+
+
Missing In Target
+
+ {statusCounts.source_only} +
+
+
+
Missing In Source
+
+ {statusCounts.target_only} +
+
+
+ +
+
+
+ {isLoading + ? 'Loading comparison...' + : `${visibleRows.length} resources`} +
+
+ + +
+
+
+ {!isLoading && visibleRows.length === 0 && ( +
+ No resources match the selected filter. +
+ )} + {visibleRows.map((row) => { + const canSelect = row.status !== 'target_only'; + return ( +
+ + toggleSelect(row.id, event.target.checked) + } + /> +
+
+ {row.resourceType} + {row.key} + {statusBadge(row.status)} +
+ {row.differences.length > 0 && ( +
+ Differences: {row.differences.join(', ')} +
+ )} +
+
+ {row.status === 'same' && ( + + )} + {row.status === 'different' && ( + + )} + {(row.status === 'source_only' || + row.status === 'target_only') && ( + + )} +
+
+ ); + })} +
+
+ + {info.product !== Product.PRO && ( +
+ Read-only compare is available in OSS. Copying selected resources from + compare view requires Flipt Pro. +
+ )} +
+ ); +} diff --git a/ui/src/app/environments/environmentsApi.ts b/ui/src/app/environments/environmentsApi.ts index cfb0a1e510..1736eee332 100644 --- a/ui/src/app/environments/environmentsApi.ts +++ b/ui/src/app/environments/environmentsApi.ts @@ -14,6 +14,16 @@ import { baseQuery } from '~/utils/redux-rtk'; export const environmentKey = 'environment'; +const toConflictStrategy = (value?: string) => { + if (!value) { + return undefined; + } + + return value.startsWith('CONFLICT_STRATEGY_') + ? value + : `CONFLICT_STRATEGY_${value}`; +}; + interface IEnvironmentsState { environments: { [key: string]: IEnvironment }; status: LoadingStatus; @@ -178,6 +188,129 @@ export const environmentsApi = createApi({ body: { title, body, draft } }), invalidatesTags: () => [{ type: 'BranchEnvironment' }] + }), + bulkApplyResources: builder.mutation< + { + results: Array<{ + namespaceKey: string; + status: string; + error?: string; + }>; + revision: string; + }, + { + environmentKey: string; + namespaceKeys: string[]; + operation: string; + typeUrl: string; + key: string; + payload?: unknown; + onConflict?: string; + revision: string; + } + >({ + query: ({ + environmentKey, + namespaceKeys, + operation, + typeUrl, + key, + payload, + onConflict, + revision + }) => ({ + url: `/${environmentKey}/resources/bulk`, + method: 'POST', + body: { + namespace_keys: namespaceKeys, + operation, + type_url: typeUrl, + key, + payload, + on_conflict: toConflictStrategy(onConflict), + revision + } + }), + invalidatesTags: () => [{ type: 'Environment' }] + }), + copyNamespace: builder.mutation< + { + results: Array<{ + typeUrl: string; + key: string; + status: string; + error?: string; + }>; + revision: string; + }, + { + environmentKey: string; + namespaceKey: string; + sourceEnvironmentKey: string; + sourceNamespaceKey: string; + onConflict?: string; + revision: string; + } + >({ + query: ({ + environmentKey, + namespaceKey, + sourceEnvironmentKey, + sourceNamespaceKey, + onConflict, + revision + }) => ({ + url: `/${environmentKey}/namespaces/copy`, + method: 'POST', + body: { + source_environment_key: sourceEnvironmentKey, + source_namespace_key: sourceNamespaceKey, + namespace_key: namespaceKey, + on_conflict: toConflictStrategy(onConflict), + revision + } + }), + invalidatesTags: () => [{ type: 'Environment' }] + }), + compareEnvironments: builder.query< + { + results: Array<{ + typeUrl: string; + key: string; + status: string; + source?: { + namespaceKey: string; + key: string; + payload?: Record; + }; + target?: { + namespaceKey: string; + key: string; + payload?: Record; + }; + }>; + }, + { + environmentKey: string; + namespaceKey: string; + targetEnvironmentKey: string; + targetNamespaceKey: string; + } + >({ + query: ({ + environmentKey, + namespaceKey, + targetEnvironmentKey, + targetNamespaceKey + }) => ({ + url: `/${environmentKey}/compare`, + method: 'POST', + body: { + namespace_key: namespaceKey, + target_environment_key: targetEnvironmentKey, + target_namespace_key: targetNamespaceKey + } + }) }) }) }); @@ -188,7 +321,10 @@ export const { useCreateBranchEnvironmentMutation, useDeleteBranchEnvironmentMutation, useListBranchEnvironmentChangesQuery, - useProposeEnvironmentMutation + useProposeEnvironmentMutation, + useBulkApplyResourcesMutation, + useCopyNamespaceMutation, + useCompareEnvironmentsQuery } = environmentsApi; export const environmentsReducer = environmentsSlice.reducer; diff --git a/ui/src/app/flags/Flag.tsx b/ui/src/app/flags/Flag.tsx index afe7591e54..9a941cb1e4 100644 --- a/ui/src/app/flags/Flag.tsx +++ b/ui/src/app/flags/Flag.tsx @@ -11,7 +11,10 @@ import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router'; import { useParams } from 'react-router'; -import { selectCurrentEnvironment } from '~/app/environments/environmentsApi'; +import { + selectCurrentEnvironment, + useBulkApplyResourcesMutation +} from '~/app/environments/environmentsApi'; import { selectInfo } from '~/app/meta/metaSlice'; import { selectCurrentNamespace, @@ -32,11 +35,7 @@ import { useError } from '~/data/hooks/error'; import { useSuccess } from '~/data/hooks/success'; import { getRevision } from '~/utils/helpers'; -import { - useCopyFlagMutation, - useDeleteFlagMutation, - useGetFlagQuery -} from './flagsApi'; +import { useDeleteFlagMutation, useGetFlagQuery } from './flagsApi'; export default function Flag() { let { flagKey } = useParams(); @@ -73,7 +72,7 @@ export default function Flag() { ); const [deleteFlag] = useDeleteFlagMutation(); - const [copyFlag] = useCopyFlagMutation(); + const [bulkApplyResources] = useBulkApplyResourcesMutation(); useEffect(() => { if (isError) { @@ -128,11 +127,23 @@ export default function Flag() { } panelType="Flag" setOpen={setShowCopyFlagModal} - handleCopy={(namespaceKey: string) => - copyFlag({ - environmentKey: environment.key, - from: { namespaceKey: namespace.key, flagKey: flag.key }, - to: { namespaceKey: namespaceKey, flagKey: flag.key } + handleCopy={( + namespaceKey: string, + targetEnvironmentKey?: string, + onConflict?: string + ) => + bulkApplyResources({ + environmentKey: targetEnvironmentKey || environment.key, + namespaceKeys: [namespaceKey], + operation: 'BULK_OPERATION_CREATE', + typeUrl: 'flipt.core.Flag', + key: flag.key, + payload: { + '@type': 'flipt.core.Flag', + ...flag + }, + onConflict, + revision }).unwrap() } onSuccess={() => { diff --git a/ui/src/app/flags/flagsApi.ts b/ui/src/app/flags/flagsApi.ts index 5af6157124..ed6bb56f77 100644 --- a/ui/src/app/flags/flagsApi.ts +++ b/ui/src/app/flags/flagsApi.ts @@ -206,54 +206,6 @@ export const flagsApi = createApi({ id: environmentKey + '/' + namespaceKey + '/' + flagKey } ] - }), - // copy the flag from one namespace to another one - copyFlag: builder.mutation< - void, - { - environmentKey: string; - from: { namespaceKey: string; flagKey: string }; - to: { namespaceKey: string; flagKey: string }; - } - >({ - queryFn: async ( - { environmentKey, from, to }, - _api, - _extraOptions, - baseQuery - ) => { - let resp = await baseQuery({ - url: `/${environmentKey}/namespaces/${from.namespaceKey}/resources/flipt.core.Flag/${from.flagKey}`, - method: 'GET' - }); - if (resp.error) { - return { error: resp.error }; - } - - const res = resp.data as { - resource: { payload: IFlag; key: string }; - revision: string; - }; - - let data = { - key: res.resource.key, - payload: res.resource.payload, - revision: res.revision - }; - - resp = await baseQuery({ - url: `/${environmentKey}/namespaces/${to.namespaceKey}/resources`, - method: 'POST', - body: data - }); - if (resp.error) { - return { error: resp.error }; - } - return { data: undefined }; - }, - invalidatesTags: (_result, _error, { environmentKey, to }) => [ - { type: 'Flag', id: environmentKey + '/' + to.namespaceKey } - ] }) }) }); @@ -263,6 +215,5 @@ export const { useGetFlagQuery, useCreateFlagMutation, useDeleteFlagMutation, - useUpdateFlagMutation, - useCopyFlagMutation + useUpdateFlagMutation } = flagsApi; diff --git a/ui/src/app/segments/Segment.tsx b/ui/src/app/segments/Segment.tsx index 0663e0389c..741b21affb 100644 --- a/ui/src/app/segments/Segment.tsx +++ b/ui/src/app/segments/Segment.tsx @@ -3,13 +3,15 @@ import { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { useNavigate, useParams } from 'react-router'; -import { selectCurrentEnvironment } from '~/app/environments/environmentsApi'; +import { + selectCurrentEnvironment, + useBulkApplyResourcesMutation +} from '~/app/environments/environmentsApi'; import { selectCurrentNamespace, selectNamespaces } from '~/app/namespaces/namespacesApi'; import { - useCopySegmentMutation, useDeleteSegmentMutation, useGetSegmentQuery } from '~/app/segments/segmentsApi'; @@ -61,7 +63,7 @@ export default function Segment() { ); const [deleteSegment] = useDeleteSegmentMutation(); - const [copySegment] = useCopySegmentMutation(); + const [bulkApplyResources] = useBulkApplyResourcesMutation(); useEffect(() => { if (isError) { @@ -118,11 +120,23 @@ export default function Segment() { } panelType="Segment" - handleCopy={(namespaceKey: string) => - copySegment({ - environmentKey: environment.key, - from: { namespaceKey: namespace.key, segmentKey: segment.key }, - to: { namespaceKey: namespaceKey, segmentKey: segment.key } + handleCopy={( + namespaceKey: string, + targetEnvironmentKey?: string, + onConflict?: string + ) => + bulkApplyResources({ + environmentKey: targetEnvironmentKey || environment.key, + namespaceKeys: [namespaceKey], + operation: 'BULK_OPERATION_CREATE', + typeUrl: 'flipt.core.Segment', + key: segment.key, + payload: { + '@type': 'flipt.core.Segment', + ...segment + }, + onConflict, + revision }).unwrap() } onSuccess={() => { diff --git a/ui/src/app/segments/segmentsApi.ts b/ui/src/app/segments/segmentsApi.ts index f96c1397ac..314711a0a8 100644 --- a/ui/src/app/segments/segmentsApi.ts +++ b/ui/src/app/segments/segmentsApi.ts @@ -197,54 +197,6 @@ export const segmentsApi = createApi({ id: environmentKey + '/' + namespaceKey + '/' + segmentKey } ] - }), - // copy the segment from one namespace to another one - copySegment: builder.mutation< - void, - { - environmentKey: string; - from: { namespaceKey: string; segmentKey: string }; - to: { namespaceKey: string; segmentKey: string }; - } - >({ - queryFn: async ( - { environmentKey, from, to }, - _api, - _extraOptions, - baseQuery - ) => { - let resp = await baseQuery({ - url: `/${environmentKey}/namespaces/${from.namespaceKey}/resources/flipt.core.Segment/${from.segmentKey}`, - method: 'GET' - }); - if (resp.error) { - return { error: resp.error }; - } - - const res = resp.data as { - resource: { payload: ISegment; key: string }; - revision: string; - }; - - let data = { - key: res.resource.key, - payload: res.resource.payload, - revision: res.revision - }; - - resp = await baseQuery({ - url: `/${environmentKey}/namespaces/${to.namespaceKey}/resources`, - method: 'POST', - body: data - }); - if (resp.error) { - return { error: resp.error }; - } - return { data: undefined }; - }, - invalidatesTags: (_result, _error, { environmentKey, to }) => [ - { type: 'Segment', id: environmentKey + '/' + to.namespaceKey } - ] }) }) }); @@ -254,6 +206,5 @@ export const { useGetSegmentQuery, useCreateSegmentMutation, useDeleteSegmentMutation, - useUpdateSegmentMutation, - useCopySegmentMutation + useUpdateSegmentMutation } = segmentsApi; diff --git a/ui/src/components/Chips.tsx b/ui/src/components/Chips.tsx deleted file mode 100644 index 8bfc936fb3..0000000000 --- a/ui/src/components/Chips.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useState } from 'react'; - -export default function ChipList({ - values, - maxItemCount = 5, - showAll = false -}: { - values: (string | number)[]; - maxItemCount?: number; - showAll?: boolean; -}) { - const [showAllValues, setShowAllValues] = useState(showAll); - - const visibleValues = showAllValues ? values : values.slice(0, maxItemCount); - return ( -
- {visibleValues.map((value, i) => ( -
- {value} -
- ))} - {!showAll && values.length > maxItemCount && ( - - )} -
- ); -} diff --git a/ui/src/components/NavMain.tsx b/ui/src/components/NavMain.tsx index cb9a00e133..60788a4101 100644 --- a/ui/src/components/NavMain.tsx +++ b/ui/src/components/NavMain.tsx @@ -2,6 +2,7 @@ import { BugPlayIcon, ChartNoAxesCombinedIcon, FlagIcon, + GitCompareIcon, UsersIcon } from 'lucide-react'; @@ -24,6 +25,11 @@ export function NavMain({ ns }: { ns: string }) { url: `#/namespaces/${ns}/segments`, icon: UsersIcon }, + { + title: 'Compare', + url: `#/namespaces/${ns}/compare`, + icon: GitCompareIcon + }, { title: 'Playground', url: `#/namespaces/${ns}/playground`, diff --git a/ui/src/components/Table.tsx b/ui/src/components/Table.tsx deleted file mode 100644 index f6d2b09398..0000000000 --- a/ui/src/components/Table.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { RowData } from '@tanstack/react-table'; - -declare module '@tanstack/table-core' { - interface ColumnMeta { - className: string; - } -} diff --git a/ui/src/components/TagBadge.tsx b/ui/src/components/TagBadge.tsx deleted file mode 100644 index a29ad3eaaf..0000000000 --- a/ui/src/components/TagBadge.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { cls } from '~/utils/helpers'; - -export type Tag = { - label: string; - variant?: 'default' | 'outline' | 'purple' | 'blue' | 'green' | 'pink'; -}; - -export function TagBadge({ tag }: { tag: Tag }) { - const variants = { - default: 'bg-secondary/50 text-secondary-foreground', - outline: 'border border-border bg-transparent', - purple: - 'bg-violet-500/10 text-violet-500 dark:bg-violet-600/30 dark:text-violet-300', - blue: 'bg-blue-500/10 text-blue-500 dark:bg-blue-600/30 dark:text-blue-300', - green: - 'bg-green-500/10 text-green-500 dark:bg-green-600/30 dark:text-green-300', - pink: 'bg-pink-500/10 text-pink-500 dark:bg-pink-600/30 dark:text-pink-300' - }; - - return ( - - {tag.label} - - ); -} diff --git a/ui/src/components/flags/FlagEnvironments.tsx b/ui/src/components/flags/FlagEnvironments.tsx new file mode 100644 index 0000000000..93f14c92a6 --- /dev/null +++ b/ui/src/components/flags/FlagEnvironments.tsx @@ -0,0 +1,211 @@ +import { + AlertCircleIcon, + CheckCircle2Icon, + CircleAlertIcon +} from 'lucide-react'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { + selectCurrentEnvironment, + selectEnvironments +} from '~/app/environments/environmentsApi'; +import { useGetFlagQuery } from '~/app/flags/flagsApi'; +import { selectCurrentNamespace } from '~/app/namespaces/namespacesApi'; + +import { Badge } from '~/components/Badge'; + +import { IFlag } from '~/types/Flag'; +import { IEnvironment } from '~/types/Environment'; + +import { driftFields } from '~/utils/compare'; + +function EnvironmentRow({ + environment, + flagKey, + namespaceKey, + currentFlag +}: { + environment: IEnvironment; + flagKey: string; + namespaceKey: string; + currentFlag: IFlag; +}) { + const { data: flag, isLoading, error } = useGetFlagQuery( + { + environmentKey: environment.key, + namespaceKey, + flagKey + }, + { refetchOnFocus: false } + ); + + const drift = useMemo(() => { + if (!flag) return []; + return driftFields( + currentFlag as unknown as Record, + flag as unknown as Record + ); + }, [flag, currentFlag]); + + if (isLoading) { + return ( +
+
{environment.name || environment.key}
+
+
+
+
+ ); + } + + if (error) { + const isNotFound = + error && typeof error === 'object' && 'status' in error && error.status === 404; + + return ( +
+
{environment.name || environment.key}
+ {isNotFound ? ( + + Not Present + + ) : ( +
+ + Failed to load +
+ )} +
+ ); + } + + if (!flag) return null; + + const hasDrift = drift.length > 0; + const enabledMatch = flag.enabled === currentFlag.enabled; + + return ( +
+
+ {environment.name || environment.key} +
+ + + {flag.enabled ? 'Enabled' : 'Disabled'} + + + {!enabledMatch && ( + + (differs from current) + + )} + + + {flag.variants?.length || 0} variants + + + + {flag.rules?.length || 0} rules + + + + {flag.rollouts?.length || 0} rollouts + + +
+ {hasDrift ? ( + <> + + + Drift: {drift.join(', ')} + + + ) : ( + <> + + In sync + + )} +
+
+ ); +} + +export default function FlagEnvironments({ flag }: { flag: IFlag }) { + const currentEnvironment = useSelector(selectCurrentEnvironment); + const namespace = useSelector(selectCurrentNamespace); + const environments = useSelector(selectEnvironments); + + const otherEnvironments = useMemo( + () => environments.filter((env) => env.key !== currentEnvironment.key), + [environments, currentEnvironment.key] + ); + + if (otherEnvironments.length === 0) { + return ( +
+ Only one environment configured. Add more environments to compare flag state across them. +
+ ); + } + + return ( +
+
+
+ Flag state across environments +
+
+ Comparing against current environment: {currentEnvironment.name || currentEnvironment.key} +
+
+ + {/* Current environment row */} +
+
+ {currentEnvironment.name || currentEnvironment.key} +
+ + + {flag.enabled ? 'Enabled' : 'Disabled'} + + + + {flag.variants?.length || 0} variants + + + + {flag.rules?.length || 0} rules + + + + {flag.rollouts?.length || 0} rollouts + + +
+ Current +
+
+ + {/* Other environment rows */} +
+ {otherEnvironments.map((env) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/flags/FlagForm.tsx b/ui/src/components/flags/FlagForm.tsx index 4c4d6a5622..22168abac3 100644 --- a/ui/src/components/flags/FlagForm.tsx +++ b/ui/src/components/flags/FlagForm.tsx @@ -18,6 +18,7 @@ import Loading from '~/components/Loading'; import { UnsavedChangesModalWrapper } from '~/components/UnsavedChangesModal'; import Input from '~/components/forms/Input'; import Toggle from '~/components/forms/Toggle'; +import FlagEnvironments from '~/components/flags/FlagEnvironments'; import Variants from '~/components/variants/Variants'; import { IDistribution } from '~/types/Distribution'; @@ -69,10 +70,14 @@ export const validRollout = (rollouts: IDistribution[]): boolean => { const variantFlagTabs = [ { name: 'Variants', id: 'variants' }, - { name: 'Rules', id: 'rules' } + { name: 'Rules', id: 'rules' }, + { name: 'Environments', id: 'environments' } ]; -const booleanFlagTabs = [{ name: 'Rollouts', id: 'rollouts' }]; +const booleanFlagTabs = [ + { name: 'Rollouts', id: 'rollouts' }, + { name: 'Environments', id: 'environments' } +]; function FlagTypeSelector({ selectedType, @@ -478,6 +483,9 @@ export default function FlagForm(props: { flag?: IFlag }) { {selectedTab == 'rules' && ( )} + {selectedTab == 'environments' && ( + + )}
)} diff --git a/ui/src/components/flags/FlagTable.tsx b/ui/src/components/flags/FlagTable.tsx index 20ab61c7e4..749af89b62 100644 --- a/ui/src/components/flags/FlagTable.tsx +++ b/ui/src/components/flags/FlagTable.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router'; +import { useBulkApplyResourcesMutation } from '~/app/environments/environmentsApi'; import { useGetBatchFlagEvaluationCountQuery } from '~/app/flags/analyticsApi'; import { selectSorting, @@ -36,6 +37,7 @@ import { DataTablePagination } from '~/components/TablePagination'; import { TableSkeleton } from '~/components/TableSkeleton'; import { DataTableViewOptions } from '~/components/TableViewOptions'; import Well from '~/components/Well'; +import CopyToNamespacePanel from '~/components/panels/CopyToNamespacePanel'; import { IBatchFlagEvaluationCount } from '~/types/Analytics'; import { IEnvironment } from '~/types/Environment'; @@ -43,6 +45,8 @@ import { FlagType, IFlag, flagTypeToLabel } from '~/types/Flag'; import { INamespace } from '~/types/Namespace'; import { useError } from '~/data/hooks/error'; +import { useNotification } from '~/data/hooks/notification'; +import { getRevision } from '~/utils/helpers'; import { cls } from '~/utils/helpers'; function VariantFlagBadge({ enabled }: { enabled: boolean }) { @@ -103,11 +107,15 @@ function CombinedFlagBadge({ item }: { item: IFlag }) { function FlagListItem({ item, path, - evaluationValues = [] + evaluationValues = [], + isSelected, + onToggleSelect }: { item: IFlag; path: string; evaluationValues?: number[]; + isSelected: boolean; + onToggleSelect: (key: string, checked: boolean) => void; }) { const navigate = useNavigate(); @@ -119,6 +127,14 @@ function FlagListItem({ onClick={() => navigate(path)} >
+ e.stopPropagation()} + onChange={(e) => onToggleSelect(item.key, e.target.checked)} + /> {/* Flag Info and Tags Column */}
@@ -253,6 +269,8 @@ export default function FlagTable(props: FlagTableProps) { }); const [filter, setFilter] = useState(''); + const [showBulkCopyModal, setShowBulkCopyModal] = useState(false); + const [selectedFlagKeys, setSelectedFlagKeys] = useState([]); const sorting = useSelector(selectSorting); @@ -265,13 +283,21 @@ export default function FlagTable(props: FlagTableProps) { const flags = useMemo(() => data?.flags || [], [data]); const flagKeys = useMemo(() => flags.map((f) => f.key), [flags]); const hasFlags = flags.length > 0; + const revision = getRevision(); const { setError } = useError(); + const { setNotification } = useNotification(); + const [bulkApplyResources] = useBulkApplyResourcesMutation(); useEffect(() => { if (error) { setError(error); } }, [error, setError]); + useEffect(() => { + setSelectedFlagKeys((current) => + current.filter((key) => flags.some((flag) => flag.key === key)) + ); + }, [flags]); const table = useReactTable({ data: flags, @@ -312,13 +338,86 @@ export default function FlagTable(props: FlagTableProps) { return ; } + const selectedFlags = flags.filter((flag) => + selectedFlagKeys.includes(flag.key) + ); + return (
+ Copy {selectedFlags.length} selected flags to the namespace: + } + handleCopy={async ( + namespaceKey: string, + targetEnvironmentKey?: string, + onConflict?: string + ) => { + const environmentKey = targetEnvironmentKey || environment.key; + const results = []; + let currentRevision = revision; + + for (const flag of selectedFlags) { + const response = await bulkApplyResources({ + environmentKey, + namespaceKeys: [namespaceKey], + operation: 'BULK_OPERATION_CREATE', + typeUrl: 'flipt.core.Flag', + key: flag.key, + payload: { + '@type': 'flipt.core.Flag', + ...flag + }, + onConflict, + revision: currentRevision + }).unwrap(); + + currentRevision = response.revision; + + const namespaceResult = response.results?.[0]; + results.push({ + key: flag.key, + status: namespaceResult?.status || 'UNKNOWN', + error: namespaceResult?.error + }); + } + + return { results }; + }} + onSuccess={(response) => { + const results = response?.results || []; + const failed = results.filter((result: any) => result.error); + const successful = results.length - failed.length; + if (failed.length === 0) { + setNotification(`Copied ${successful} flags successfully`); + } else { + const failedKeys = failed + .map((result: any) => result.key) + .join(', '); + setNotification(`Copied ${successful}/${results.length} flags`, { + description: `Failed: ${failedKeys}` + }); + } + setSelectedFlagKeys([]); + setShowBulkCopyModal(false); + }} + />
+ {selectedFlagKeys.length > 0 && ( + + )}
{hasFlags && }
@@ -349,6 +448,14 @@ export default function FlagTable(props: FlagTableProps) { item={item} path={`${path}/${item.key}`} evaluationValues={values} + isSelected={selectedFlagKeys.includes(item.key)} + onToggleSelect={(key, checked) => { + setSelectedFlagKeys((current) => + checked + ? Array.from(new Set([...current, key])) + : current.filter((selectedKey) => selectedKey !== key) + ); + }} /> ); })} diff --git a/ui/src/components/flags/FlagTypeBadge.tsx b/ui/src/components/flags/FlagTypeBadge.tsx deleted file mode 100644 index a554127164..0000000000 --- a/ui/src/components/flags/FlagTypeBadge.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { VariableIcon } from 'lucide-react'; -import { ToggleLeftIcon } from 'lucide-react'; - -import { Badge } from '~/components/Badge'; - -import { FlagType } from '~/types/Flag'; -import { flagTypeToLabel } from '~/types/Flag'; - -import { cls } from '~/utils/helpers'; - -type FlagTypeBadgeProps = { - type: FlagType; - className?: string; -}; - -export function FlagTypeBadge({ type, className }: FlagTypeBadgeProps) { - return ( - - {type === FlagType.BOOLEAN ? ( - - ) : ( - - )} - {flagTypeToLabel(type)} - - ); -} diff --git a/ui/src/components/forms/TextArea.tsx b/ui/src/components/forms/TextArea.tsx deleted file mode 100644 index 10074b2b53..0000000000 --- a/ui/src/components/forms/TextArea.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useField } from 'formik'; - -import { cls } from '~/utils/helpers'; - -type TextAreaProps = { - id: string; - name: string; - autocomplete?: boolean; - rows?: number; - className?: string; - placeholder?: string; -}; - -export default function TextArea(props: TextAreaProps) { - const { id, rows = 3, className, placeholder, autocomplete = false } = props; - const [field, meta] = useField(props); - const hasError = meta.touched && meta.error; - - return ( - <> -