Skip to content

Commit 27afd6b

Browse files
authored
feat(controlplane): accept a workflow template reference on workflow create (#3386)
1 parent 8336074 commit 27afd6b

17 files changed

Lines changed: 433 additions & 16 deletions

‎app/cli/pkg/action/workflow_create.go‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,27 @@ func NewWorkflowCreate(cfg *ActionsOpts) *WorkflowCreate {
3232
type NewWorkflowCreateOpts struct {
3333
Name, Description, Project, Team, ContractName string
3434
ContractBytes []byte
35+
// WorkflowTemplateID optionally binds the workflow to a platform workflow template.
36+
// The open-source CLI does not set it, it exists so template-aware clients can.
37+
WorkflowTemplateID string
3538
}
3639

3740
func (action *WorkflowCreate) Run(opts *NewWorkflowCreateOpts) (*WorkflowItem, error) {
3841
client := pb.NewWorkflowServiceClient(action.cfg.CPConnection)
39-
resp, err := client.Create(context.Background(), &pb.WorkflowServiceCreateRequest{
40-
Name: opts.Name, ProjectName: opts.Project, Team: opts.Team, ContractName: opts.ContractName,
41-
Description: opts.Description,
42-
ContractBytes: opts.ContractBytes,
43-
})
42+
resp, err := client.Create(context.Background(), newWorkflowCreateRequest(opts))
4443
if err != nil {
4544
return nil, err
4645
}
4746

4847
return pbWorkflowItemToAction(resp.Result), nil
4948
}
49+
50+
// newWorkflowCreateRequest maps the create options to the API request
51+
func newWorkflowCreateRequest(opts *NewWorkflowCreateOpts) *pb.WorkflowServiceCreateRequest {
52+
return &pb.WorkflowServiceCreateRequest{
53+
Name: opts.Name, ProjectName: opts.Project, Team: opts.Team, ContractName: opts.ContractName,
54+
Description: opts.Description,
55+
ContractBytes: opts.ContractBytes,
56+
WorkflowTemplateId: opts.WorkflowTemplateID,
57+
}
58+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package action
17+
18+
import (
19+
"testing"
20+
21+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
22+
"github.com/stretchr/testify/assert"
23+
"google.golang.org/protobuf/types/known/timestamppb"
24+
)
25+
26+
// TestNewWorkflowCreateOptsToRequest pins that the create options, including the optional
27+
// workflow template reference, are forwarded verbatim to the API request.
28+
func TestNewWorkflowCreateOptsToRequest(t *testing.T) {
29+
testCases := []struct {
30+
desc string
31+
opts *NewWorkflowCreateOpts
32+
}{
33+
{
34+
desc: "without a template reference",
35+
opts: &NewWorkflowCreateOpts{
36+
Name: "my-workflow",
37+
Project: "my-project",
38+
},
39+
},
40+
{
41+
desc: "with a template reference",
42+
opts: &NewWorkflowCreateOpts{
43+
Name: "my-workflow",
44+
Project: "my-project",
45+
Team: "my-team",
46+
Description: "a description",
47+
ContractName: "my-contract",
48+
ContractBytes: []byte("schemaVersion: v1"),
49+
WorkflowTemplateID: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c",
50+
},
51+
},
52+
}
53+
54+
for _, tc := range testCases {
55+
t.Run(tc.desc, func(t *testing.T) {
56+
got := newWorkflowCreateRequest(tc.opts)
57+
58+
assert.Equal(t, tc.opts.Name, got.GetName())
59+
assert.Equal(t, tc.opts.Project, got.GetProjectName())
60+
assert.Equal(t, tc.opts.Team, got.GetTeam())
61+
assert.Equal(t, tc.opts.Description, got.GetDescription())
62+
assert.Equal(t, tc.opts.ContractName, got.GetContractName())
63+
assert.Equal(t, tc.opts.ContractBytes, got.GetContractBytes())
64+
assert.Equal(t, tc.opts.WorkflowTemplateID, got.GetWorkflowTemplateId())
65+
})
66+
}
67+
}
68+
69+
func TestPbWorkflowItemToActionWorkflowTemplateID(t *testing.T) {
70+
testCases := []struct {
71+
desc string
72+
item *pb.WorkflowItem
73+
want string
74+
}{
75+
{
76+
desc: "unbound workflow leaves the template reference empty",
77+
item: &pb.WorkflowItem{
78+
Id: "wf-id",
79+
Name: "my-workflow",
80+
CreatedAt: timestamppb.Now(),
81+
},
82+
want: "",
83+
},
84+
{
85+
desc: "template-backed workflow carries the template reference",
86+
item: &pb.WorkflowItem{
87+
Id: "wf-id",
88+
Name: "my-workflow",
89+
CreatedAt: timestamppb.Now(),
90+
WorkflowTemplateId: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c",
91+
},
92+
want: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c",
93+
},
94+
}
95+
96+
for _, tc := range testCases {
97+
t.Run(tc.desc, func(t *testing.T) {
98+
got := pbWorkflowItemToAction(tc.item)
99+
100+
assert.Equal(t, tc.want, got.WorkflowTemplateID)
101+
})
102+
}
103+
}

‎app/cli/pkg/action/workflow_list.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ type WorkflowItem struct {
3838
ContractName string `json:"contractName,omitempty"`
3939
ContractRevisionLatest int32 `json:"contractRevisionLatest,omitempty"`
4040
LastRun *WorkflowRunItem `json:"lastRun,omitempty"`
41+
// WorkflowTemplateID is the platform workflow template this workflow is bound to,
42+
// empty when it is not bound to any
43+
WorkflowTemplateID string `json:"workflowTemplateId,omitempty"`
4144
}
4245

4346
// WorkflowListResult holds the output of the workflow list action
@@ -106,6 +109,7 @@ func pbWorkflowItemToAction(wf *pb.WorkflowItem) *WorkflowItem {
106109
ContractRevisionLatest: wf.ContractRevisionLatest,
107110
LastRun: pbWorkflowRunItemToAction(wf.LastRun),
108111
Description: wf.Description,
112+
WorkflowTemplateID: wf.WorkflowTemplateId,
109113
}
110114
}
111115

‎app/controlplane/api/controlplane/v1/response_messages.pb.go‎

Lines changed: 17 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/controlplane/api/controlplane/v1/response_messages.proto‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ message WorkflowItem {
4242
// is retained only for wire compatibility; it will be removed in a future release.
4343
bool public = 9 [deprecated = true];
4444
string description = 10;
45+
// Optional reference to the platform workflow template this workflow was created from.
46+
// Empty when the workflow is not bound to any template.
47+
string workflow_template_id = 13;
4548
}
4649

4750
message WorkflowRunItem {

‎app/controlplane/api/controlplane/v1/workflow.pb.go‎

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/controlplane/api/controlplane/v1/workflow.proto‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ message WorkflowServiceCreateRequest {
6060
string description = 6;
6161
reserved 7;
6262
reserved "public";
63+
64+
// Optional reference to the platform workflow template this workflow is created from.
65+
// Not dereferenced nor validated for existence in the control plane, it's an opaque
66+
// reference to a platform-owned entity.
67+
string workflow_template_id = 8 [
68+
(buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE,
69+
(buf.validate.field).string.uuid = true
70+
];
6371
}
6472

6573
message WorkflowServiceUpdateRequest {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package v1_test
17+
18+
import (
19+
"testing"
20+
21+
"buf.build/go/protovalidate"
22+
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestWorkflowServiceCreateRequestWorkflowTemplateID(t *testing.T) {
28+
testCases := []struct {
29+
desc string
30+
templateID string
31+
wantErr bool
32+
}{
33+
{
34+
desc: "empty is allowed, the template reference is optional",
35+
templateID: "",
36+
},
37+
{
38+
desc: "a valid UUID is accepted",
39+
templateID: "1b4c6f8a-2d3e-4f5a-8b9c-0d1e2f3a4b5c",
40+
},
41+
{
42+
desc: "a non-UUID string is rejected",
43+
templateID: "sast-scan",
44+
wantErr: true,
45+
},
46+
{
47+
desc: "a malformed UUID is rejected",
48+
templateID: "1b4c6f8a-2d3e-4f5a-8b9c",
49+
wantErr: true,
50+
},
51+
}
52+
53+
validator, err := protovalidate.New()
54+
require.NoError(t, err)
55+
56+
for _, tc := range testCases {
57+
t.Run(tc.desc, func(t *testing.T) {
58+
req := &v1.WorkflowServiceCreateRequest{
59+
Name: "my-workflow",
60+
ProjectName: "my-project",
61+
WorkflowTemplateId: tc.templateID,
62+
}
63+
64+
err := validator.Validate(req)
65+
if tc.wantErr {
66+
assert.Error(t, err)
67+
assert.Contains(t, err.Error(), "workflow_template_id")
68+
return
69+
}
70+
71+
assert.NoError(t, err)
72+
})
73+
}
74+
}

0 commit comments

Comments
 (0)