diff --git a/app/cli/cmd/workflow_contract_delete.go b/app/cli/cmd/workflow_contract_delete.go index 5176add1d..f1eaf810c 100644 --- a/app/cli/cmd/workflow_contract_delete.go +++ b/app/cli/cmd/workflow_contract_delete.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,17 +16,35 @@ package cmd import ( + "errors" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" ) func newWorkflowContractDeleteCmd() *cobra.Command { var name string + var purgeUnused bool cmd := &cobra.Command{ Use: "delete", Short: "Delete a contract", - RunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(_ *cobra.Command, _ []string) error { + if !purgeUnused && name == "" { + return errors.New("either --name or --purge-unused must be provided") + } + return nil + }, + RunE: func(_ *cobra.Command, _ []string) error { + if purgeUnused { + n, err := action.NewWorkflowContractPurgeUnused(ActionOpts).Run() + if err != nil { + return err + } + logger.Info().Int32("count", n).Msg("unused contracts purged") + return nil + } + if err := action.NewWorkflowContractDelete(ActionOpts).Run(name); err != nil { return err } @@ -36,8 +54,8 @@ func newWorkflowContractDeleteCmd() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "contract name") - err := cmd.MarkFlagRequired("name") - cobra.CheckErr(err) + cmd.Flags().BoolVar(&purgeUnused, "purge-unused", false, "delete all contracts with no associated workflows") + cmd.MarkFlagsMutuallyExclusive("name", "purge-unused") return cmd } diff --git a/app/cli/pkg/action/workflow_contract_purge_unused.go b/app/cli/pkg/action/workflow_contract_purge_unused.go new file mode 100644 index 000000000..37f5be8b1 --- /dev/null +++ b/app/cli/pkg/action/workflow_contract_purge_unused.go @@ -0,0 +1,41 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" +) + +type WorkflowContractPurgeUnused struct { + cfg *ActionsOpts +} + +func NewWorkflowContractPurgeUnused(cfg *ActionsOpts) *WorkflowContractPurgeUnused { + return &WorkflowContractPurgeUnused{cfg} +} + +func (action *WorkflowContractPurgeUnused) Run() (int32, error) { + client := pb.NewWorkflowContractServiceClient(action.cfg.CPConnection) + resp, err := client.PurgeUnused(context.Background(), &pb.WorkflowContractServicePurgeUnusedRequest{}) + if err != nil { + action.cfg.Logger.Debug().Err(err).Msg("making the API request") + return 0, err + } + + return resp.GetTotalPurged(), nil +} diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go index 67177e354..2ea81ddd2 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -512,6 +512,86 @@ func (*WorkflowContractServiceDeleteResponse) Descriptor() ([]byte, []int) { return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{9} } +type WorkflowContractServicePurgeUnusedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkflowContractServicePurgeUnusedRequest) Reset() { + *x = WorkflowContractServicePurgeUnusedRequest{} + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkflowContractServicePurgeUnusedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkflowContractServicePurgeUnusedRequest) ProtoMessage() {} + +func (x *WorkflowContractServicePurgeUnusedRequest) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[10] + 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 WorkflowContractServicePurgeUnusedRequest.ProtoReflect.Descriptor instead. +func (*WorkflowContractServicePurgeUnusedRequest) Descriptor() ([]byte, []int) { + return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{10} +} + +type WorkflowContractServicePurgeUnusedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TotalPurged int32 `protobuf:"varint,1,opt,name=total_purged,json=totalPurged,proto3" json:"total_purged,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkflowContractServicePurgeUnusedResponse) Reset() { + *x = WorkflowContractServicePurgeUnusedResponse{} + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkflowContractServicePurgeUnusedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkflowContractServicePurgeUnusedResponse) ProtoMessage() {} + +func (x *WorkflowContractServicePurgeUnusedResponse) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[11] + 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 WorkflowContractServicePurgeUnusedResponse.ProtoReflect.Descriptor instead. +func (*WorkflowContractServicePurgeUnusedResponse) Descriptor() ([]byte, []int) { + return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{11} +} + +func (x *WorkflowContractServicePurgeUnusedResponse) GetTotalPurged() int32 { + if x != nil { + return x.TotalPurged + } + return 0 +} + type WorkflowContractServiceApplyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Raw representation of the contract in json, yaml or cue @@ -522,7 +602,7 @@ type WorkflowContractServiceApplyRequest struct { func (x *WorkflowContractServiceApplyRequest) Reset() { *x = WorkflowContractServiceApplyRequest{} - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[10] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -534,7 +614,7 @@ func (x *WorkflowContractServiceApplyRequest) String() string { func (*WorkflowContractServiceApplyRequest) ProtoMessage() {} func (x *WorkflowContractServiceApplyRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[10] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -547,7 +627,7 @@ func (x *WorkflowContractServiceApplyRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use WorkflowContractServiceApplyRequest.ProtoReflect.Descriptor instead. func (*WorkflowContractServiceApplyRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{10} + return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{12} } func (x *WorkflowContractServiceApplyRequest) GetRawSchema() []byte { @@ -572,7 +652,7 @@ type WorkflowContractServiceApplyResponse struct { func (x *WorkflowContractServiceApplyResponse) Reset() { *x = WorkflowContractServiceApplyResponse{} - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[11] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -584,7 +664,7 @@ func (x *WorkflowContractServiceApplyResponse) String() string { func (*WorkflowContractServiceApplyResponse) ProtoMessage() {} func (x *WorkflowContractServiceApplyResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[11] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -597,7 +677,7 @@ func (x *WorkflowContractServiceApplyResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use WorkflowContractServiceApplyResponse.ProtoReflect.Descriptor instead. func (*WorkflowContractServiceApplyResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{11} + return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{13} } func (x *WorkflowContractServiceApplyResponse) GetResult() *WorkflowContractItem { @@ -632,7 +712,7 @@ type WorkflowContractServiceUpdateResponse_Result struct { func (x *WorkflowContractServiceUpdateResponse_Result) Reset() { *x = WorkflowContractServiceUpdateResponse_Result{} - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[12] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -644,7 +724,7 @@ func (x *WorkflowContractServiceUpdateResponse_Result) String() string { func (*WorkflowContractServiceUpdateResponse_Result) ProtoMessage() {} func (x *WorkflowContractServiceUpdateResponse_Result) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[12] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -684,7 +764,7 @@ type WorkflowContractServiceDescribeResponse_Result struct { func (x *WorkflowContractServiceDescribeResponse_Result) Reset() { *x = WorkflowContractServiceDescribeResponse_Result{} - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[13] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -696,7 +776,7 @@ func (x *WorkflowContractServiceDescribeResponse_Result) String() string { func (*WorkflowContractServiceDescribeResponse_Result) ProtoMessage() {} func (x *WorkflowContractServiceDescribeResponse_Result) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[13] + mi := &file_controlplane_v1_workflow_contract_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -766,21 +846,25 @@ const file_controlplane_v1_workflow_contract_proto_rawDesc = "" + "$WorkflowContractServiceDeleteRequest\x12\x97\x01\n" + "\x04name\x18\x01 \x01(\tB\x82\x01\xbaH\x7f\xba\x01|\n" + "\rname.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a/this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x04name\"'\n" + - "%WorkflowContractServiceDeleteResponse\"D\n" + + "%WorkflowContractServiceDeleteResponse\"+\n" + + ")WorkflowContractServicePurgeUnusedRequest\"O\n" + + "*WorkflowContractServicePurgeUnusedResponse\x12!\n" + + "\ftotal_purged\x18\x01 \x01(\x05R\vtotalPurged\"D\n" + "#WorkflowContractServiceApplyRequest\x12\x1d\n" + "\n" + "raw_schema\x18\x01 \x01(\fR\trawSchema\"\xa1\x01\n" + "$WorkflowContractServiceApplyResponse\x12=\n" + "\x06result\x18\x01 \x01(\v2%.controlplane.v1.WorkflowContractItemR\x06result\x12 \n" + "\tunchanged\x18\x02 \x01(\bB\x02\x18\x01R\tunchanged\x12\x18\n" + - "\achanged\x18\x03 \x01(\bR\achanged2\xec\x05\n" + + "\achanged\x18\x03 \x01(\bR\achanged2\xf5\x06\n" + "\x17WorkflowContractService\x12q\n" + "\x04List\x123.controlplane.v1.WorkflowContractServiceListRequest\x1a4.controlplane.v1.WorkflowContractServiceListResponse\x12w\n" + "\x06Create\x125.controlplane.v1.WorkflowContractServiceCreateRequest\x1a6.controlplane.v1.WorkflowContractServiceCreateResponse\x12w\n" + "\x06Update\x125.controlplane.v1.WorkflowContractServiceUpdateRequest\x1a6.controlplane.v1.WorkflowContractServiceUpdateResponse\x12}\n" + "\bDescribe\x127.controlplane.v1.WorkflowContractServiceDescribeRequest\x1a8.controlplane.v1.WorkflowContractServiceDescribeResponse\x12w\n" + "\x06Delete\x125.controlplane.v1.WorkflowContractServiceDeleteRequest\x1a6.controlplane.v1.WorkflowContractServiceDeleteResponse\x12t\n" + - "\x05Apply\x124.controlplane.v1.WorkflowContractServiceApplyRequest\x1a5.controlplane.v1.WorkflowContractServiceApplyResponseBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" + "\x05Apply\x124.controlplane.v1.WorkflowContractServiceApplyRequest\x1a5.controlplane.v1.WorkflowContractServiceApplyResponse\x12\x86\x01\n" + + "\vPurgeUnused\x12:.controlplane.v1.WorkflowContractServicePurgeUnusedRequest\x1a;.controlplane.v1.WorkflowContractServicePurgeUnusedResponseBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" var ( file_controlplane_v1_workflow_contract_proto_rawDescOnce sync.Once @@ -794,7 +878,7 @@ func file_controlplane_v1_workflow_contract_proto_rawDescGZIP() []byte { return file_controlplane_v1_workflow_contract_proto_rawDescData } -var file_controlplane_v1_workflow_contract_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_controlplane_v1_workflow_contract_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_controlplane_v1_workflow_contract_proto_goTypes = []any{ (*WorkflowContractServiceListRequest)(nil), // 0: controlplane.v1.WorkflowContractServiceListRequest (*WorkflowContractServiceListResponse)(nil), // 1: controlplane.v1.WorkflowContractServiceListResponse @@ -806,39 +890,43 @@ var file_controlplane_v1_workflow_contract_proto_goTypes = []any{ (*WorkflowContractServiceDescribeResponse)(nil), // 7: controlplane.v1.WorkflowContractServiceDescribeResponse (*WorkflowContractServiceDeleteRequest)(nil), // 8: controlplane.v1.WorkflowContractServiceDeleteRequest (*WorkflowContractServiceDeleteResponse)(nil), // 9: controlplane.v1.WorkflowContractServiceDeleteResponse - (*WorkflowContractServiceApplyRequest)(nil), // 10: controlplane.v1.WorkflowContractServiceApplyRequest - (*WorkflowContractServiceApplyResponse)(nil), // 11: controlplane.v1.WorkflowContractServiceApplyResponse - (*WorkflowContractServiceUpdateResponse_Result)(nil), // 12: controlplane.v1.WorkflowContractServiceUpdateResponse.Result - (*WorkflowContractServiceDescribeResponse_Result)(nil), // 13: controlplane.v1.WorkflowContractServiceDescribeResponse.Result - (*WorkflowContractItem)(nil), // 14: controlplane.v1.WorkflowContractItem - (*IdentityReference)(nil), // 15: controlplane.v1.IdentityReference - (*WorkflowContractVersionItem)(nil), // 16: controlplane.v1.WorkflowContractVersionItem + (*WorkflowContractServicePurgeUnusedRequest)(nil), // 10: controlplane.v1.WorkflowContractServicePurgeUnusedRequest + (*WorkflowContractServicePurgeUnusedResponse)(nil), // 11: controlplane.v1.WorkflowContractServicePurgeUnusedResponse + (*WorkflowContractServiceApplyRequest)(nil), // 12: controlplane.v1.WorkflowContractServiceApplyRequest + (*WorkflowContractServiceApplyResponse)(nil), // 13: controlplane.v1.WorkflowContractServiceApplyResponse + (*WorkflowContractServiceUpdateResponse_Result)(nil), // 14: controlplane.v1.WorkflowContractServiceUpdateResponse.Result + (*WorkflowContractServiceDescribeResponse_Result)(nil), // 15: controlplane.v1.WorkflowContractServiceDescribeResponse.Result + (*WorkflowContractItem)(nil), // 16: controlplane.v1.WorkflowContractItem + (*IdentityReference)(nil), // 17: controlplane.v1.IdentityReference + (*WorkflowContractVersionItem)(nil), // 18: controlplane.v1.WorkflowContractVersionItem } var file_controlplane_v1_workflow_contract_proto_depIdxs = []int32{ - 14, // 0: controlplane.v1.WorkflowContractServiceListResponse.result:type_name -> controlplane.v1.WorkflowContractItem - 15, // 1: controlplane.v1.WorkflowContractServiceCreateRequest.project_reference:type_name -> controlplane.v1.IdentityReference - 14, // 2: controlplane.v1.WorkflowContractServiceCreateResponse.result:type_name -> controlplane.v1.WorkflowContractItem - 12, // 3: controlplane.v1.WorkflowContractServiceUpdateResponse.result:type_name -> controlplane.v1.WorkflowContractServiceUpdateResponse.Result - 13, // 4: controlplane.v1.WorkflowContractServiceDescribeResponse.result:type_name -> controlplane.v1.WorkflowContractServiceDescribeResponse.Result - 14, // 5: controlplane.v1.WorkflowContractServiceApplyResponse.result:type_name -> controlplane.v1.WorkflowContractItem - 14, // 6: controlplane.v1.WorkflowContractServiceUpdateResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractItem - 16, // 7: controlplane.v1.WorkflowContractServiceUpdateResponse.Result.revision:type_name -> controlplane.v1.WorkflowContractVersionItem - 14, // 8: controlplane.v1.WorkflowContractServiceDescribeResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractItem - 16, // 9: controlplane.v1.WorkflowContractServiceDescribeResponse.Result.revision:type_name -> controlplane.v1.WorkflowContractVersionItem + 16, // 0: controlplane.v1.WorkflowContractServiceListResponse.result:type_name -> controlplane.v1.WorkflowContractItem + 17, // 1: controlplane.v1.WorkflowContractServiceCreateRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 16, // 2: controlplane.v1.WorkflowContractServiceCreateResponse.result:type_name -> controlplane.v1.WorkflowContractItem + 14, // 3: controlplane.v1.WorkflowContractServiceUpdateResponse.result:type_name -> controlplane.v1.WorkflowContractServiceUpdateResponse.Result + 15, // 4: controlplane.v1.WorkflowContractServiceDescribeResponse.result:type_name -> controlplane.v1.WorkflowContractServiceDescribeResponse.Result + 16, // 5: controlplane.v1.WorkflowContractServiceApplyResponse.result:type_name -> controlplane.v1.WorkflowContractItem + 16, // 6: controlplane.v1.WorkflowContractServiceUpdateResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractItem + 18, // 7: controlplane.v1.WorkflowContractServiceUpdateResponse.Result.revision:type_name -> controlplane.v1.WorkflowContractVersionItem + 16, // 8: controlplane.v1.WorkflowContractServiceDescribeResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractItem + 18, // 9: controlplane.v1.WorkflowContractServiceDescribeResponse.Result.revision:type_name -> controlplane.v1.WorkflowContractVersionItem 0, // 10: controlplane.v1.WorkflowContractService.List:input_type -> controlplane.v1.WorkflowContractServiceListRequest 2, // 11: controlplane.v1.WorkflowContractService.Create:input_type -> controlplane.v1.WorkflowContractServiceCreateRequest 4, // 12: controlplane.v1.WorkflowContractService.Update:input_type -> controlplane.v1.WorkflowContractServiceUpdateRequest 6, // 13: controlplane.v1.WorkflowContractService.Describe:input_type -> controlplane.v1.WorkflowContractServiceDescribeRequest 8, // 14: controlplane.v1.WorkflowContractService.Delete:input_type -> controlplane.v1.WorkflowContractServiceDeleteRequest - 10, // 15: controlplane.v1.WorkflowContractService.Apply:input_type -> controlplane.v1.WorkflowContractServiceApplyRequest - 1, // 16: controlplane.v1.WorkflowContractService.List:output_type -> controlplane.v1.WorkflowContractServiceListResponse - 3, // 17: controlplane.v1.WorkflowContractService.Create:output_type -> controlplane.v1.WorkflowContractServiceCreateResponse - 5, // 18: controlplane.v1.WorkflowContractService.Update:output_type -> controlplane.v1.WorkflowContractServiceUpdateResponse - 7, // 19: controlplane.v1.WorkflowContractService.Describe:output_type -> controlplane.v1.WorkflowContractServiceDescribeResponse - 9, // 20: controlplane.v1.WorkflowContractService.Delete:output_type -> controlplane.v1.WorkflowContractServiceDeleteResponse - 11, // 21: controlplane.v1.WorkflowContractService.Apply:output_type -> controlplane.v1.WorkflowContractServiceApplyResponse - 16, // [16:22] is the sub-list for method output_type - 10, // [10:16] is the sub-list for method input_type + 12, // 15: controlplane.v1.WorkflowContractService.Apply:input_type -> controlplane.v1.WorkflowContractServiceApplyRequest + 10, // 16: controlplane.v1.WorkflowContractService.PurgeUnused:input_type -> controlplane.v1.WorkflowContractServicePurgeUnusedRequest + 1, // 17: controlplane.v1.WorkflowContractService.List:output_type -> controlplane.v1.WorkflowContractServiceListResponse + 3, // 18: controlplane.v1.WorkflowContractService.Create:output_type -> controlplane.v1.WorkflowContractServiceCreateResponse + 5, // 19: controlplane.v1.WorkflowContractService.Update:output_type -> controlplane.v1.WorkflowContractServiceUpdateResponse + 7, // 20: controlplane.v1.WorkflowContractService.Describe:output_type -> controlplane.v1.WorkflowContractServiceDescribeResponse + 9, // 21: controlplane.v1.WorkflowContractService.Delete:output_type -> controlplane.v1.WorkflowContractServiceDeleteResponse + 13, // 22: controlplane.v1.WorkflowContractService.Apply:output_type -> controlplane.v1.WorkflowContractServiceApplyResponse + 11, // 23: controlplane.v1.WorkflowContractService.PurgeUnused:output_type -> controlplane.v1.WorkflowContractServicePurgeUnusedResponse + 17, // [17:24] is the sub-list for method output_type + 10, // [10:17] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name 10, // [10:10] is the sub-list for extension extendee 0, // [0:10] is the sub-list for field type_name @@ -859,7 +947,7 @@ func file_controlplane_v1_workflow_contract_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_workflow_contract_proto_rawDesc), len(file_controlplane_v1_workflow_contract_proto_rawDesc)), NumEnums: 0, - NumMessages: 14, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.proto b/app/controlplane/api/controlplane/v1/workflow_contract.proto index e5a9f42b2..c6c4d472b 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract.proto +++ b/app/controlplane/api/controlplane/v1/workflow_contract.proto @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ service WorkflowContractService { rpc Describe(WorkflowContractServiceDescribeRequest) returns (WorkflowContractServiceDescribeResponse); rpc Delete(WorkflowContractServiceDeleteRequest) returns (WorkflowContractServiceDeleteResponse); rpc Apply(WorkflowContractServiceApplyRequest) returns (WorkflowContractServiceApplyResponse); + rpc PurgeUnused(WorkflowContractServicePurgeUnusedRequest) returns (WorkflowContractServicePurgeUnusedResponse); } message WorkflowContractServiceListRequest {} @@ -116,6 +117,12 @@ message WorkflowContractServiceDeleteRequest { message WorkflowContractServiceDeleteResponse {} +message WorkflowContractServicePurgeUnusedRequest {} + +message WorkflowContractServicePurgeUnusedResponse { + int32 total_purged = 1; +} + message WorkflowContractServiceApplyRequest { // Raw representation of the contract in json, yaml or cue bytes raw_schema = 1; diff --git a/app/controlplane/api/controlplane/v1/workflow_contract_grpc.pb.go b/app/controlplane/api/controlplane/v1/workflow_contract_grpc.pb.go index bb98fd92b..626e62ffd 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_contract_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,12 +34,13 @@ import ( const _ = grpc.SupportPackageIsVersion7 const ( - WorkflowContractService_List_FullMethodName = "/controlplane.v1.WorkflowContractService/List" - WorkflowContractService_Create_FullMethodName = "/controlplane.v1.WorkflowContractService/Create" - WorkflowContractService_Update_FullMethodName = "/controlplane.v1.WorkflowContractService/Update" - WorkflowContractService_Describe_FullMethodName = "/controlplane.v1.WorkflowContractService/Describe" - WorkflowContractService_Delete_FullMethodName = "/controlplane.v1.WorkflowContractService/Delete" - WorkflowContractService_Apply_FullMethodName = "/controlplane.v1.WorkflowContractService/Apply" + WorkflowContractService_List_FullMethodName = "/controlplane.v1.WorkflowContractService/List" + WorkflowContractService_Create_FullMethodName = "/controlplane.v1.WorkflowContractService/Create" + WorkflowContractService_Update_FullMethodName = "/controlplane.v1.WorkflowContractService/Update" + WorkflowContractService_Describe_FullMethodName = "/controlplane.v1.WorkflowContractService/Describe" + WorkflowContractService_Delete_FullMethodName = "/controlplane.v1.WorkflowContractService/Delete" + WorkflowContractService_Apply_FullMethodName = "/controlplane.v1.WorkflowContractService/Apply" + WorkflowContractService_PurgeUnused_FullMethodName = "/controlplane.v1.WorkflowContractService/PurgeUnused" ) // WorkflowContractServiceClient is the client API for WorkflowContractService service. @@ -52,6 +53,7 @@ type WorkflowContractServiceClient interface { Describe(ctx context.Context, in *WorkflowContractServiceDescribeRequest, opts ...grpc.CallOption) (*WorkflowContractServiceDescribeResponse, error) Delete(ctx context.Context, in *WorkflowContractServiceDeleteRequest, opts ...grpc.CallOption) (*WorkflowContractServiceDeleteResponse, error) Apply(ctx context.Context, in *WorkflowContractServiceApplyRequest, opts ...grpc.CallOption) (*WorkflowContractServiceApplyResponse, error) + PurgeUnused(ctx context.Context, in *WorkflowContractServicePurgeUnusedRequest, opts ...grpc.CallOption) (*WorkflowContractServicePurgeUnusedResponse, error) } type workflowContractServiceClient struct { @@ -116,6 +118,15 @@ func (c *workflowContractServiceClient) Apply(ctx context.Context, in *WorkflowC return out, nil } +func (c *workflowContractServiceClient) PurgeUnused(ctx context.Context, in *WorkflowContractServicePurgeUnusedRequest, opts ...grpc.CallOption) (*WorkflowContractServicePurgeUnusedResponse, error) { + out := new(WorkflowContractServicePurgeUnusedResponse) + err := c.cc.Invoke(ctx, WorkflowContractService_PurgeUnused_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // WorkflowContractServiceServer is the server API for WorkflowContractService service. // All implementations must embed UnimplementedWorkflowContractServiceServer // for forward compatibility @@ -126,6 +137,7 @@ type WorkflowContractServiceServer interface { Describe(context.Context, *WorkflowContractServiceDescribeRequest) (*WorkflowContractServiceDescribeResponse, error) Delete(context.Context, *WorkflowContractServiceDeleteRequest) (*WorkflowContractServiceDeleteResponse, error) Apply(context.Context, *WorkflowContractServiceApplyRequest) (*WorkflowContractServiceApplyResponse, error) + PurgeUnused(context.Context, *WorkflowContractServicePurgeUnusedRequest) (*WorkflowContractServicePurgeUnusedResponse, error) mustEmbedUnimplementedWorkflowContractServiceServer() } @@ -151,6 +163,9 @@ func (UnimplementedWorkflowContractServiceServer) Delete(context.Context, *Workf func (UnimplementedWorkflowContractServiceServer) Apply(context.Context, *WorkflowContractServiceApplyRequest) (*WorkflowContractServiceApplyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Apply not implemented") } +func (UnimplementedWorkflowContractServiceServer) PurgeUnused(context.Context, *WorkflowContractServicePurgeUnusedRequest) (*WorkflowContractServicePurgeUnusedResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PurgeUnused not implemented") +} func (UnimplementedWorkflowContractServiceServer) mustEmbedUnimplementedWorkflowContractServiceServer() { } @@ -273,6 +288,24 @@ func _WorkflowContractService_Apply_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } +func _WorkflowContractService_PurgeUnused_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WorkflowContractServicePurgeUnusedRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WorkflowContractServiceServer).PurgeUnused(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WorkflowContractService_PurgeUnused_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WorkflowContractServiceServer).PurgeUnused(ctx, req.(*WorkflowContractServicePurgeUnusedRequest)) + } + return interceptor(ctx, in, info, handler) +} + // WorkflowContractService_ServiceDesc is the grpc.ServiceDesc for WorkflowContractService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -304,6 +337,10 @@ var WorkflowContractService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Apply", Handler: _WorkflowContractService_Apply_Handler, }, + { + MethodName: "PurgeUnused", + Handler: _WorkflowContractService_PurgeUnused_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "controlplane/v1/workflow_contract.proto", diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts index bef4b4026..cebfbf20b 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts @@ -66,6 +66,13 @@ export interface WorkflowContractServiceDeleteRequest { export interface WorkflowContractServiceDeleteResponse { } +export interface WorkflowContractServicePurgeUnusedRequest { +} + +export interface WorkflowContractServicePurgeUnusedResponse { + totalPurged: number; +} + export interface WorkflowContractServiceApplyRequest { /** Raw representation of the contract in json, yaml or cue */ rawSchema: Uint8Array; @@ -936,6 +943,114 @@ export const WorkflowContractServiceDeleteResponse = { }, }; +function createBaseWorkflowContractServicePurgeUnusedRequest(): WorkflowContractServicePurgeUnusedRequest { + return {}; +} + +export const WorkflowContractServicePurgeUnusedRequest = { + encode(_: WorkflowContractServicePurgeUnusedRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): WorkflowContractServicePurgeUnusedRequest { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWorkflowContractServicePurgeUnusedRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(_: any): WorkflowContractServicePurgeUnusedRequest { + return {}; + }, + + toJSON(_: WorkflowContractServicePurgeUnusedRequest): unknown { + const obj: any = {}; + return obj; + }, + + create, I>>( + base?: I, + ): WorkflowContractServicePurgeUnusedRequest { + return WorkflowContractServicePurgeUnusedRequest.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + _: I, + ): WorkflowContractServicePurgeUnusedRequest { + const message = createBaseWorkflowContractServicePurgeUnusedRequest(); + return message; + }, +}; + +function createBaseWorkflowContractServicePurgeUnusedResponse(): WorkflowContractServicePurgeUnusedResponse { + return { totalPurged: 0 }; +} + +export const WorkflowContractServicePurgeUnusedResponse = { + encode(message: WorkflowContractServicePurgeUnusedResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.totalPurged !== 0) { + writer.uint32(8).int32(message.totalPurged); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): WorkflowContractServicePurgeUnusedResponse { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseWorkflowContractServicePurgeUnusedResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.totalPurged = reader.int32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): WorkflowContractServicePurgeUnusedResponse { + return { totalPurged: isSet(object.totalPurged) ? Number(object.totalPurged) : 0 }; + }, + + toJSON(message: WorkflowContractServicePurgeUnusedResponse): unknown { + const obj: any = {}; + message.totalPurged !== undefined && (obj.totalPurged = Math.round(message.totalPurged)); + return obj; + }, + + create, I>>( + base?: I, + ): WorkflowContractServicePurgeUnusedResponse { + return WorkflowContractServicePurgeUnusedResponse.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): WorkflowContractServicePurgeUnusedResponse { + const message = createBaseWorkflowContractServicePurgeUnusedResponse(); + message.totalPurged = object.totalPurged ?? 0; + return message; + }, +}; + function createBaseWorkflowContractServiceApplyRequest(): WorkflowContractServiceApplyRequest { return { rawSchema: new Uint8Array(0) }; } @@ -1113,6 +1228,10 @@ export interface WorkflowContractService { request: DeepPartial, metadata?: grpc.Metadata, ): Promise; + PurgeUnused( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise; } export class WorkflowContractServiceClientImpl implements WorkflowContractService { @@ -1126,6 +1245,7 @@ export class WorkflowContractServiceClientImpl implements WorkflowContractServic this.Describe = this.Describe.bind(this); this.Delete = this.Delete.bind(this); this.Apply = this.Apply.bind(this); + this.PurgeUnused = this.PurgeUnused.bind(this); } List( @@ -1193,6 +1313,17 @@ export class WorkflowContractServiceClientImpl implements WorkflowContractServic metadata, ); } + + PurgeUnused( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise { + return this.rpc.unary( + WorkflowContractServicePurgeUnusedDesc, + WorkflowContractServicePurgeUnusedRequest.fromPartial(request), + metadata, + ); + } } export const WorkflowContractServiceDesc = { serviceName: "controlplane.v1.WorkflowContractService" }; @@ -1335,6 +1466,29 @@ export const WorkflowContractServiceApplyDesc: UnaryMethodDefinitionish = { } as any, }; +export const WorkflowContractServicePurgeUnusedDesc: UnaryMethodDefinitionish = { + methodName: "PurgeUnused", + service: WorkflowContractServiceDesc, + requestStream: false, + responseStream: false, + requestType: { + serializeBinary() { + return WorkflowContractServicePurgeUnusedRequest.encode(this).finish(); + }, + } as any, + responseType: { + deserializeBinary(data: Uint8Array) { + const value = WorkflowContractServicePurgeUnusedResponse.decode(data); + return { + ...value, + toObject() { + return value; + }, + }; + }, + } as any, +}; + interface UnaryMethodDefinitionishR extends grpc.UnaryMethodDefinition { requestStream: any; responseStream: any; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedRequest.jsonschema.json new file mode 100644 index 000000000..4bc35efb5 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedRequest.jsonschema.json @@ -0,0 +1,8 @@ +{ + "$id": "controlplane.v1.WorkflowContractServicePurgeUnusedRequest.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "title": "Workflow Contract Service Purge Unused Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedRequest.schema.json new file mode 100644 index 000000000..437c3f895 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedRequest.schema.json @@ -0,0 +1,8 @@ +{ + "$id": "controlplane.v1.WorkflowContractServicePurgeUnusedRequest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "title": "Workflow Contract Service Purge Unused Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedResponse.jsonschema.json new file mode 100644 index 000000000..71a1a2c40 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedResponse.jsonschema.json @@ -0,0 +1,21 @@ +{ + "$id": "controlplane.v1.WorkflowContractServicePurgeUnusedResponse.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "patternProperties": { + "^(total_purged)$": { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "properties": { + "totalPurged": { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "title": "Workflow Contract Service Purge Unused Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedResponse.schema.json new file mode 100644 index 000000000..3dd1614b9 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServicePurgeUnusedResponse.schema.json @@ -0,0 +1,21 @@ +{ + "$id": "controlplane.v1.WorkflowContractServicePurgeUnusedResponse.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "patternProperties": { + "^(totalPurged)$": { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "properties": { + "total_purged": { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "title": "Workflow Contract Service Purge Unused Response", + "type": "object" +} diff --git a/app/controlplane/internal/service/workflowcontract.go b/app/controlplane/internal/service/workflowcontract.go index 047079923..4e4b382fd 100644 --- a/app/controlplane/internal/service/workflowcontract.go +++ b/app/controlplane/internal/service/workflowcontract.go @@ -332,6 +332,24 @@ func (s *WorkflowContractService) Delete(ctx context.Context, req *pb.WorkflowCo return &pb.WorkflowContractServiceDeleteResponse{}, nil } +func (s *WorkflowContractService) PurgeUnused(ctx context.Context, _ *pb.WorkflowContractServicePurgeUnusedRequest) (*pb.WorkflowContractServicePurgeUnusedResponse, error) { + currentOrg, err := requireCurrentOrg(ctx) + if err != nil { + return nil, err + } + + if err := s.checkPolicy(ctx, authz.PolicyWorkflowContractDelete); err != nil { + return nil, err + } + + n, err := s.contractUseCase.PurgeUnused(ctx, currentOrg.ID, biz.WithProjectFilter(s.visibleProjects(ctx))) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + return &pb.WorkflowContractServicePurgeUnusedResponse{TotalPurged: int32(n)}, nil +} + func bizWorkFlowContractToPb(schema *biz.WorkflowContract) *pb.WorkflowContractItem { // nolint:prealloc var workflowNames []string diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contracts_purged.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contracts_purged.json new file mode 100644 index 000000000..3220d7615 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contracts_purged.json @@ -0,0 +1,15 @@ +{ + "ActionType": "WorkflowContractPurged", + "TargetType": "WorkflowContract", + "TargetID": null, + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "ActorName": "John Connor", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "John Connor has purged 5 unused workflow contracts", + "Info": { + "total_purged": 5 + }, + "Digest": "sha256:902d94a29ee927ec4ec2fd787a219cc97064623500b6b4528fb622cbebd0f027" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contracts_purged_by_api_token.json b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contracts_purged_by_api_token.json new file mode 100644 index 000000000..3b12692bb --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/workflowcontracts/workflow_contracts_purged_by_api_token.json @@ -0,0 +1,15 @@ +{ + "ActionType": "WorkflowContractPurged", + "TargetType": "WorkflowContract", + "TargetID": null, + "ActorType": "API_TOKEN", + "ActorID": "2089bb36-e27b-428b-8009-d015c8737c55", + "ActorEmail": "", + "ActorName": "test-token", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "test-token has purged 5 unused workflow contracts", + "Info": { + "total_purged": 5 + }, + "Digest": "sha256:7cb3c69164aa41acc08d6158310739c26cf6a0cc336dcf744d6f55591d27ea0c" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/workflowcontract.go b/app/controlplane/pkg/auditor/events/workflowcontract.go index 39b63dc76..7f1e2db6d 100644 --- a/app/controlplane/pkg/auditor/events/workflowcontract.go +++ b/app/controlplane/pkg/auditor/events/workflowcontract.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ var ( _ auditor.LogEntry = (*WorkflowContractDeleted)(nil) _ auditor.LogEntry = (*WorkflowContractAttached)(nil) _ auditor.LogEntry = (*WorkflowContractDetached)(nil) + _ auditor.LogEntry = (*WorkflowContractPurged)(nil) ) const ( @@ -40,6 +41,7 @@ const ( WorkflowContractDeletedActionType string = "WorkflowContractDeleted" WorkflowContractContractAttachedActionType string = "WorkflowContractContractAttached" WorkflowContractContractDetachedActionType string = "WorkflowContractContractDetached" + WorkflowContractPurgedActionType string = "WorkflowContractPurged" ) // WorkflowContractBase is the base struct for workflow contract events @@ -181,3 +183,31 @@ func (w *WorkflowContractDetached) ActionInfo() (json.RawMessage, error) { func (w *WorkflowContractDetached) Description() string { return fmt.Sprintf("%s has detached the workflow %s from the workflow contract %s", auditor.GetActorIdentifier(), w.WorkflowName, w.WorkflowContractName) } + +type WorkflowContractPurged struct { + TotalPurged int `json:"total_purged"` +} + +func (w *WorkflowContractPurged) ActionType() string { + return WorkflowContractPurgedActionType +} + +func (w *WorkflowContractPurged) ActionInfo() (json.RawMessage, error) { + return json.Marshal(w) +} + +func (w *WorkflowContractPurged) TargetType() auditor.TargetType { + return WorkflowContractType +} + +func (w *WorkflowContractPurged) TargetID() *uuid.UUID { + return nil +} + +func (w *WorkflowContractPurged) RequiresActor() bool { + return true +} + +func (w *WorkflowContractPurged) Description() string { + return fmt.Sprintf("%s has purged %d unused workflow contracts", auditor.GetActorIdentifier(), w.TotalPurged) +} diff --git a/app/controlplane/pkg/auditor/events/workflowcontract_test.go b/app/controlplane/pkg/auditor/events/workflowcontract_test.go index 92b31f2ea..4efedfdef 100644 --- a/app/controlplane/pkg/auditor/events/workflowcontract_test.go +++ b/app/controlplane/pkg/auditor/events/workflowcontract_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -188,6 +188,20 @@ func TestWorkflowContractEvents(t *testing.T) { actor: auditor.ActorTypeAPIToken, actorID: apiTokenUUID, }, + { + name: "Workflow contracts purged by user", + event: &events.WorkflowContractPurged{TotalPurged: 5}, + expected: "testdata/workflowcontracts/workflow_contracts_purged.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "Workflow contracts purged by API token", + event: &events.WorkflowContractPurged{TotalPurged: 5}, + expected: "testdata/workflowcontracts/workflow_contracts_purged_by_api_token.json", + actor: auditor.ActorTypeAPIToken, + actorID: apiTokenUUID, + }, } for _, tt := range tests { diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index 0615678a9..da12359fb 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -104,6 +104,7 @@ type WorkflowContractRepo interface { FindVersionByID(ctx context.Context, versionID uuid.UUID) (*WorkflowContractWithVersion, error) Update(ctx context.Context, orgID uuid.UUID, name string, opts *ContractUpdateOpts) (*WorkflowContractWithVersion, error) SoftDelete(ctx context.Context, contractID uuid.UUID) error + SoftDeleteUnused(ctx context.Context, orgID uuid.UUID, filter *WorkflowContractListFilters) (int, error) } type ContractQueryOpts struct { @@ -673,6 +674,31 @@ func (uc *WorkflowContractUseCase) Delete(ctx context.Context, orgID, contractID return nil } +func (uc *WorkflowContractUseCase) PurgeUnused(ctx context.Context, orgID string, opts ...WorkflowListOpt) (int, error) { + orgUUID, err := uuid.Parse(orgID) + if err != nil { + return 0, NewErrInvalidUUID(err) + } + + filters := &WorkflowContractListFilters{} + for _, opt := range opts { + opt(filters) + } + + n, err := uc.repo.SoftDeleteUnused(ctx, orgUUID, filters) + if err != nil { + return 0, fmt.Errorf("failed to purge unused contracts: %w", err) + } + + if n > 0 { + uc.auditorUC.Dispatch(ctx, &events.WorkflowContractPurged{ + TotalPurged: n, + }, &orgUUID) + } + + return n, nil +} + type RemotePolicy struct { ProviderRef *policies.PolicyReference Policy *schemav1.Policy diff --git a/app/controlplane/pkg/biz/workflowcontract_integration_test.go b/app/controlplane/pkg/biz/workflowcontract_integration_test.go index ffa1077bc..7a14ba3df 100644 --- a/app/controlplane/pkg/biz/workflowcontract_integration_test.go +++ b/app/controlplane/pkg/biz/workflowcontract_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -300,6 +300,82 @@ func (s *workflowContractIntegrationTestSuite) TestList() { }) } +func (s *workflowContractIntegrationTestSuite) TestPurgeUnused() { + ctx := context.Background() + + s.Run("purges all contracts when none have workflows", func() { + contracts, err := s.WorkflowContract.List(ctx, s.org.ID) + s.Require().NoError(err) + s.Require().Equal(3, len(contracts)) + + n, err := s.WorkflowContract.PurgeUnused(ctx, s.org.ID) + s.Require().NoError(err) + s.Equal(3, n) + + contracts, err = s.WorkflowContract.List(ctx, s.org.ID) + s.NoError(err) + s.Empty(contracts) + }) +} + +func (s *workflowContractIntegrationTestSuite) TestPurgeUnusedWithWorkflows() { + ctx := context.Background() + + _, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + Name: "wf-attached", + OrgID: s.org.ID, + Project: s.p1.Name, + ContractID: s.contractScopedToProject.ID.String(), + }) + s.Require().NoError(err) + + s.Run("purges only unused contracts", func() { + contracts, err := s.WorkflowContract.List(ctx, s.org.ID) + s.Require().NoError(err) + s.Require().Equal(3, len(contracts)) + + n, err := s.WorkflowContract.PurgeUnused(ctx, s.org.ID) + s.Require().NoError(err) + s.Equal(2, n) + + contracts, err = s.WorkflowContract.List(ctx, s.org.ID) + s.NoError(err) + s.Require().Equal(1, len(contracts)) + s.Equal(s.contractScopedToProject.ID, contracts[0].ID) + }) +} + +func (s *workflowContractIntegrationTestSuite) TestPurgeUnusedNothingToPurge() { + ctx := context.Background() + + contracts, err := s.WorkflowContract.List(ctx, s.org.ID) + s.Require().NoError(err) + + for _, c := range contracts { + projectName := s.p1.Name + if c.IsProjectScoped() { + projectName = c.ScopedEntity.Name + } + _, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + Name: fmt.Sprintf("wf-for-%s", c.Name), + OrgID: s.org.ID, + Project: projectName, + ContractID: c.ID.String(), + }) + s.Require().NoError(err) + } + + s.Run("returns zero when all contracts have workflows", func() { + n, err := s.WorkflowContract.PurgeUnused(ctx, s.org.ID) + s.Require().NoError(err) + s.Equal(0, n) + + contracts, err := s.WorkflowContract.List(ctx, s.org.ID) + s.NoError(err) + s.Equal(3, len(contracts)) + }) +} + func (s *workflowContractIntegrationTestSuite) TestCreateWithCustomContract() { ctx := context.Background() diff --git a/app/controlplane/pkg/data/workflowcontract.go b/app/controlplane/pkg/data/workflowcontract.go index 511350456..2faad5e5f 100644 --- a/app/controlplane/pkg/data/workflowcontract.go +++ b/app/controlplane/pkg/data/workflowcontract.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -51,23 +51,12 @@ func NewWorkflowContractRepo(data *Data, logger log.Logger) biz.WorkflowContract // If no project filters are provided, we return all the contracts scoped to the organization // otherwise we return the global contracts alongside the org scoped projects func (r *WorkflowContractRepo) List(ctx context.Context, orgID uuid.UUID, filter *biz.WorkflowContractListFilters) ([]*biz.WorkflowContract, error) { - wcontractQuery := orgScopedQuery(r.data.DB, orgID). - QueryWorkflowContracts(). - Where(workflowcontract.DeletedAtIsNil()) - - // If specific projects are provided - // we return the global contracts alongside the org scoped projects - if len(filter.FilterByProjects) > 0 { - wcontractQuery = wcontractQuery.Where( - workflowcontract.Or( - workflowcontract.And( - workflowcontract.ScopedResourceTypeIn(biz.ContractScopeProject), - workflowcontract.ScopedResourceIDIn(filter.FilterByProjects...), - ), - workflowcontract.ScopedResourceIDIsNil(), - ), - ) - } + wcontractQuery := applyProjectFilter( + orgScopedQuery(r.data.DB, orgID). + QueryWorkflowContracts(). + Where(workflowcontract.DeletedAtIsNil()), + filter, + ) contracts, err := wcontractQuery. WithWorkflows(func(q *ent.WorkflowQuery) { @@ -331,6 +320,59 @@ func (r *WorkflowContractRepo) SoftDelete(ctx context.Context, id uuid.UUID) err return r.data.DB.WorkflowContract.UpdateOneID(id).SetDeletedAt(time.Now()).SetUpdatedAt(time.Now()).Exec(ctx) } +func (r *WorkflowContractRepo) SoftDeleteUnused(ctx context.Context, orgID uuid.UUID, filter *biz.WorkflowContractListFilters) (int, error) { + var n int + if err := WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { + query := applyProjectFilter( + tx.Organization.Query(). + Where(organization.ID(orgID)). + QueryWorkflowContracts(). + Where( + workflowcontract.DeletedAtIsNil(), + workflowcontract.Not(workflowcontract.HasWorkflowsWith(workflow.DeletedAtIsNil())), + ), + filter, + ) + + ids, err := query.IDs(ctx) + if err != nil { + return err + } + + if len(ids) == 0 { + return nil + } + + now := time.Now() + n, err = tx.WorkflowContract.Update(). + Where(workflowcontract.IDIn(ids...)). + SetDeletedAt(now). + SetUpdatedAt(now). + Save(ctx) + + return err + }); err != nil { + return 0, err + } + + return n, nil +} + +func applyProjectFilter(query *ent.WorkflowContractQuery, filter *biz.WorkflowContractListFilters) *ent.WorkflowContractQuery { + if len(filter.FilterByProjects) > 0 { + query = query.Where( + workflowcontract.Or( + workflowcontract.And( + workflowcontract.ScopedResourceTypeIn(biz.ContractScopeProject), + workflowcontract.ScopedResourceIDIn(filter.FilterByProjects...), + ), + workflowcontract.ScopedResourceIDIsNil(), + ), + ) + } + return query +} + func entContractVersionToBizContractVersion(w *ent.WorkflowContractVersion) (*biz.WorkflowContractVersion, error) { contract := &biz.Contract{ Raw: w.RawBody,