From 16df54ddbc71f313bd32915fa5716c864e6c9852 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 31 Aug 2026 14:17:45 +0200 Subject: [PATCH] feat(controlplane): accept a workflow template reference on workflow create Expose the existing workflows.workflow_template_id binding over the gRPC API so a workflow can be created bound to a platform workflow template, rather than only through repository auto-setup. The reference is opaque: the control plane has no workflow_template table and no foreign key on the column, so existence and org ownership are not validated here. Contract semantics are unchanged, a template-bound workflow gets the contract the caller passed or the default empty one. The open-source CLI command layer is untouched and exposes no --template flag. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 0768ad26-7f72-465d-ac82-73b23a38067d --- app/cli/pkg/action/workflow_create.go | 19 +++- app/cli/pkg/action/workflow_create_test.go | 103 ++++++++++++++++++ app/cli/pkg/action/workflow_list.go | 4 + .../controlplane/v1/response_messages.pb.go | 23 +++- .../controlplane/v1/response_messages.proto | 3 + .../api/controlplane/v1/workflow.pb.go | 20 +++- .../api/controlplane/v1/workflow.proto | 8 ++ .../api/controlplane/v1/workflow_test.go | 74 +++++++++++++ .../controlplane/v1/response_messages.ts | 19 ++++ .../gen/frontend/controlplane/v1/workflow.ts | 29 ++++- ...ntrolplane.v1.WorkflowItem.jsonschema.json | 8 ++ .../controlplane.v1.WorkflowItem.schema.json | 8 ++ ...rkflowServiceCreateRequest.jsonschema.json | 10 ++ ...1.WorkflowServiceCreateRequest.schema.json | 10 ++ app/controlplane/internal/service/workflow.go | 15 +++ .../internal/service/workflow_test.go | 61 +++++++++++ .../pkg/biz/workflow_integration_test.go | 35 ++++++ 17 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 app/cli/pkg/action/workflow_create_test.go create mode 100644 app/controlplane/api/controlplane/v1/workflow_test.go create mode 100644 app/controlplane/internal/service/workflow_test.go diff --git a/app/cli/pkg/action/workflow_create.go b/app/cli/pkg/action/workflow_create.go index 32f8daee8..b224d67eb 100644 --- a/app/cli/pkg/action/workflow_create.go +++ b/app/cli/pkg/action/workflow_create.go @@ -32,18 +32,27 @@ func NewWorkflowCreate(cfg *ActionsOpts) *WorkflowCreate { type NewWorkflowCreateOpts struct { Name, Description, Project, Team, ContractName string ContractBytes []byte + // WorkflowTemplateID optionally binds the workflow to a platform workflow template. + // The open-source CLI does not set it, it exists so template-aware clients can. + WorkflowTemplateID string } func (action *WorkflowCreate) Run(opts *NewWorkflowCreateOpts) (*WorkflowItem, error) { client := pb.NewWorkflowServiceClient(action.cfg.CPConnection) - resp, err := client.Create(context.Background(), &pb.WorkflowServiceCreateRequest{ - Name: opts.Name, ProjectName: opts.Project, Team: opts.Team, ContractName: opts.ContractName, - Description: opts.Description, - ContractBytes: opts.ContractBytes, - }) + resp, err := client.Create(context.Background(), newWorkflowCreateRequest(opts)) if err != nil { return nil, err } return pbWorkflowItemToAction(resp.Result), nil } + +// newWorkflowCreateRequest maps the create options to the API request +func newWorkflowCreateRequest(opts *NewWorkflowCreateOpts) *pb.WorkflowServiceCreateRequest { + return &pb.WorkflowServiceCreateRequest{ + Name: opts.Name, ProjectName: opts.Project, Team: opts.Team, ContractName: opts.ContractName, + Description: opts.Description, + ContractBytes: opts.ContractBytes, + WorkflowTemplateId: opts.WorkflowTemplateID, + } +} diff --git a/app/cli/pkg/action/workflow_create_test.go b/app/cli/pkg/action/workflow_create_test.go new file mode 100644 index 000000000..492325b86 --- /dev/null +++ b/app/cli/pkg/action/workflow_create_test.go @@ -0,0 +1,103 @@ +// +// 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 ( + "testing" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// TestNewWorkflowCreateOptsToRequest pins that the create options, including the optional +// workflow template reference, are forwarded verbatim to the API request. +func TestNewWorkflowCreateOptsToRequest(t *testing.T) { + testCases := []struct { + desc string + opts *NewWorkflowCreateOpts + }{ + { + desc: "without a template reference", + opts: &NewWorkflowCreateOpts{ + Name: "my-workflow", + Project: "my-project", + }, + }, + { + desc: "with a template reference", + opts: &NewWorkflowCreateOpts{ + Name: "my-workflow", + Project: "my-project", + Team: "my-team", + Description: "a description", + ContractName: "my-contract", + ContractBytes: []byte("schemaVersion: v1"), + WorkflowTemplateID: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + got := newWorkflowCreateRequest(tc.opts) + + assert.Equal(t, tc.opts.Name, got.GetName()) + assert.Equal(t, tc.opts.Project, got.GetProjectName()) + assert.Equal(t, tc.opts.Team, got.GetTeam()) + assert.Equal(t, tc.opts.Description, got.GetDescription()) + assert.Equal(t, tc.opts.ContractName, got.GetContractName()) + assert.Equal(t, tc.opts.ContractBytes, got.GetContractBytes()) + assert.Equal(t, tc.opts.WorkflowTemplateID, got.GetWorkflowTemplateId()) + }) + } +} + +func TestPbWorkflowItemToActionWorkflowTemplateID(t *testing.T) { + testCases := []struct { + desc string + item *pb.WorkflowItem + want string + }{ + { + desc: "unbound workflow leaves the template reference empty", + item: &pb.WorkflowItem{ + Id: "wf-id", + Name: "my-workflow", + CreatedAt: timestamppb.Now(), + }, + want: "", + }, + { + desc: "template-backed workflow carries the template reference", + item: &pb.WorkflowItem{ + Id: "wf-id", + Name: "my-workflow", + CreatedAt: timestamppb.Now(), + WorkflowTemplateId: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c", + }, + want: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + got := pbWorkflowItemToAction(tc.item) + + assert.Equal(t, tc.want, got.WorkflowTemplateID) + }) + } +} diff --git a/app/cli/pkg/action/workflow_list.go b/app/cli/pkg/action/workflow_list.go index 47be7a866..12a63470d 100644 --- a/app/cli/pkg/action/workflow_list.go +++ b/app/cli/pkg/action/workflow_list.go @@ -38,6 +38,9 @@ type WorkflowItem struct { ContractName string `json:"contractName,omitempty"` ContractRevisionLatest int32 `json:"contractRevisionLatest,omitempty"` LastRun *WorkflowRunItem `json:"lastRun,omitempty"` + // WorkflowTemplateID is the platform workflow template this workflow is bound to, + // empty when it is not bound to any + WorkflowTemplateID string `json:"workflowTemplateId,omitempty"` } // WorkflowListResult holds the output of the workflow list action @@ -106,6 +109,7 @@ func pbWorkflowItemToAction(wf *pb.WorkflowItem) *WorkflowItem { ContractRevisionLatest: wf.ContractRevisionLatest, LastRun: pbWorkflowRunItemToAction(wf.LastRun), Description: wf.Description, + WorkflowTemplateID: wf.WorkflowTemplateId, } } diff --git a/app/controlplane/api/controlplane/v1/response_messages.pb.go b/app/controlplane/api/controlplane/v1/response_messages.pb.go index 1f996e42f..1b236dac0 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.pb.go +++ b/app/controlplane/api/controlplane/v1/response_messages.pb.go @@ -746,10 +746,13 @@ type WorkflowItem struct { // is retained only for wire compatibility; it will be removed in a future release. // // Deprecated: Marked as deprecated in controlplane/v1/response_messages.proto. - Public bool `protobuf:"varint,9,opt,name=public,proto3" json:"public,omitempty"` - Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Public bool `protobuf:"varint,9,opt,name=public,proto3" json:"public,omitempty"` + Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"` + // Optional reference to the platform workflow template this workflow was created from. + // Empty when the workflow is not bound to any template. + WorkflowTemplateId string `protobuf:"bytes,13,opt,name=workflow_template_id,json=workflowTemplateId,proto3" json:"workflow_template_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkflowItem) Reset() { @@ -867,6 +870,13 @@ func (x *WorkflowItem) GetDescription() string { return "" } +func (x *WorkflowItem) GetWorkflowTemplateId() string { + if x != nil { + return x.WorkflowTemplateId + } + return "" +} + type WorkflowRunItem struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -3103,7 +3113,7 @@ var File_controlplane_v1_response_messages_proto protoreflect.FileDescriptor const file_controlplane_v1_response_messages_proto_rawDesc = "" + "\n" + - "'controlplane/v1/response_messages.proto\x12\x0fcontrolplane.v1\x1a#attestation/v1/crafting_state.proto\x1a\x1bbuf/validate/validate.proto\x1a\x13errors/errors.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a)workflowcontract/v1/crafting_schema.proto\"\xbd\x03\n" + + "'controlplane/v1/response_messages.proto\x12\x0fcontrolplane.v1\x1a#attestation/v1/crafting_state.proto\x1a\x1bbuf/validate/validate.proto\x1a\x13errors/errors.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a)workflowcontract/v1/crafting_schema.proto\"\xef\x03\n" + "\fWorkflowItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + @@ -3120,7 +3130,8 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "\x18contract_revision_latest\x18\v \x01(\x05R\x16contractRevisionLatest\x12\x1a\n" + "\x06public\x18\t \x01(\bB\x02\x18\x01R\x06public\x12 \n" + "\vdescription\x18\n" + - " \x01(\tR\vdescription\"\xd3\x06\n" + + " \x01(\tR\vdescription\x120\n" + + "\x14workflow_template_id\x18\r \x01(\tR\x12workflowTemplateId\"\xd3\x06\n" + "\x0fWorkflowRunItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + diff --git a/app/controlplane/api/controlplane/v1/response_messages.proto b/app/controlplane/api/controlplane/v1/response_messages.proto index 10d275536..990390745 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.proto +++ b/app/controlplane/api/controlplane/v1/response_messages.proto @@ -42,6 +42,9 @@ message WorkflowItem { // is retained only for wire compatibility; it will be removed in a future release. bool public = 9 [deprecated = true]; string description = 10; + // Optional reference to the platform workflow template this workflow was created from. + // Empty when the workflow is not bound to any template. + string workflow_template_id = 13; } message WorkflowRunItem { diff --git a/app/controlplane/api/controlplane/v1/workflow.pb.go b/app/controlplane/api/controlplane/v1/workflow.pb.go index fb7c605c9..ae14d9f2b 100644 --- a/app/controlplane/api/controlplane/v1/workflow.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow.pb.go @@ -102,8 +102,12 @@ type WorkflowServiceCreateRequest struct { ContractBytes []byte `protobuf:"bytes,4,opt,name=contract_bytes,json=contractBytes,proto3" json:"contract_bytes,omitempty"` Team string `protobuf:"bytes,5,opt,name=team,proto3" json:"team,omitempty"` Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional reference to the platform workflow template this workflow is created from. + // Not dereferenced nor validated for existence in the control plane, it's an opaque + // reference to a platform-owned entity. + WorkflowTemplateId string `protobuf:"bytes,8,opt,name=workflow_template_id,json=workflowTemplateId,proto3" json:"workflow_template_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkflowServiceCreateRequest) Reset() { @@ -178,6 +182,13 @@ func (x *WorkflowServiceCreateRequest) GetDescription() string { return "" } +func (x *WorkflowServiceCreateRequest) GetWorkflowTemplateId() string { + if x != nil { + return x.WorkflowTemplateId + } + return "" +} + type WorkflowServiceUpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -701,7 +712,7 @@ var File_controlplane_v1_workflow_proto protoreflect.FileDescriptor const file_controlplane_v1_workflow_proto_rawDesc = "" + "\n" + - "\x1econtrolplane/v1/workflow.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a'controlplane/v1/response_messages.proto\x1a\x1ejsonfilter/v1/jsonfilter.proto\x1a)workflowcontract/v1/crafting_schema.proto\"\xfe\x03\n" + + "\x1econtrolplane/v1/workflow.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a'controlplane/v1/response_messages.proto\x1a\x1ejsonfilter/v1/jsonfilter.proto\x1a)workflowcontract/v1/crafting_schema.proto\"\xbd\x04\n" + "\x1cWorkflowServiceCreateRequest\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\x12*\n" + @@ -710,7 +721,8 @@ const file_controlplane_v1_workflow_proto_rawDesc = "" + "\rname.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a/this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')\xd8\x01\x01R\fcontractName\x12%\n" + "\x0econtract_bytes\x18\x04 \x01(\fR\rcontractBytes\x12\x12\n" + "\x04team\x18\x05 \x01(\tR\x04team\x12 \n" + - "\vdescription\x18\x06 \x01(\tR\vdescriptionJ\x04\b\a\x10\bR\x06public\"\x8d\x04\n" + + "\vdescription\x18\x06 \x01(\tR\vdescription\x12=\n" + + "\x14workflow_template_id\x18\b \x01(\tB\v\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01R\x12workflowTemplateIdJ\x04\b\a\x10\bR\x06public\"\x8d\x04\n" + "\x1cWorkflowServiceUpdateRequest\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\x12*\n" + diff --git a/app/controlplane/api/controlplane/v1/workflow.proto b/app/controlplane/api/controlplane/v1/workflow.proto index 3fd94eace..73d2fc0c9 100644 --- a/app/controlplane/api/controlplane/v1/workflow.proto +++ b/app/controlplane/api/controlplane/v1/workflow.proto @@ -60,6 +60,14 @@ message WorkflowServiceCreateRequest { string description = 6; reserved 7; reserved "public"; + + // Optional reference to the platform workflow template this workflow is created from. + // Not dereferenced nor validated for existence in the control plane, it's an opaque + // reference to a platform-owned entity. + string workflow_template_id = 8 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; } message WorkflowServiceUpdateRequest { diff --git a/app/controlplane/api/controlplane/v1/workflow_test.go b/app/controlplane/api/controlplane/v1/workflow_test.go new file mode 100644 index 000000000..e93be3df6 --- /dev/null +++ b/app/controlplane/api/controlplane/v1/workflow_test.go @@ -0,0 +1,74 @@ +// +// 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 v1_test + +import ( + "testing" + + "buf.build/go/protovalidate" + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowServiceCreateRequestWorkflowTemplateID(t *testing.T) { + testCases := []struct { + desc string + templateID string + wantErr bool + }{ + { + desc: "empty is allowed, the template reference is optional", + templateID: "", + }, + { + desc: "a valid UUID is accepted", + templateID: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c", + }, + { + desc: "a non-UUID string is rejected", + templateID: "sast-scan", + wantErr: true, + }, + { + desc: "a malformed UUID is rejected", + templateID: "1b4c6f8a-2d3e-4f5a-8b9c", + wantErr: true, + }, + } + + validator, err := protovalidate.New() + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + req := &v1.WorkflowServiceCreateRequest{ + Name: "my-workflow", + ProjectName: "my-project", + WorkflowTemplateId: tc.templateID, + } + + err := validator.Validate(req) + if tc.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), "workflow_template_id") + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts index e23dc8273..63f7d9084 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts @@ -508,6 +508,11 @@ export interface WorkflowItem { */ public: boolean; description: string; + /** + * Optional reference to the platform workflow template this workflow was created from. + * Empty when the workflow is not bound to any template. + */ + workflowTemplateId: string; } export interface WorkflowRunItem { @@ -1041,6 +1046,7 @@ function createBaseWorkflowItem(): WorkflowItem { contractRevisionLatest: 0, public: false, description: "", + workflowTemplateId: "", }; } @@ -1082,6 +1088,9 @@ export const WorkflowItem = { if (message.description !== "") { writer.uint32(82).string(message.description); } + if (message.workflowTemplateId !== "") { + writer.uint32(106).string(message.workflowTemplateId); + } return writer; }, @@ -1176,6 +1185,13 @@ export const WorkflowItem = { message.description = reader.string(); continue; + case 13: + if (tag !== 106) { + break; + } + + message.workflowTemplateId = reader.string(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1199,6 +1215,7 @@ export const WorkflowItem = { contractRevisionLatest: isSet(object.contractRevisionLatest) ? Number(object.contractRevisionLatest) : 0, public: isSet(object.public) ? Boolean(object.public) : false, description: isSet(object.description) ? String(object.description) : "", + workflowTemplateId: isSet(object.workflowTemplateId) ? String(object.workflowTemplateId) : "", }; }, @@ -1218,6 +1235,7 @@ export const WorkflowItem = { (obj.contractRevisionLatest = Math.round(message.contractRevisionLatest)); message.public !== undefined && (obj.public = message.public); message.description !== undefined && (obj.description = message.description); + message.workflowTemplateId !== undefined && (obj.workflowTemplateId = message.workflowTemplateId); return obj; }, @@ -1241,6 +1259,7 @@ export const WorkflowItem = { message.contractRevisionLatest = object.contractRevisionLatest ?? 0; message.public = object.public ?? false; message.description = object.description ?? ""; + message.workflowTemplateId = object.workflowTemplateId ?? ""; return message; }, }; diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/workflow.ts b/app/controlplane/api/gen/frontend/controlplane/v1/workflow.ts index 213aa85a6..2a9d9dc2f 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow.ts @@ -68,6 +68,12 @@ export interface WorkflowServiceCreateRequest { contractBytes: Uint8Array; team: string; description: string; + /** + * Optional reference to the platform workflow template this workflow is created from. + * Not dereferenced nor validated for existence in the control plane, it's an opaque + * reference to a platform-owned entity. + */ + workflowTemplateId: string; } export interface WorkflowServiceUpdateRequest { @@ -134,7 +140,15 @@ export interface WorkflowServiceViewResponse { } function createBaseWorkflowServiceCreateRequest(): WorkflowServiceCreateRequest { - return { name: "", projectName: "", contractName: "", contractBytes: new Uint8Array(0), team: "", description: "" }; + return { + name: "", + projectName: "", + contractName: "", + contractBytes: new Uint8Array(0), + team: "", + description: "", + workflowTemplateId: "", + }; } export const WorkflowServiceCreateRequest = { @@ -157,6 +171,9 @@ export const WorkflowServiceCreateRequest = { if (message.description !== "") { writer.uint32(50).string(message.description); } + if (message.workflowTemplateId !== "") { + writer.uint32(66).string(message.workflowTemplateId); + } return writer; }, @@ -209,6 +226,13 @@ export const WorkflowServiceCreateRequest = { message.description = reader.string(); continue; + case 8: + if (tag !== 66) { + break; + } + + message.workflowTemplateId = reader.string(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -226,6 +250,7 @@ export const WorkflowServiceCreateRequest = { contractBytes: isSet(object.contractBytes) ? bytesFromBase64(object.contractBytes) : new Uint8Array(0), team: isSet(object.team) ? String(object.team) : "", description: isSet(object.description) ? String(object.description) : "", + workflowTemplateId: isSet(object.workflowTemplateId) ? String(object.workflowTemplateId) : "", }; }, @@ -240,6 +265,7 @@ export const WorkflowServiceCreateRequest = { )); message.team !== undefined && (obj.team = message.team); message.description !== undefined && (obj.description = message.description); + message.workflowTemplateId !== undefined && (obj.workflowTemplateId = message.workflowTemplateId); return obj; }, @@ -255,6 +281,7 @@ export const WorkflowServiceCreateRequest = { message.contractBytes = object.contractBytes ?? new Uint8Array(0); message.team = object.team ?? ""; message.description = object.description ?? ""; + message.workflowTemplateId = object.workflowTemplateId ?? ""; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.jsonschema.json index 4c82ec5c6..e03256492 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.jsonschema.json @@ -26,6 +26,10 @@ "maximum": 2147483647, "minimum": -2147483648, "type": "integer" + }, + "^(workflow_template_id)$": { + "description": "Optional reference to the platform workflow template this workflow was created from.\n Empty when the workflow is not bound to any template.", + "type": "string" } }, "properties": { @@ -71,6 +75,10 @@ }, "team": { "type": "string" + }, + "workflowTemplateId": { + "description": "Optional reference to the platform workflow template this workflow was created from.\n Empty when the workflow is not bound to any template.", + "type": "string" } }, "title": "Workflow Item", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.schema.json index da762dc32..42c33fcc3 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowItem.schema.json @@ -26,6 +26,10 @@ "maximum": 2147483647, "minimum": -2147483648, "type": "integer" + }, + "^(workflowTemplateId)$": { + "description": "Optional reference to the platform workflow template this workflow was created from.\n Empty when the workflow is not bound to any template.", + "type": "string" } }, "properties": { @@ -71,6 +75,10 @@ }, "team": { "type": "string" + }, + "workflow_template_id": { + "description": "Optional reference to the platform workflow template this workflow was created from.\n Empty when the workflow is not bound to any template.", + "type": "string" } }, "title": "Workflow Item", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.jsonschema.json index ff3857f7a..3b16f57ca 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.jsonschema.json @@ -15,6 +15,11 @@ "^(project_name)$": { "minLength": 1, "type": "string" + }, + "^(workflow_template_id)$": { + "description": "Optional reference to the platform workflow template this workflow is created from.\n Not dereferenced nor validated for existence in the control plane, it's an opaque\n reference to a platform-owned entity.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" } }, "properties": { @@ -39,6 +44,11 @@ }, "team": { "type": "string" + }, + "workflowTemplateId": { + "description": "Optional reference to the platform workflow template this workflow is created from.\n Not dereferenced nor validated for existence in the control plane, it's an opaque\n reference to a platform-owned entity.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" } }, "title": "Workflow Service Create Request", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.schema.json index 31339fca7..31b688825 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowServiceCreateRequest.schema.json @@ -15,6 +15,11 @@ "^(projectName)$": { "minLength": 1, "type": "string" + }, + "^(workflowTemplateId)$": { + "description": "Optional reference to the platform workflow template this workflow is created from.\n Not dereferenced nor validated for existence in the control plane, it's an opaque\n reference to a platform-owned entity.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" } }, "properties": { @@ -39,6 +44,11 @@ }, "team": { "type": "string" + }, + "workflow_template_id": { + "description": "Optional reference to the platform workflow template this workflow is created from.\n Not dereferenced nor validated for existence in the control plane, it's an opaque\n reference to a platform-owned entity.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" } }, "title": "Workflow Service Create Request", diff --git a/app/controlplane/internal/service/workflow.go b/app/controlplane/internal/service/workflow.go index 5ac837509..f77a10ed2 100644 --- a/app/controlplane/internal/service/workflow.go +++ b/app/controlplane/internal/service/workflow.go @@ -91,6 +91,17 @@ func (s *WorkflowService) Create(ctx context.Context, req *pb.WorkflowServiceCre OrgRestrictContractCreationToAdmins: org.RestrictContractCreationToOrgAdmins, } + // The template reference is an opaque pointer to a platform-owned entity: there is no + // workflow_template table in the control plane, so existence and ownership are not checked + // here. protovalidate already enforces the UUID shape, this parse is defence in depth. + if req.GetWorkflowTemplateId() != "" { + templateID, err := uuid.Parse(req.GetWorkflowTemplateId()) + if err != nil { + return nil, errors.BadRequest("invalid", "invalid workflow template ID") + } + createOpts.WorkflowTemplateID = &templateID + } + // add current user as the owner of the project in case it needs to be created user := entities.CurrentUser(ctx) if user != nil { @@ -308,6 +319,10 @@ func bizWorkflowToPb(wf *biz.Workflow) *pb.WorkflowItem { item.LastRun = bizWorkFlowRunToPb(wf.LastRun) } + if wf.WorkflowTemplateID != nil { + item.WorkflowTemplateId = wf.WorkflowTemplateID.String() + } + return item } diff --git a/app/controlplane/internal/service/workflow_test.go b/app/controlplane/internal/service/workflow_test.go new file mode 100644 index 000000000..f4a0738ce --- /dev/null +++ b/app/controlplane/internal/service/workflow_test.go @@ -0,0 +1,61 @@ +// +// 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 ( + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestBizWorkflowToPbWorkflowTemplateID(t *testing.T) { + createdAt := time.Now() + templateID := uuid.MustParse("1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c") + + testCases := []struct { + desc string + templateID *uuid.UUID + want string + }{ + { + desc: "unbound workflow maps to an empty template ID", + templateID: nil, + want: "", + }, + { + desc: "template-backed workflow maps to the template ID", + templateID: &templateID, + want: templateID.String(), + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + got := bizWorkflowToPb(&biz.Workflow{ + ID: uuid.New(), + Name: "my-workflow", + CreatedAt: &createdAt, + ProjectID: uuid.New(), + WorkflowTemplateID: tc.templateID, + }) + + assert.Equal(t, tc.want, got.GetWorkflowTemplateId()) + }) + } +} diff --git a/app/controlplane/pkg/biz/workflow_integration_test.go b/app/controlplane/pkg/biz/workflow_integration_test.go index 4b90568fa..2e2cbb684 100644 --- a/app/controlplane/pkg/biz/workflow_integration_test.go +++ b/app/controlplane/pkg/biz/workflow_integration_test.go @@ -218,6 +218,41 @@ func (s *workflowIntegrationTestSuite) TestCreate() { } } +// TestCreateWithWorkflowTemplate verifies that the platform workflow template reference given +// at creation time is persisted and read back. The reference is opaque: the control plane has no +// workflow_template table, so an arbitrary UUID is accepted and never dereferenced. +func (s *workflowIntegrationTestSuite) TestCreateWithWorkflowTemplate() { + ctx := context.Background() + templateID := uuid.New() + + s.Run("the template reference is persisted and read back", func() { + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, Name: "templated-workflow", Project: "templated-project", + WorkflowTemplateID: &templateID, + }) + require.NoError(s.T(), err) + s.Require().NotNil(wf.WorkflowTemplateID) + s.Equal(templateID, *wf.WorkflowTemplateID) + + found, err := s.Workflow.FindByID(ctx, wf.ID.String()) + require.NoError(s.T(), err) + s.Require().NotNil(found.WorkflowTemplateID) + s.Equal(templateID, *found.WorkflowTemplateID) + }) + + s.Run("it stays unset when no template is given", func() { + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, Name: "plain-workflow", Project: "templated-project", + }) + require.NoError(s.T(), err) + s.Nil(wf.WorkflowTemplateID) + + found, err := s.Workflow.FindByID(ctx, wf.ID.String()) + require.NoError(s.T(), err) + s.Nil(found.WorkflowTemplateID) + }) +} + // TestCreateWithProjectScopedContract verifies that a project-scoped contract can be used // when creating a workflow in the same project, and is rejected for different projects. func (s *workflowIntegrationTestSuite) TestCreateWithProjectScopedContract() {