diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go index 67177e354..6057f1000 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. @@ -37,6 +37,62 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ApplyStatus describes how the applied resource changed +type WorkflowContractServiceApplyResponse_ApplyStatus int32 + +const ( + WorkflowContractServiceApplyResponse_APPLY_STATUS_UNSPECIFIED WorkflowContractServiceApplyResponse_ApplyStatus = 0 + // The resource did not exist and was created + WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED WorkflowContractServiceApplyResponse_ApplyStatus = 1 + // The resource existed and its content changed + WorkflowContractServiceApplyResponse_APPLY_STATUS_UPDATED WorkflowContractServiceApplyResponse_ApplyStatus = 2 + // The resource existed and its content was identical + WorkflowContractServiceApplyResponse_APPLY_STATUS_UNCHANGED WorkflowContractServiceApplyResponse_ApplyStatus = 3 +) + +// Enum value maps for WorkflowContractServiceApplyResponse_ApplyStatus. +var ( + WorkflowContractServiceApplyResponse_ApplyStatus_name = map[int32]string{ + 0: "APPLY_STATUS_UNSPECIFIED", + 1: "APPLY_STATUS_CREATED", + 2: "APPLY_STATUS_UPDATED", + 3: "APPLY_STATUS_UNCHANGED", + } + WorkflowContractServiceApplyResponse_ApplyStatus_value = map[string]int32{ + "APPLY_STATUS_UNSPECIFIED": 0, + "APPLY_STATUS_CREATED": 1, + "APPLY_STATUS_UPDATED": 2, + "APPLY_STATUS_UNCHANGED": 3, + } +) + +func (x WorkflowContractServiceApplyResponse_ApplyStatus) Enum() *WorkflowContractServiceApplyResponse_ApplyStatus { + p := new(WorkflowContractServiceApplyResponse_ApplyStatus) + *p = x + return p +} + +func (x WorkflowContractServiceApplyResponse_ApplyStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WorkflowContractServiceApplyResponse_ApplyStatus) Descriptor() protoreflect.EnumDescriptor { + return file_controlplane_v1_workflow_contract_proto_enumTypes[0].Descriptor() +} + +func (WorkflowContractServiceApplyResponse_ApplyStatus) Type() protoreflect.EnumType { + return &file_controlplane_v1_workflow_contract_proto_enumTypes[0] +} + +func (x WorkflowContractServiceApplyResponse_ApplyStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WorkflowContractServiceApplyResponse_ApplyStatus.Descriptor instead. +func (WorkflowContractServiceApplyResponse_ApplyStatus) EnumDescriptor() ([]byte, []int) { + return file_controlplane_v1_workflow_contract_proto_rawDescGZIP(), []int{11, 0} +} + type WorkflowContractServiceListRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -515,7 +571,9 @@ func (*WorkflowContractServiceDeleteResponse) Descriptor() ([]byte, []int) { type WorkflowContractServiceApplyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Raw representation of the contract in json, yaml or cue - RawSchema []byte `protobuf:"bytes,1,opt,name=raw_schema,json=rawSchema,proto3" json:"raw_schema,omitempty"` + RawSchema []byte `protobuf:"bytes,1,opt,name=raw_schema,json=rawSchema,proto3" json:"raw_schema,omitempty"` + // When true, validate and compute the result without persisting any change + DryRun bool `protobuf:"varint,2,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -557,6 +615,13 @@ func (x *WorkflowContractServiceApplyRequest) GetRawSchema() []byte { return nil } +func (x *WorkflowContractServiceApplyRequest) GetDryRun() bool { + if x != nil { + return x.DryRun + } + return false +} + type WorkflowContractServiceApplyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *WorkflowContractItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -565,9 +630,16 @@ type WorkflowContractServiceApplyResponse struct { // Deprecated: Marked as deprecated in controlplane/v1/workflow_contract.proto. Unchanged bool `protobuf:"varint,2,opt,name=unchanged,proto3" json:"unchanged,omitempty"` // Whether the resource was changed - Changed bool `protobuf:"varint,3,opt,name=changed,proto3" json:"changed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Changed bool `protobuf:"varint,3,opt,name=changed,proto3" json:"changed,omitempty"` + // Detailed outcome of the apply operation + Status WorkflowContractServiceApplyResponse_ApplyStatus `protobuf:"varint,4,opt,name=status,proto3,enum=controlplane.v1.WorkflowContractServiceApplyResponse_ApplyStatus" json:"status,omitempty"` + // Current revision of the contract after the apply. + // For a newly created contract this is the first revision; for an + // unchanged contract it stays at the existing revision; for dry runs it + // reflects the existing revision (0 when the contract does not exist yet). + CurrentRevision int32 `protobuf:"varint,5,opt,name=current_revision,json=currentRevision,proto3" json:"current_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkflowContractServiceApplyResponse) Reset() { @@ -622,6 +694,20 @@ func (x *WorkflowContractServiceApplyResponse) GetChanged() bool { return false } +func (x *WorkflowContractServiceApplyResponse) GetStatus() WorkflowContractServiceApplyResponse_ApplyStatus { + if x != nil { + return x.Status + } + return WorkflowContractServiceApplyResponse_APPLY_STATUS_UNSPECIFIED +} + +func (x *WorkflowContractServiceApplyResponse) GetCurrentRevision() int32 { + if x != nil { + return x.CurrentRevision + } + return 0 +} + type WorkflowContractServiceUpdateResponse_Result struct { state protoimpl.MessageState `protogen:"open.v1"` Contract *WorkflowContractItem `protobuf:"bytes,1,opt,name=contract,proto3" json:"contract,omitempty"` @@ -766,14 +852,22 @@ 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" + "#WorkflowContractServiceApplyRequest\x12\x1d\n" + "\n" + - "raw_schema\x18\x01 \x01(\fR\trawSchema\"\xa1\x01\n" + + "raw_schema\x18\x01 \x01(\fR\trawSchema\x12\x17\n" + + "\adry_run\x18\x02 \x01(\bR\x06dryRun\"\xa4\x03\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\achanged\x12Y\n" + + "\x06status\x18\x04 \x01(\x0e2A.controlplane.v1.WorkflowContractServiceApplyResponse.ApplyStatusR\x06status\x12)\n" + + "\x10current_revision\x18\x05 \x01(\x05R\x0fcurrentRevision\"{\n" + + "\vApplyStatus\x12\x1c\n" + + "\x18APPLY_STATUS_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14APPLY_STATUS_CREATED\x10\x01\x12\x18\n" + + "\x14APPLY_STATUS_UPDATED\x10\x02\x12\x1a\n" + + "\x16APPLY_STATUS_UNCHANGED\x10\x032\xec\x05\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" + @@ -794,54 +888,57 @@ func file_controlplane_v1_workflow_contract_proto_rawDescGZIP() []byte { return file_controlplane_v1_workflow_contract_proto_rawDescData } +var file_controlplane_v1_workflow_contract_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_controlplane_v1_workflow_contract_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_controlplane_v1_workflow_contract_proto_goTypes = []any{ - (*WorkflowContractServiceListRequest)(nil), // 0: controlplane.v1.WorkflowContractServiceListRequest - (*WorkflowContractServiceListResponse)(nil), // 1: controlplane.v1.WorkflowContractServiceListResponse - (*WorkflowContractServiceCreateRequest)(nil), // 2: controlplane.v1.WorkflowContractServiceCreateRequest - (*WorkflowContractServiceCreateResponse)(nil), // 3: controlplane.v1.WorkflowContractServiceCreateResponse - (*WorkflowContractServiceUpdateRequest)(nil), // 4: controlplane.v1.WorkflowContractServiceUpdateRequest - (*WorkflowContractServiceUpdateResponse)(nil), // 5: controlplane.v1.WorkflowContractServiceUpdateResponse - (*WorkflowContractServiceDescribeRequest)(nil), // 6: controlplane.v1.WorkflowContractServiceDescribeRequest - (*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 + (WorkflowContractServiceApplyResponse_ApplyStatus)(0), // 0: controlplane.v1.WorkflowContractServiceApplyResponse.ApplyStatus + (*WorkflowContractServiceListRequest)(nil), // 1: controlplane.v1.WorkflowContractServiceListRequest + (*WorkflowContractServiceListResponse)(nil), // 2: controlplane.v1.WorkflowContractServiceListResponse + (*WorkflowContractServiceCreateRequest)(nil), // 3: controlplane.v1.WorkflowContractServiceCreateRequest + (*WorkflowContractServiceCreateResponse)(nil), // 4: controlplane.v1.WorkflowContractServiceCreateResponse + (*WorkflowContractServiceUpdateRequest)(nil), // 5: controlplane.v1.WorkflowContractServiceUpdateRequest + (*WorkflowContractServiceUpdateResponse)(nil), // 6: controlplane.v1.WorkflowContractServiceUpdateResponse + (*WorkflowContractServiceDescribeRequest)(nil), // 7: controlplane.v1.WorkflowContractServiceDescribeRequest + (*WorkflowContractServiceDescribeResponse)(nil), // 8: controlplane.v1.WorkflowContractServiceDescribeResponse + (*WorkflowContractServiceDeleteRequest)(nil), // 9: controlplane.v1.WorkflowContractServiceDeleteRequest + (*WorkflowContractServiceDeleteResponse)(nil), // 10: controlplane.v1.WorkflowContractServiceDeleteResponse + (*WorkflowContractServiceApplyRequest)(nil), // 11: controlplane.v1.WorkflowContractServiceApplyRequest + (*WorkflowContractServiceApplyResponse)(nil), // 12: controlplane.v1.WorkflowContractServiceApplyResponse + (*WorkflowContractServiceUpdateResponse_Result)(nil), // 13: controlplane.v1.WorkflowContractServiceUpdateResponse.Result + (*WorkflowContractServiceDescribeResponse_Result)(nil), // 14: controlplane.v1.WorkflowContractServiceDescribeResponse.Result + (*WorkflowContractItem)(nil), // 15: controlplane.v1.WorkflowContractItem + (*IdentityReference)(nil), // 16: controlplane.v1.IdentityReference + (*WorkflowContractVersionItem)(nil), // 17: 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 - 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 - 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 + 15, // 0: controlplane.v1.WorkflowContractServiceListResponse.result:type_name -> controlplane.v1.WorkflowContractItem + 16, // 1: controlplane.v1.WorkflowContractServiceCreateRequest.project_reference:type_name -> controlplane.v1.IdentityReference + 15, // 2: controlplane.v1.WorkflowContractServiceCreateResponse.result:type_name -> controlplane.v1.WorkflowContractItem + 13, // 3: controlplane.v1.WorkflowContractServiceUpdateResponse.result:type_name -> controlplane.v1.WorkflowContractServiceUpdateResponse.Result + 14, // 4: controlplane.v1.WorkflowContractServiceDescribeResponse.result:type_name -> controlplane.v1.WorkflowContractServiceDescribeResponse.Result + 15, // 5: controlplane.v1.WorkflowContractServiceApplyResponse.result:type_name -> controlplane.v1.WorkflowContractItem + 0, // 6: controlplane.v1.WorkflowContractServiceApplyResponse.status:type_name -> controlplane.v1.WorkflowContractServiceApplyResponse.ApplyStatus + 15, // 7: controlplane.v1.WorkflowContractServiceUpdateResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractItem + 17, // 8: controlplane.v1.WorkflowContractServiceUpdateResponse.Result.revision:type_name -> controlplane.v1.WorkflowContractVersionItem + 15, // 9: controlplane.v1.WorkflowContractServiceDescribeResponse.Result.contract:type_name -> controlplane.v1.WorkflowContractItem + 17, // 10: controlplane.v1.WorkflowContractServiceDescribeResponse.Result.revision:type_name -> controlplane.v1.WorkflowContractVersionItem + 1, // 11: controlplane.v1.WorkflowContractService.List:input_type -> controlplane.v1.WorkflowContractServiceListRequest + 3, // 12: controlplane.v1.WorkflowContractService.Create:input_type -> controlplane.v1.WorkflowContractServiceCreateRequest + 5, // 13: controlplane.v1.WorkflowContractService.Update:input_type -> controlplane.v1.WorkflowContractServiceUpdateRequest + 7, // 14: controlplane.v1.WorkflowContractService.Describe:input_type -> controlplane.v1.WorkflowContractServiceDescribeRequest + 9, // 15: controlplane.v1.WorkflowContractService.Delete:input_type -> controlplane.v1.WorkflowContractServiceDeleteRequest + 11, // 16: controlplane.v1.WorkflowContractService.Apply:input_type -> controlplane.v1.WorkflowContractServiceApplyRequest + 2, // 17: controlplane.v1.WorkflowContractService.List:output_type -> controlplane.v1.WorkflowContractServiceListResponse + 4, // 18: controlplane.v1.WorkflowContractService.Create:output_type -> controlplane.v1.WorkflowContractServiceCreateResponse + 6, // 19: controlplane.v1.WorkflowContractService.Update:output_type -> controlplane.v1.WorkflowContractServiceUpdateResponse + 8, // 20: controlplane.v1.WorkflowContractService.Describe:output_type -> controlplane.v1.WorkflowContractServiceDescribeResponse + 10, // 21: controlplane.v1.WorkflowContractService.Delete:output_type -> controlplane.v1.WorkflowContractServiceDeleteResponse + 12, // 22: controlplane.v1.WorkflowContractService.Apply:output_type -> controlplane.v1.WorkflowContractServiceApplyResponse + 17, // [17:23] is the sub-list for method output_type + 11, // [11:17] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_controlplane_v1_workflow_contract_proto_init() } @@ -858,13 +955,14 @@ func file_controlplane_v1_workflow_contract_proto_init() { File: protoimpl.DescBuilder{ 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, + NumEnums: 1, NumMessages: 14, NumExtensions: 0, NumServices: 1, }, GoTypes: file_controlplane_v1_workflow_contract_proto_goTypes, DependencyIndexes: file_controlplane_v1_workflow_contract_proto_depIdxs, + EnumInfos: file_controlplane_v1_workflow_contract_proto_enumTypes, MessageInfos: file_controlplane_v1_workflow_contract_proto_msgTypes, }.Build() File_controlplane_v1_workflow_contract_proto = out.File diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.proto b/app/controlplane/api/controlplane/v1/workflow_contract.proto index e5a9f42b2..ab9fb87fd 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. @@ -119,6 +119,8 @@ message WorkflowContractServiceDeleteResponse {} message WorkflowContractServiceApplyRequest { // Raw representation of the contract in json, yaml or cue bytes raw_schema = 1; + // When true, validate and compute the result without persisting any change + bool dry_run = 2; } message WorkflowContractServiceApplyResponse { @@ -127,4 +129,22 @@ message WorkflowContractServiceApplyResponse { bool unchanged = 2 [deprecated = true]; // Whether the resource was changed bool changed = 3; + // Detailed outcome of the apply operation + ApplyStatus status = 4; + // Current revision of the contract after the apply. + // For a newly created contract this is the first revision; for an + // unchanged contract it stays at the existing revision; for dry runs it + // reflects the existing revision (0 when the contract does not exist yet). + int32 current_revision = 5; + + // ApplyStatus describes how the applied resource changed + enum ApplyStatus { + APPLY_STATUS_UNSPECIFIED = 0; + // The resource did not exist and was created + APPLY_STATUS_CREATED = 1; + // The resource existed and its content changed + APPLY_STATUS_UPDATED = 2; + // The resource existed and its content was identical + APPLY_STATUS_UNCHANGED = 3; + } } 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..3776c0ad0 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. 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..8e74f327e 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts @@ -69,6 +69,8 @@ export interface WorkflowContractServiceDeleteResponse { export interface WorkflowContractServiceApplyRequest { /** Raw representation of the contract in json, yaml or cue */ rawSchema: Uint8Array; + /** When true, validate and compute the result without persisting any change */ + dryRun: boolean; } export interface WorkflowContractServiceApplyResponse { @@ -81,6 +83,68 @@ export interface WorkflowContractServiceApplyResponse { unchanged: boolean; /** Whether the resource was changed */ changed: boolean; + /** Detailed outcome of the apply operation */ + status: WorkflowContractServiceApplyResponse_ApplyStatus; + /** + * Current revision of the contract after the apply. + * For a newly created contract this is the first revision; for an + * unchanged contract it stays at the existing revision; for dry runs it + * reflects the existing revision (0 when the contract does not exist yet). + */ + currentRevision: number; +} + +/** ApplyStatus describes how the applied resource changed */ +export enum WorkflowContractServiceApplyResponse_ApplyStatus { + APPLY_STATUS_UNSPECIFIED = 0, + /** APPLY_STATUS_CREATED - The resource did not exist and was created */ + APPLY_STATUS_CREATED = 1, + /** APPLY_STATUS_UPDATED - The resource existed and its content changed */ + APPLY_STATUS_UPDATED = 2, + /** APPLY_STATUS_UNCHANGED - The resource existed and its content was identical */ + APPLY_STATUS_UNCHANGED = 3, + UNRECOGNIZED = -1, +} + +export function workflowContractServiceApplyResponse_ApplyStatusFromJSON( + object: any, +): WorkflowContractServiceApplyResponse_ApplyStatus { + switch (object) { + case 0: + case "APPLY_STATUS_UNSPECIFIED": + return WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_UNSPECIFIED; + case 1: + case "APPLY_STATUS_CREATED": + return WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_CREATED; + case 2: + case "APPLY_STATUS_UPDATED": + return WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_UPDATED; + case 3: + case "APPLY_STATUS_UNCHANGED": + return WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_UNCHANGED; + case -1: + case "UNRECOGNIZED": + default: + return WorkflowContractServiceApplyResponse_ApplyStatus.UNRECOGNIZED; + } +} + +export function workflowContractServiceApplyResponse_ApplyStatusToJSON( + object: WorkflowContractServiceApplyResponse_ApplyStatus, +): string { + switch (object) { + case WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_UNSPECIFIED: + return "APPLY_STATUS_UNSPECIFIED"; + case WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_CREATED: + return "APPLY_STATUS_CREATED"; + case WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_UPDATED: + return "APPLY_STATUS_UPDATED"; + case WorkflowContractServiceApplyResponse_ApplyStatus.APPLY_STATUS_UNCHANGED: + return "APPLY_STATUS_UNCHANGED"; + case WorkflowContractServiceApplyResponse_ApplyStatus.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } } function createBaseWorkflowContractServiceListRequest(): WorkflowContractServiceListRequest { @@ -937,7 +1001,7 @@ export const WorkflowContractServiceDeleteResponse = { }; function createBaseWorkflowContractServiceApplyRequest(): WorkflowContractServiceApplyRequest { - return { rawSchema: new Uint8Array(0) }; + return { rawSchema: new Uint8Array(0), dryRun: false }; } export const WorkflowContractServiceApplyRequest = { @@ -945,6 +1009,9 @@ export const WorkflowContractServiceApplyRequest = { if (message.rawSchema.length !== 0) { writer.uint32(10).bytes(message.rawSchema); } + if (message.dryRun === true) { + writer.uint32(16).bool(message.dryRun); + } return writer; }, @@ -962,6 +1029,13 @@ export const WorkflowContractServiceApplyRequest = { message.rawSchema = reader.bytes(); continue; + case 2: + if (tag !== 16) { + break; + } + + message.dryRun = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -972,13 +1046,17 @@ export const WorkflowContractServiceApplyRequest = { }, fromJSON(object: any): WorkflowContractServiceApplyRequest { - return { rawSchema: isSet(object.rawSchema) ? bytesFromBase64(object.rawSchema) : new Uint8Array(0) }; + return { + rawSchema: isSet(object.rawSchema) ? bytesFromBase64(object.rawSchema) : new Uint8Array(0), + dryRun: isSet(object.dryRun) ? Boolean(object.dryRun) : false, + }; }, toJSON(message: WorkflowContractServiceApplyRequest): unknown { const obj: any = {}; message.rawSchema !== undefined && (obj.rawSchema = base64FromBytes(message.rawSchema !== undefined ? message.rawSchema : new Uint8Array(0))); + message.dryRun !== undefined && (obj.dryRun = message.dryRun); return obj; }, @@ -993,12 +1071,13 @@ export const WorkflowContractServiceApplyRequest = { ): WorkflowContractServiceApplyRequest { const message = createBaseWorkflowContractServiceApplyRequest(); message.rawSchema = object.rawSchema ?? new Uint8Array(0); + message.dryRun = object.dryRun ?? false; return message; }, }; function createBaseWorkflowContractServiceApplyResponse(): WorkflowContractServiceApplyResponse { - return { result: undefined, unchanged: false, changed: false }; + return { result: undefined, unchanged: false, changed: false, status: 0, currentRevision: 0 }; } export const WorkflowContractServiceApplyResponse = { @@ -1012,6 +1091,12 @@ export const WorkflowContractServiceApplyResponse = { if (message.changed === true) { writer.uint32(24).bool(message.changed); } + if (message.status !== 0) { + writer.uint32(32).int32(message.status); + } + if (message.currentRevision !== 0) { + writer.uint32(40).int32(message.currentRevision); + } return writer; }, @@ -1043,6 +1128,20 @@ export const WorkflowContractServiceApplyResponse = { message.changed = reader.bool(); continue; + case 4: + if (tag !== 32) { + break; + } + + message.status = reader.int32() as any; + continue; + case 5: + if (tag !== 40) { + break; + } + + message.currentRevision = reader.int32(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1057,6 +1156,8 @@ export const WorkflowContractServiceApplyResponse = { result: isSet(object.result) ? WorkflowContractItem.fromJSON(object.result) : undefined, unchanged: isSet(object.unchanged) ? Boolean(object.unchanged) : false, changed: isSet(object.changed) ? Boolean(object.changed) : false, + status: isSet(object.status) ? workflowContractServiceApplyResponse_ApplyStatusFromJSON(object.status) : 0, + currentRevision: isSet(object.currentRevision) ? Number(object.currentRevision) : 0, }; }, @@ -1066,6 +1167,9 @@ export const WorkflowContractServiceApplyResponse = { (obj.result = message.result ? WorkflowContractItem.toJSON(message.result) : undefined); message.unchanged !== undefined && (obj.unchanged = message.unchanged); message.changed !== undefined && (obj.changed = message.changed); + message.status !== undefined && + (obj.status = workflowContractServiceApplyResponse_ApplyStatusToJSON(message.status)); + message.currentRevision !== undefined && (obj.currentRevision = Math.round(message.currentRevision)); return obj; }, @@ -1084,6 +1188,8 @@ export const WorkflowContractServiceApplyResponse = { : undefined; message.unchanged = object.unchanged ?? false; message.changed = object.changed ?? false; + message.status = object.status ?? 0; + message.currentRevision = object.currentRevision ?? 0; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json index 7db4b3f6a..1df8e1e06 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json @@ -3,6 +3,10 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(dry_run)$": { + "description": "When true, validate and compute the result without persisting any change", + "type": "boolean" + }, "^(raw_schema)$": { "description": "Raw representation of the contract in json, yaml or cue", "pattern": "^[A-Za-z0-9+/]*={0,2}$", @@ -10,6 +14,10 @@ } }, "properties": { + "dryRun": { + "description": "When true, validate and compute the result without persisting any change", + "type": "boolean" + }, "rawSchema": { "description": "Raw representation of the contract in json, yaml or cue", "pattern": "^[A-Za-z0-9+/]*={0,2}$", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json index 377643571..3a9060d0d 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json @@ -3,6 +3,10 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(dryRun)$": { + "description": "When true, validate and compute the result without persisting any change", + "type": "boolean" + }, "^(rawSchema)$": { "description": "Raw representation of the contract in json, yaml or cue", "pattern": "^[A-Za-z0-9+/]*={0,2}$", @@ -10,6 +14,10 @@ } }, "properties": { + "dry_run": { + "description": "When true, validate and compute the result without persisting any change", + "type": "boolean" + }, "raw_schema": { "description": "Raw representation of the contract in json, yaml or cue", "pattern": "^[A-Za-z0-9+/]*={0,2}$", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json index 8a8849ae0..ea8fce68d 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json @@ -2,14 +2,48 @@ "$id": "controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(current_revision)$": { + "description": "Current revision of the contract after the apply.\n For a newly created contract this is the first revision; for an\n unchanged contract it stays at the existing revision; for dry runs it\n reflects the existing revision (0 when the contract does not exist yet).", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, "properties": { "changed": { "description": "Whether the resource was changed", "type": "boolean" }, + "currentRevision": { + "description": "Current revision of the contract after the apply.\n For a newly created contract this is the first revision; for an\n unchanged contract it stays at the existing revision; for dry runs it\n reflects the existing revision (0 when the contract does not exist yet).", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "result": { "$ref": "controlplane.v1.WorkflowContractItem.jsonschema.json" }, + "status": { + "anyOf": [ + { + "enum": [ + "APPLY_STATUS_UNSPECIFIED", + "APPLY_STATUS_CREATED", + "APPLY_STATUS_UPDATED", + "APPLY_STATUS_UNCHANGED" + ], + "title": "Apply Status", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "Detailed outcome of the apply operation" + }, "unchanged": { "description": "Deprecated: use changed instead", "type": "boolean" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.schema.json index bff86e520..f75d37194 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.schema.json @@ -2,14 +2,48 @@ "$id": "controlplane.v1.WorkflowContractServiceApplyResponse.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(currentRevision)$": { + "description": "Current revision of the contract after the apply.\n For a newly created contract this is the first revision; for an\n unchanged contract it stays at the existing revision; for dry runs it\n reflects the existing revision (0 when the contract does not exist yet).", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, "properties": { "changed": { "description": "Whether the resource was changed", "type": "boolean" }, + "current_revision": { + "description": "Current revision of the contract after the apply.\n For a newly created contract this is the first revision; for an\n unchanged contract it stays at the existing revision; for dry runs it\n reflects the existing revision (0 when the contract does not exist yet).", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, "result": { "$ref": "controlplane.v1.WorkflowContractItem.schema.json" }, + "status": { + "anyOf": [ + { + "enum": [ + "APPLY_STATUS_UNSPECIFIED", + "APPLY_STATUS_CREATED", + "APPLY_STATUS_UPDATED", + "APPLY_STATUS_UNCHANGED" + ], + "title": "Apply Status", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "Detailed outcome of the apply operation" + }, "unchanged": { "description": "Deprecated: use changed instead", "type": "boolean" diff --git a/app/controlplane/internal/service/workflowcontract.go b/app/controlplane/internal/service/workflowcontract.go index 5ae5074db..6debe45b7 100644 --- a/app/controlplane/internal/service/workflowcontract.go +++ b/app/controlplane/internal/service/workflowcontract.go @@ -240,6 +240,8 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon return nil, err } + dryRun := req.GetDryRun() + // Validate and extract contract name and description from the raw schema contractName, description, err := validateAndExtractMetadata(req.RawSchema, "", "") if err != nil { @@ -267,6 +269,26 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon return nil, err } + // On a dry run we only compute whether the revision would change, without persisting + if dryRun { + changed, err := s.contractUseCase.RevisionWouldChange(ctx, currentOrg.ID, contract.ID.String(), req.RawSchema) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + status := pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UNCHANGED + if changed { + status = pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UPDATED + } + + return &pb.WorkflowContractServiceApplyResponse{ + Result: bizWorkFlowContractToPb(contract), + Changed: changed, + Status: status, + CurrentRevision: int32(contract.LatestRevision), + }, nil + } + schemaWithVersion, err := s.contractUseCase.Update(ctx, currentOrg.ID, contractName, &biz.WorkflowContractUpdateOpts{ Description: description, @@ -276,9 +298,16 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon return nil, handleUseCaseErr(err, s.log) } + status := pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UNCHANGED + if schemaWithVersion.Changed { + status = pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UPDATED + } + return &pb.WorkflowContractServiceApplyResponse{ - Result: bizWorkFlowContractToPb(schemaWithVersion.Contract), - Changed: schemaWithVersion.Changed, + Result: bizWorkFlowContractToPb(schemaWithVersion.Contract), + Changed: schemaWithVersion.Changed, + Status: status, + CurrentRevision: int32(schemaWithVersion.Contract.LatestRevision), }, nil } @@ -295,6 +324,15 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon } } + // On a dry run we report that the contract would be created, without persisting it + if dryRun { + return &pb.WorkflowContractServiceApplyResponse{ + Changed: true, + Status: pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED, + CurrentRevision: 0, + }, nil + } + schema, err := s.contractUseCase.Create(ctx, &biz.WorkflowContractCreateOpts{ OrgID: currentOrg.ID, Name: contractName, @@ -305,7 +343,12 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon return nil, handleUseCaseErr(err, s.log) } - return &pb.WorkflowContractServiceApplyResponse{Result: bizWorkFlowContractToPb(schema), Changed: true}, nil + return &pb.WorkflowContractServiceApplyResponse{ + Result: bizWorkFlowContractToPb(schema), + Changed: true, + Status: pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED, + CurrentRevision: int32(schema.LatestRevision), + }, nil } func (s *WorkflowContractService) Delete(ctx context.Context, req *pb.WorkflowContractServiceDeleteRequest) (*pb.WorkflowContractServiceDeleteResponse, error) { diff --git a/app/controlplane/internal/service/workflowcontract_integration_test.go b/app/controlplane/internal/service/workflowcontract_integration_test.go new file mode 100644 index 000000000..06a2c7e3a --- /dev/null +++ b/app/controlplane/internal/service/workflowcontract_integration_test.go @@ -0,0 +1,197 @@ +// +// 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 service + +import ( + "context" + "testing" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/go-kratos/kratos/v2/transport" + "github.com/stretchr/testify/suite" +) + +const ( + applyContractName = "svc-apply-contract" + + applyContractV1 = ` +apiVersion: chainloop.dev/v1 +kind: Contract +metadata: + name: svc-apply-contract +spec: + materials: + - type: ARTIFACT + name: my-artifact +` + // Same contract with an extra material, so the raw body differs + applyContractV2 = ` +apiVersion: chainloop.dev/v1 +kind: Contract +metadata: + name: svc-apply-contract +spec: + materials: + - type: ARTIFACT + name: my-artifact + - type: SBOM_CYCLONEDX_JSON + name: my-sbom +` +) + +func (s *workflowContractApplyIntegrationTestSuite) apply(rawSchema string, dryRun bool) *pb.WorkflowContractServiceApplyResponse { + resp, err := s.svc.Apply(s.ctx, &pb.WorkflowContractServiceApplyRequest{ + RawSchema: []byte(rawSchema), + DryRun: dryRun, + }) + s.Require().NoError(err) + return resp +} + +func (s *workflowContractApplyIntegrationTestSuite) latestRevision() int { + contract, err := s.WorkflowContract.FindByNameInOrg(s.ctx, s.org.ID, applyContractName) + if err != nil && biz.IsNotFound(err) { + return 0 + } + s.Require().NoError(err) + if contract == nil { + return 0 + } + return contract.LatestRevision +} + +func (s *workflowContractApplyIntegrationTestSuite) TestApply() { + // 1 - Real apply creates the contract + resp := s.apply(applyContractV1, false) + s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED, resp.GetStatus()) + s.True(resp.GetChanged()) + s.EqualValues(1, resp.GetCurrentRevision()) + s.Equal(1, s.latestRevision()) + + // 2 - Dry run with identical content reports unchanged and does not persist + resp = s.apply(applyContractV1, true) + s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UNCHANGED, resp.GetStatus()) + s.False(resp.GetChanged()) + s.EqualValues(1, resp.GetCurrentRevision()) + s.Equal(1, s.latestRevision()) + + // 3 - Dry run with different content reports updated and does not persist + resp = s.apply(applyContractV2, true) + s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UPDATED, resp.GetStatus()) + s.True(resp.GetChanged()) + s.EqualValues(1, resp.GetCurrentRevision()) + s.Equal(1, s.latestRevision(), "dry run must not bump the revision") + + // 4 - Real apply with different content bumps the revision + resp = s.apply(applyContractV2, false) + s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UPDATED, resp.GetStatus()) + s.True(resp.GetChanged()) + s.EqualValues(2, resp.GetCurrentRevision()) + s.Equal(2, s.latestRevision()) + + // 5 - Real apply with the same content again reports unchanged + resp = s.apply(applyContractV2, false) + s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_UNCHANGED, resp.GetStatus()) + s.False(resp.GetChanged()) + s.EqualValues(2, resp.GetCurrentRevision()) + + // 6 - Dry run for a brand new contract reports created with revision 0 and does not persist + resp, err := s.svc.Apply(s.ctx, &pb.WorkflowContractServiceApplyRequest{ + RawSchema: []byte(` +apiVersion: chainloop.dev/v1 +kind: Contract +metadata: + name: svc-apply-contract-new +spec: + materials: + - type: ARTIFACT + name: my-artifact +`), + DryRun: true, + }) + s.Require().NoError(err) + s.Equal(pb.WorkflowContractServiceApplyResponse_APPLY_STATUS_CREATED, resp.GetStatus()) + s.True(resp.GetChanged()) + s.EqualValues(0, resp.GetCurrentRevision()) + + created, err := s.WorkflowContract.FindByNameInOrg(s.ctx, s.org.ID, "svc-apply-contract-new") + if err != nil { + s.True(biz.IsNotFound(err), "dry run must not create the contract") + } else { + s.Nil(created, "dry run must not create the contract") + } +} + +func TestWorkflowContractApply(t *testing.T) { + suite.Run(t, new(workflowContractApplyIntegrationTestSuite)) +} + +type workflowContractApplyIntegrationTestSuite struct { + testhelpers.UseCasesEachTestSuite + org *biz.Organization + svc *WorkflowContractService + ctx context.Context +} + +func (s *workflowContractApplyIntegrationTestSuite) SetupTest() { + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) + + var err error + s.org, err = s.Organization.CreateWithRandomName(context.Background()) + s.Require().NoError(err) + + s.svc = NewWorkflowSchemaService(s.WorkflowContract, s.Organization, s.User) + + // Build a context with the current org and a bearer token, as the handler expects + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) + ctx = transport.NewServerContext(ctx, &applyMockTransport{ + header: applyMockHeader{"Authorization": "Bearer test-token"}, + }) + s.ctx = ctx +} + +type applyMockHeader map[string]string + +func (h applyMockHeader) Get(key string) string { return h[key] } +func (h applyMockHeader) Set(key, value string) { h[key] = value } +func (h applyMockHeader) Add(key, value string) { h[key] = value } +func (h applyMockHeader) Keys() []string { + keys := make([]string, 0, len(h)) + for k := range h { + keys = append(keys, k) + } + return keys +} + +func (h applyMockHeader) Values(key string) []string { + if v, ok := h[key]; ok { + return []string{v} + } + return nil +} + +type applyMockTransport struct { + header transport.Header +} + +func (tr *applyMockTransport) Kind() transport.Kind { return transport.KindGRPC } +func (tr *applyMockTransport) Endpoint() string { return "" } +func (tr *applyMockTransport) Operation() string { return "" } +func (tr *applyMockTransport) RequestHeader() transport.Header { return tr.header } +func (tr *applyMockTransport) ReplyHeader() transport.Header { return tr.header } diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index 57c26e9d6..44277dc08 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -16,6 +16,7 @@ package biz import ( + "bytes" "context" "errors" "fmt" @@ -464,6 +465,41 @@ func (uc *WorkflowContractUseCase) Update(ctx context.Context, orgID, name strin return c, nil } +// RevisionWouldChange reports whether applying rawSchema to the contract +// identified by contractID would create a new revision, without persisting +// anything. It mirrors the change detection performed by the data layer on +// Update (a new revision is created only when the stored raw body differs from +// the incoming one), so dry-run apply and real apply agree on the outcome. +func (uc *WorkflowContractUseCase) RevisionWouldChange(ctx context.Context, orgID, contractID string, rawSchema []byte) (bool, error) { + ctx, span := otelx.Start(ctx, workflowContractTracer, "WorkflowContractUseCase.RevisionWouldChange") + defer span.End() + + orgUUID, err := uuid.Parse(orgID) + if err != nil { + return false, err + } + + contractUUID, err := uuid.Parse(contractID) + if err != nil { + return false, err + } + + incoming, err := identifyUnMarshalAndValidateRawContract(rawSchema) + if err != nil { + return false, fmt.Errorf("failed to load contract: %w", err) + } + + // revision 0 means the latest version + latest, err := uc.repo.Describe(ctx, orgUUID, contractUUID, 0) + if err != nil { + return false, err + } + + // Same rule used by the data layer on Update: a new revision is created + // only when the stored raw body differs from the incoming one. + return !bytes.Equal(latest.Version.Schema.Raw, incoming.Raw), nil +} + func (uc *WorkflowContractUseCase) ValidateContractPolicies(ctx context.Context, rawSchema []byte, token string) error { // Validate that externally provided policies exist c, err := identifyUnMarshalAndValidateRawContract(rawSchema) diff --git a/app/controlplane/pkg/biz/workflowcontract_integration_test.go b/app/controlplane/pkg/biz/workflowcontract_integration_test.go index ffa1077bc..d9bbd1e2c 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. @@ -141,6 +141,46 @@ func (s *workflowContractIntegrationTestSuite) TestUpdate() { } } +func (s *workflowContractIntegrationTestSuite) TestRevisionWouldChange() { + ctx := context.Background() + + // Grab the current (latest) raw body of the existing contract + current, err := s.WorkflowContract.Describe(ctx, s.org.ID, s.contractOrg1.ID.String(), 0) + require.NoError(s.T(), err) + currentRaw := current.Version.Schema.Raw + currentRevision := current.Version.Revision + + updatedSchema := &schemav1.CraftingSchema{SchemaVersion: "v1", Runner: &schemav1.CraftingSchema_Runner{Type: schemav1.CraftingSchema_Runner_AZURE_PIPELINE}} + updatedRaw, err := biz.SchemaToRawContract(updatedSchema) + require.NoError(s.T(), err) + + s.Run("identical schema would not change the revision", func() { + changed, err := s.WorkflowContract.RevisionWouldChange(ctx, s.org.ID, s.contractOrg1.ID.String(), currentRaw) + require.NoError(s.T(), err) + s.False(changed) + }) + + s.Run("different schema would change the revision", func() { + changed, err := s.WorkflowContract.RevisionWouldChange(ctx, s.org.ID, s.contractOrg1.ID.String(), updatedRaw.Raw) + require.NoError(s.T(), err) + s.True(changed) + }) + + s.Run("it does not persist any change", func() { + _, err := s.WorkflowContract.RevisionWouldChange(ctx, s.org.ID, s.contractOrg1.ID.String(), updatedRaw.Raw) + require.NoError(s.T(), err) + + after, err := s.WorkflowContract.Describe(ctx, s.org.ID, s.contractOrg1.ID.String(), 0) + require.NoError(s.T(), err) + s.Equal(currentRevision, after.Version.Revision) + }) + + s.Run("non-existing contract returns not found", func() { + _, err := s.WorkflowContract.RevisionWouldChange(ctx, s.org.ID, uuid.NewString(), updatedRaw.Raw) + s.ErrorContains(err, "not found") + }) +} + func (s *workflowContractIntegrationTestSuite) TestCreateDuplicatedName() { ctx := context.Background()