From 45a4204560a30b3e7b971d9f5114cdba46712d1d Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Fri, 6 Mar 2026 14:44:31 +0100 Subject: [PATCH] detect unchanged contract Signed-off-by: Sylwester Piskozub --- app/cli/cmd/apply.go | 8 ++++++- app/cli/cmd/workflow_contract_apply.go | 8 +++++-- app/cli/pkg/action/apply.go | 19 +++++++++-------- app/cli/pkg/action/workflow_contract_apply.go | 18 +++++++++------- .../controlplane/v1/workflow_contract.pb.go | 18 ++++++++++++---- .../controlplane/v1/workflow_contract.proto | 2 ++ .../controlplane/v1/workflow_contract.ts | 21 +++++++++++++++++-- ...ntractServiceApplyResponse.jsonschema.json | 4 ++++ ...owContractServiceApplyResponse.schema.json | 4 ++++ .../internal/service/workflowcontract.go | 5 ++++- app/controlplane/pkg/biz/workflowcontract.go | 10 ++++++--- 11 files changed, 88 insertions(+), 29 deletions(-) diff --git a/app/cli/cmd/apply.go b/app/cli/cmd/apply.go index 38f0146ed..a1d1d0504 100644 --- a/app/cli/cmd/apply.go +++ b/app/cli/cmd/apply.go @@ -39,7 +39,13 @@ Supports multi-document YAML files. Each document must have a 'kind' field.`, return err } - logger.Info().Msgf("%d contracts applied", len(results)) + for _, r := range results { + status := "applied" + if r.Unchanged { + status = "unchanged" + } + logger.Info().Msgf("%s/%s %s", r.Kind, r.Name, status) + } return nil }, diff --git a/app/cli/cmd/workflow_contract_apply.go b/app/cli/cmd/workflow_contract_apply.go index 5aaf3e9d0..70614d43b 100644 --- a/app/cli/cmd/workflow_contract_apply.go +++ b/app/cli/cmd/workflow_contract_apply.go @@ -48,12 +48,16 @@ or update it if it already exists.`, desc = &description } - res, err := action.NewWorkflowContractApply(ActionOpts).Run(cmd.Context(), contractName, contractPath, desc, projectName) + res, unchanged, err := action.NewWorkflowContractApply(ActionOpts).Run(cmd.Context(), contractName, contractPath, desc, projectName) if err != nil { return err } - logger.Info().Msg("Contract applied!") + status := "applied" + if unchanged { + status = "unchanged" + } + logger.Info().Msgf("Contract/%s %s", contractName, status) return output.EncodeOutput(flagOutputFormat, res, contractItemTableOutput) }, } diff --git a/app/cli/pkg/action/apply.go b/app/cli/pkg/action/apply.go index c198e24a7..022c31146 100644 --- a/app/cli/pkg/action/apply.go +++ b/app/cli/pkg/action/apply.go @@ -36,8 +36,9 @@ const ( // ApplyResult holds the outcome of a successfully applied resource document type ApplyResult struct { - Kind string - Name string + Kind string + Name string + Unchanged bool } // YAMLDoc holds a parsed YAML document with its kind and raw bytes @@ -67,16 +68,16 @@ func (a *Apply) Run(ctx context.Context, path string) ([]*ApplyResult, error) { // Apply contracts var results []*ApplyResult for _, doc := range docs { - result := &ApplyResult{Kind: doc.Kind, Name: doc.Name} switch doc.Kind { case KindContract: - if err := ApplyContractFromRawData(ctx, a.cfg.CPConnection, doc.RawData); err != nil { + unchanged, err := ApplyContractFromRawData(ctx, a.cfg.CPConnection, doc.RawData) + if err != nil { return results, fmt.Errorf("%s/%s: %w", doc.Kind, doc.Name, err) } + results = append(results, &ApplyResult{Kind: doc.Kind, Name: doc.Name, Unchanged: unchanged}) default: return results, fmt.Errorf("unsupported kind %q", doc.Kind) } - results = append(results, result) } return results, nil @@ -113,17 +114,17 @@ func ParseYAMLPath(path string) ([]*YAMLDoc, error) { } // ApplyContractFromRawData applies a single contract document using the gRPC client. -func ApplyContractFromRawData(ctx context.Context, conn *grpc.ClientConn, rawData []byte) error { +func ApplyContractFromRawData(ctx context.Context, conn *grpc.ClientConn, rawData []byte) (bool, error) { client := pb.NewWorkflowContractServiceClient(conn) - _, err := client.Apply(ctx, &pb.WorkflowContractServiceApplyRequest{ + resp, err := client.Apply(ctx, &pb.WorkflowContractServiceApplyRequest{ RawSchema: rawData, }) if err != nil { - return fmt.Errorf("failed to apply contract: %w", err) + return false, fmt.Errorf("failed to apply contract: %w", err) } - return nil + return resp.GetUnchanged(), nil } // CollectYAMLFiles returns YAML file paths from the given path. diff --git a/app/cli/pkg/action/workflow_contract_apply.go b/app/cli/pkg/action/workflow_contract_apply.go index 63a6f9335..a86ae8389 100644 --- a/app/cli/pkg/action/workflow_contract_apply.go +++ b/app/cli/pkg/action/workflow_contract_apply.go @@ -30,7 +30,7 @@ func NewWorkflowContractApply(cfg *ActionsOpts) *WorkflowContractApply { return &WorkflowContractApply{cfg} } -func (action *WorkflowContractApply) Run(ctx context.Context, contractName string, contractPath string, description *string, projectName string) (*WorkflowContractItem, error) { +func (action *WorkflowContractApply) Run(ctx context.Context, contractName string, contractPath string, description *string, projectName string) (*WorkflowContractItem, bool, error) { client := pb.NewWorkflowContractServiceClient(action.cfg.CPConnection) // Try to describe the specific contract first to determine if we should create or update @@ -43,14 +43,16 @@ func (action *WorkflowContractApply) Run(ctx context.Context, contractName strin raw, err := LoadFileOrURL(contractPath) if err != nil { action.cfg.Logger.Debug().Err(err).Msg("loading the contract") - return nil, err + return nil, false, err } rawContract = raw } - _, err := client.Describe(ctx, describeReq) + describeRes, err := client.Describe(ctx, describeReq) if err == nil { // Contract exists, perform update + prevRevision := describeRes.Result.GetRevision().GetRevision() + updateReq := &pb.WorkflowContractServiceUpdateRequest{ Name: contractName, Description: description, @@ -59,10 +61,12 @@ func (action *WorkflowContractApply) Run(ctx context.Context, contractName strin res, err := client.Update(ctx, updateReq) if err != nil { - return nil, fmt.Errorf("failed to update existing contract '%s': %w", contractName, err) + return nil, false, fmt.Errorf("failed to update existing contract '%s': %w", contractName, err) } - return pbWorkflowContractItemToAction(res.Result.Contract), nil + unchanged := prevRevision == res.Result.GetRevision().GetRevision() + + return pbWorkflowContractItemToAction(res.Result.Contract), unchanged, nil } // Contract doesn't exist, perform create @@ -80,8 +84,8 @@ func (action *WorkflowContractApply) Run(ctx context.Context, contractName strin res, err := client.Create(ctx, createReq) if err != nil { - return nil, fmt.Errorf("failed to create new contract '%s': %w", contractName, err) + return nil, false, fmt.Errorf("failed to create new contract '%s': %w", contractName, err) } - return pbWorkflowContractItemToAction(res.Result), nil + return pbWorkflowContractItemToAction(res.Result), false, nil } diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go index 03ad32dc0..c69e06870 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go @@ -558,8 +558,10 @@ func (x *WorkflowContractServiceApplyRequest) GetRawSchema() []byte { } type WorkflowContractServiceApplyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Result *WorkflowContractItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Result *WorkflowContractItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + // Whether the resource was unchanged + Unchanged bool `protobuf:"varint,2,opt,name=unchanged,proto3" json:"unchanged,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -601,6 +603,13 @@ func (x *WorkflowContractServiceApplyResponse) GetResult() *WorkflowContractItem return nil } +func (x *WorkflowContractServiceApplyResponse) GetUnchanged() bool { + if x != nil { + return x.Unchanged + } + return false +} + type WorkflowContractServiceUpdateResponse_Result struct { state protoimpl.MessageState `protogen:"open.v1"` Contract *WorkflowContractItem `protobuf:"bytes,1,opt,name=contract,proto3" json:"contract,omitempty"` @@ -748,9 +757,10 @@ const file_controlplane_v1_workflow_contract_proto_rawDesc = "" + "%WorkflowContractServiceDeleteResponse\"D\n" + "#WorkflowContractServiceApplyRequest\x12\x1d\n" + "\n" + - "raw_schema\x18\x01 \x01(\fR\trawSchema\"e\n" + + "raw_schema\x18\x01 \x01(\fR\trawSchema\"\x83\x01\n" + "$WorkflowContractServiceApplyResponse\x12=\n" + - "\x06result\x18\x01 \x01(\v2%.controlplane.v1.WorkflowContractItemR\x06result2\xec\x05\n" + + "\x06result\x18\x01 \x01(\v2%.controlplane.v1.WorkflowContractItemR\x06result\x12\x1c\n" + + "\tunchanged\x18\x02 \x01(\bR\tunchanged2\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" + diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.proto b/app/controlplane/api/controlplane/v1/workflow_contract.proto index a0e101c39..36f37c027 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract.proto +++ b/app/controlplane/api/controlplane/v1/workflow_contract.proto @@ -123,4 +123,6 @@ message WorkflowContractServiceApplyRequest { message WorkflowContractServiceApplyResponse { WorkflowContractItem result = 1; + // Whether the resource was unchanged + bool unchanged = 2; } 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 6abf8f303..ef5fbd321 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts @@ -73,6 +73,8 @@ export interface WorkflowContractServiceApplyRequest { export interface WorkflowContractServiceApplyResponse { result?: WorkflowContractItem; + /** Whether the resource was unchanged */ + unchanged: boolean; } function createBaseWorkflowContractServiceListRequest(): WorkflowContractServiceListRequest { @@ -990,7 +992,7 @@ export const WorkflowContractServiceApplyRequest = { }; function createBaseWorkflowContractServiceApplyResponse(): WorkflowContractServiceApplyResponse { - return { result: undefined }; + return { result: undefined, unchanged: false }; } export const WorkflowContractServiceApplyResponse = { @@ -998,6 +1000,9 @@ export const WorkflowContractServiceApplyResponse = { if (message.result !== undefined) { WorkflowContractItem.encode(message.result, writer.uint32(10).fork()).ldelim(); } + if (message.unchanged === true) { + writer.uint32(16).bool(message.unchanged); + } return writer; }, @@ -1015,6 +1020,13 @@ export const WorkflowContractServiceApplyResponse = { message.result = WorkflowContractItem.decode(reader, reader.uint32()); continue; + case 2: + if (tag !== 16) { + break; + } + + message.unchanged = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1025,13 +1037,17 @@ export const WorkflowContractServiceApplyResponse = { }, fromJSON(object: any): WorkflowContractServiceApplyResponse { - return { result: isSet(object.result) ? WorkflowContractItem.fromJSON(object.result) : undefined }; + return { + result: isSet(object.result) ? WorkflowContractItem.fromJSON(object.result) : undefined, + unchanged: isSet(object.unchanged) ? Boolean(object.unchanged) : false, + }; }, toJSON(message: WorkflowContractServiceApplyResponse): unknown { const obj: any = {}; message.result !== undefined && (obj.result = message.result ? WorkflowContractItem.toJSON(message.result) : undefined); + message.unchanged !== undefined && (obj.unchanged = message.unchanged); return obj; }, @@ -1048,6 +1064,7 @@ export const WorkflowContractServiceApplyResponse = { message.result = (object.result !== undefined && object.result !== null) ? WorkflowContractItem.fromPartial(object.result) : undefined; + message.unchanged = object.unchanged ?? false; return message; }, }; 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 41e6d3771..bd89a7702 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.jsonschema.json @@ -5,6 +5,10 @@ "properties": { "result": { "$ref": "controlplane.v1.WorkflowContractItem.jsonschema.json" + }, + "unchanged": { + "description": "Whether the resource was unchanged", + "type": "boolean" } }, "title": "Workflow Contract Service Apply Response", 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 c2d3a3b57..25a2e806b 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyResponse.schema.json @@ -5,6 +5,10 @@ "properties": { "result": { "$ref": "controlplane.v1.WorkflowContractItem.schema.json" + }, + "unchanged": { + "description": "Whether the resource was unchanged", + "type": "boolean" } }, "title": "Workflow Contract Service Apply Response", diff --git a/app/controlplane/internal/service/workflowcontract.go b/app/controlplane/internal/service/workflowcontract.go index 0a7459f12..c3c7bdca7 100644 --- a/app/controlplane/internal/service/workflowcontract.go +++ b/app/controlplane/internal/service/workflowcontract.go @@ -275,7 +275,10 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon return nil, handleUseCaseErr(err, s.log) } - return &pb.WorkflowContractServiceApplyResponse{Result: bizWorkFlowContractToPb(schemaWithVersion.Contract)}, nil + return &pb.WorkflowContractServiceApplyResponse{ + Result: bizWorkFlowContractToPb(schemaWithVersion.Contract), + Unchanged: schemaWithVersion.Unchanged, + }, nil } // Contract does not exist we create diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index db4aa5a9d..e3a9ab0ad 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -90,8 +90,9 @@ func (c *Contract) isV1Schema() bool { } type WorkflowContractWithVersion struct { - Contract *WorkflowContract - Version *WorkflowContractVersion + Contract *WorkflowContract + Version *WorkflowContractVersion + Unchanged bool } type WorkflowContractRepo interface { @@ -425,13 +426,16 @@ func (uc *WorkflowContractUseCase) Update(ctx context.Context, orgID, name strin } // Check if the revisions have changed - if wfContractPreUpdate.LatestRevision != c.Version.Revision { + unchanged := wfContractPreUpdate.LatestRevision == c.Version.Revision + if !unchanged { eventPayload.NewRevision = &c.Version.Revision eventPayload.NewRevisionID = &c.Version.ID } uc.auditorUC.Dispatch(ctx, eventPayload, &orgUUID) + c.Unchanged = unchanged + return c, nil }