From fe118d1045f1d6eae438fc1d66e7559291242ac1 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 8 Jun 2026 22:52:25 +0200 Subject: [PATCH 1/2] feat(controlplane): allow filtering workflow runs by workflow and version Workflow and version filters were mutually exclusive on the workflow run list endpoint. They are independent dimensions and compose as an AND: a run carries both a workflow edge and a version_id column, so filtering by both returns the runs of a given workflow at a given version. The version is a globally-unique UUID, so it does not need a project to disambiguate it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- app/controlplane/pkg/biz/workflowrun.go | 8 ++++---- app/controlplane/pkg/biz/workflowrun_integration_test.go | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index b77afe329..d0d4f5cc0 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -476,10 +476,10 @@ func (uc *WorkflowRunUseCase) List(ctx context.Context, orgID string, f *RunList return nil, "", NewErrInvalidUUID(err) } - if f.WorkflowID != nil && f.VersionID != nil { - return nil, "", NewErrValidation(errors.New("cannot filter by workflow and version at the same time")) - } - + // Workflow and version filters are independent dimensions and compose as an + // AND: a run carries both a workflow edge and a version_id column, so + // "runs of workflow X at version Y" is a well-formed query. The version is + // a globally-unique UUID, so it doesn't need a project to disambiguate it. return uc.wfRunRepo.List(ctx, orgUUID, f, p) } diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 3b188dd6b..7111e8ead 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -85,9 +85,14 @@ func (s *workflowRunIntegrationTestSuite) TestList() { want: []*biz.WorkflowRun{finishedRun}, }, { - name: "can not filter by workflow and version", + name: "filter by workflow and version returns their intersection", + filters: &biz.RunListFilters{VersionID: &s.version1.ID, WorkflowID: &s.workflowOrg2.ID}, + want: []*biz.WorkflowRun{s.runOrg2}, + }, + { + name: "filter by workflow and version with no overlap returns nothing", filters: &biz.RunListFilters{VersionID: &s.version2.ID, WorkflowID: &s.workflowOrg2.ID}, - wantErr: true, + want: []*biz.WorkflowRun{}, }, { name: "filter by version no results", From 59144fb91a65f69b73257f6b70a680d6e7add520 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 8 Jun 2026 23:16:20 +0200 Subject: [PATCH 2/2] feat(cli): filter workflow runs by project version name Add a --version flag to `workflow workflow-run list` so runs can be filtered by project version. The flag takes a version name (e.g. v1.2.0) and requires --project, since a version name is unique only within a project, and composes with --workflow. On the API, WorkflowRunServiceListRequest.project_version (UUID) is deprecated in favor of project_version_name, resolved to a version within the given project. The dependency on project_name is enforced both at the API layer (CEL) and server-side. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 62f19a0d-f8a8-45d0-b208-c22bd4327f45 --- app/cli/cmd/workflow_workflow_run_list.go | 14 +++- .../cmd/workflow_workflow_run_list_test.go | 65 +++++++++++++++++++ app/cli/documentation/cli-reference.mdx | 1 + app/cli/pkg/action/workflow_run_list.go | 14 ++-- .../api/controlplane/v1/workflow_run.pb.go | 30 +++++++-- .../api/controlplane/v1/workflow_run.proto | 27 ++++++-- .../frontend/controlplane/v1/workflow_run.ts | 28 +++++++- ...kflowRunServiceListRequest.jsonschema.json | 12 +++- ....WorkflowRunServiceListRequest.schema.json | 12 +++- app/controlplane/cmd/wire_gen.go | 5 +- .../internal/service/workflowrun.go | 40 ++++++++++-- 11 files changed, 216 insertions(+), 32 deletions(-) create mode 100644 app/cli/cmd/workflow_workflow_run_list_test.go diff --git a/app/cli/cmd/workflow_workflow_run_list.go b/app/cli/cmd/workflow_workflow_run_list.go index 891edaade..153bbbef2 100644 --- a/app/cli/cmd/workflow_workflow_run_list.go +++ b/app/cli/cmd/workflow_workflow_run_list.go @@ -33,7 +33,7 @@ func newWorkflowWorkflowRunListCmd() *cobra.Command { DefaultLimit: 50, } - var workflowName, projectName, status, policyStatus string + var workflowName, projectName, projectVersion, status, policyStatus string cmd := &cobra.Command{ Use: "list", @@ -48,13 +48,20 @@ func newWorkflowWorkflowRunListCmd() *cobra.Command { return fmt.Errorf("invalid policy-status %q, please chose one of: all, failed, passed", policyStatus) } + // A version name is unique only within a project, so filtering by + // version requires the project to be set. + if projectVersion != "" && projectName == "" { + return fmt.Errorf("--project is required when --version is set") + } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { res, err := action.NewWorkflowRunList(ActionOpts).Run( &action.WorkflowRunListOpts{ - WorkflowName: workflowName, - ProjectName: projectName, + WorkflowName: workflowName, + ProjectName: projectName, + ProjectVersionName: projectVersion, Pagination: &action.PaginationOpts{ Limit: paginationOpts.Limit, NextCursor: paginationOpts.NextCursor, @@ -88,6 +95,7 @@ func newWorkflowWorkflowRunListCmd() *cobra.Command { cmd.Flags().StringVar(&workflowName, "workflow", "", "workflow name") cmd.Flags().StringVar(&projectName, "project", "", "project name") + cmd.Flags().StringVar(&projectVersion, "version", "", "project version name, e.g. v1.2.0 (requires --project)") cmd.Flags().BoolVar(&full, "full", false, "full report") cmd.Flags().StringVar(&status, "status", "", fmt.Sprintf("filter by workflow run status: %v", listAvailableWorkflowStatusFlag())) cmd.Flags().StringVar(&policyStatus, "policy-status", "", "filter by policy violations status: all, failed, passed") diff --git a/app/cli/cmd/workflow_workflow_run_list_test.go b/app/cli/cmd/workflow_workflow_run_list_test.go new file mode 100644 index 000000000..a9b5fb658 --- /dev/null +++ b/app/cli/cmd/workflow_workflow_run_list_test.go @@ -0,0 +1,65 @@ +// +// 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 cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowRunListPreRunValidation(t *testing.T) { + testCases := []struct { + name string + version string + project string + wantErr string + }{ + { + name: "version without project is rejected", + version: "v1.0.0", + project: "", + wantErr: "--project is required when --version is set", + }, + { + name: "version with project is allowed", + version: "v1.0.0", + project: "my-project", + }, + { + name: "no version is allowed without project", + version: "", + project: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := newWorkflowWorkflowRunListCmd() + require.NoError(t, cmd.Flags().Set("version", tc.version)) + require.NoError(t, cmd.Flags().Set("project", tc.project)) + + err := cmd.PreRunE(cmd, nil) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 729ee6da9..902c5dd2e 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -4033,6 +4033,7 @@ Options --policy-status string filter by policy violations status: all, failed, passed --project string project name --status string filter by workflow run status: [CANCELLED EXPIRED FAILED INITIALIZED SUCCEEDED] +--version string project version name, e.g. v1.2.0 (requires --project) --workflow string workflow name ``` diff --git a/app/cli/pkg/action/workflow_run_list.go b/app/cli/pkg/action/workflow_run_list.go index c00801a7b..009d88036 100644 --- a/app/cli/pkg/action/workflow_run_list.go +++ b/app/cli/pkg/action/workflow_run_list.go @@ -73,9 +73,12 @@ func NewWorkflowRunList(cfg *ActionsOpts) *WorkflowRunList { type WorkflowRunListOpts struct { WorkflowName, ProjectName string - Pagination *PaginationOpts - Status string - PolicyStatus string + // ProjectVersionName filters by project version name (e.g. v1.2.0). It requires + // ProjectName, since a version name is unique only within a project. + ProjectVersionName string + Pagination *PaginationOpts + Status string + PolicyStatus string } type PaginationOpts struct { Limit int @@ -85,8 +88,9 @@ type PaginationOpts struct { func (action *WorkflowRunList) Run(opts *WorkflowRunListOpts) (*PaginatedWorkflowRunItem, error) { client := pb.NewWorkflowRunServiceClient(action.cfg.CPConnection) req := &pb.WorkflowRunServiceListRequest{ - WorkflowName: opts.WorkflowName, - ProjectName: opts.ProjectName, + WorkflowName: opts.WorkflowName, + ProjectName: opts.ProjectName, + ProjectVersionName: opts.ProjectVersionName, Pagination: &pb.CursorPaginationRequest{ Limit: int32(opts.Pagination.Limit), Cursor: opts.Pagination.NextCursor, diff --git a/app/controlplane/api/controlplane/v1/workflow_run.pb.go b/app/controlplane/api/controlplane/v1/workflow_run.pb.go index c5d4fa2e1..2ac61bf50 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_run.pb.go @@ -961,8 +961,16 @@ type WorkflowRunServiceListRequest struct { ProjectName string `protobuf:"bytes,4,opt,name=project_name,json=projectName,proto3" json:"project_name,omitempty"` // by run status Status RunStatus `protobuf:"varint,3,opt,name=status,proto3,enum=controlplane.v1.RunStatus" json:"status,omitempty"` - // by project version + // by project version (UUID). + // Deprecated: use project_version_name together with project_name. A version + // name is unique only within a project, so filtering by name is the canonical + // and discoverable path; the UUID is kept for backward compatibility. + // + // Deprecated: Marked as deprecated in controlplane/v1/workflow_run.proto. ProjectVersion string `protobuf:"bytes,5,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` + // by project version name (e.g. v1.2.0). Requires project_name, since a + // version name is unique only within a project. + ProjectVersionName string `protobuf:"bytes,9,opt,name=project_version_name,json=projectVersionName,proto3" json:"project_version_name,omitempty"` // by policy violations status // Deprecated: use policy_status (PolicyStatusFilter), which aligns 1:1 with // the canonical PolicyStatus enum. When both are set, policy_status wins. @@ -1031,6 +1039,7 @@ func (x *WorkflowRunServiceListRequest) GetStatus() RunStatus { return RunStatus_RUN_STATUS_UNSPECIFIED } +// Deprecated: Marked as deprecated in controlplane/v1/workflow_run.proto. func (x *WorkflowRunServiceListRequest) GetProjectVersion() string { if x != nil { return x.ProjectVersion @@ -1038,6 +1047,13 @@ func (x *WorkflowRunServiceListRequest) GetProjectVersion() string { return "" } +func (x *WorkflowRunServiceListRequest) GetProjectVersionName() string { + if x != nil { + return x.ProjectVersionName + } + return "" +} + // Deprecated: Marked as deprecated in controlplane/v1/workflow_run.proto. func (x *WorkflowRunServiceListRequest) GetPolicyViolations() PolicyViolationsFilter { if x != nil { @@ -1850,20 +1866,22 @@ const file_controlplane_v1_workflow_run_proto_rawDesc = "" + "\x18TRIGGER_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" + "\x14TRIGGER_TYPE_FAILURE\x10\x01\x12\x1d\n" + "\x19TRIGGER_TYPE_CANCELLATION\x10\x02\"\"\n" + - " AttestationServiceCancelResponse\"\xa1\x06\n" + + " AttestationServiceCancelResponse\"\x83\b\n" + "\x1dWorkflowRunServiceListRequest\x12\xac\x01\n" + "\rworkflow_name\x18\x01 \x01(\tB\x86\x01\xbaH\x82\x01\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])?$')\xd8\x01\x01R\fworkflowName\x12!\n" + "\fproject_name\x18\x04 \x01(\tR\vprojectName\x122\n" + - "\x06status\x18\x03 \x01(\x0e2\x1a.controlplane.v1.RunStatusR\x06status\x124\n" + - "\x0fproject_version\x18\x05 \x01(\tB\v\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01R\x0eprojectVersion\x12X\n" + + "\x06status\x18\x03 \x01(\x0e2\x1a.controlplane.v1.RunStatusR\x06status\x126\n" + + "\x0fproject_version\x18\x05 \x01(\tB\r\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01\x18\x01R\x0eprojectVersion\x120\n" + + "\x14project_version_name\x18\t \x01(\tR\x12projectVersionName\x12X\n" + "\x11policy_violations\x18\x06 \x01(\x0e2'.controlplane.v1.PolicyViolationsFilterB\x02\x18\x01R\x10policyViolations\x12H\n" + "\rpolicy_status\x18\a \x01(\x0e2#.controlplane.v1.PolicyStatusFilterR\fpolicyStatus\x12E\n" + "\fpolicy_gates\x18\b \x01(\x0e2\".controlplane.v1.PolicyGatesFilterR\vpolicyGates\x12H\n" + "\n" + "pagination\x18\x02 \x01(\v2(.controlplane.v1.CursorPaginationRequestR\n" + - "pagination:\x8e\x01\xbaH\x8a\x01\x1a\x87\x01\n" + - "\x1bworkflow_project_dependency\x120project_name must be set if workflow_name is set\x1a6!(this.workflow_name != '' && this.project_name == '')\"\xa5\x01\n" + + "pagination:\xbc\x02\xbaH\xb8\x02\x1a\x87\x01\n" + + "\x1bworkflow_project_dependency\x120project_name must be set if workflow_name is set\x1a6!(this.workflow_name != '' && this.project_name == '')\x1a\xab\x01\n" + + "/list_project_version_name_requires_project_name\x129project_name must be set when project_version_name is set\x1a=!(this.project_version_name != '' && this.project_name == '')\"\xa5\x01\n" + "\x1eWorkflowRunServiceListResponse\x128\n" + "\x06result\x18\x01 \x03(\v2 .controlplane.v1.WorkflowRunItemR\x06result\x12I\n" + "\n" + diff --git a/app/controlplane/api/controlplane/v1/workflow_run.proto b/app/controlplane/api/controlplane/v1/workflow_run.proto index e02508e41..30708fd04 100644 --- a/app/controlplane/api/controlplane/v1/workflow_run.proto +++ b/app/controlplane/api/controlplane/v1/workflow_run.proto @@ -215,11 +215,20 @@ message WorkflowRunServiceListRequest { string project_name = 4; // by run status RunStatus status = 3; - // by project version - string project_version = 5 [(buf.validate.field) = { - string: {uuid: true} - ignore: IGNORE_IF_ZERO_VALUE - }]; + // by project version (UUID). + // Deprecated: use project_version_name together with project_name. A version + // name is unique only within a project, so filtering by name is the canonical + // and discoverable path; the UUID is kept for backward compatibility. + string project_version = 5 [ + deprecated = true, + (buf.validate.field) = { + string: {uuid: true} + ignore: IGNORE_IF_ZERO_VALUE + } + ]; + // by project version name (e.g. v1.2.0). Requires project_name, since a + // version name is unique only within a project. + string project_version_name = 9; // by policy violations status // Deprecated: use policy_status (PolicyStatusFilter), which aligns 1:1 with // the canonical PolicyStatus enum. When both are set, policy_status wins. @@ -238,6 +247,14 @@ message WorkflowRunServiceListRequest { expression: "!(this.workflow_name != '' && this.project_name == '')" message: "project_name must be set if workflow_name is set" }; + + // project_version_name requires project_name (a version name is unique only + // within a project). + option (buf.validate.message).cel = { + id: "list_project_version_name_requires_project_name" + expression: "!(this.project_version_name != '' && this.project_name == '')" + message: "project_name must be set when project_version_name is set" + }; } message WorkflowRunServiceListResponse { diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts index 08562204a..274f83ead 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_run.ts @@ -226,8 +226,20 @@ export interface WorkflowRunServiceListRequest { projectName: string; /** by run status */ status: RunStatus; - /** by project version */ + /** + * by project version (UUID). + * Deprecated: use project_version_name together with project_name. A version + * name is unique only within a project, so filtering by name is the canonical + * and discoverable path; the UUID is kept for backward compatibility. + * + * @deprecated + */ projectVersion: string; + /** + * by project version name (e.g. v1.2.0). Requires project_name, since a + * version name is unique only within a project. + */ + projectVersionName: string; /** * by policy violations status * Deprecated: use policy_status (PolicyStatusFilter), which aligns 1:1 with @@ -1919,6 +1931,7 @@ function createBaseWorkflowRunServiceListRequest(): WorkflowRunServiceListReques projectName: "", status: 0, projectVersion: "", + projectVersionName: "", policyViolations: 0, policyStatus: 0, policyGates: 0, @@ -1940,6 +1953,9 @@ export const WorkflowRunServiceListRequest = { if (message.projectVersion !== "") { writer.uint32(42).string(message.projectVersion); } + if (message.projectVersionName !== "") { + writer.uint32(74).string(message.projectVersionName); + } if (message.policyViolations !== 0) { writer.uint32(48).int32(message.policyViolations); } @@ -1990,6 +2006,13 @@ export const WorkflowRunServiceListRequest = { message.projectVersion = reader.string(); continue; + case 9: + if (tag !== 74) { + break; + } + + message.projectVersionName = reader.string(); + continue; case 6: if (tag !== 48) { break; @@ -2033,6 +2056,7 @@ export const WorkflowRunServiceListRequest = { projectName: isSet(object.projectName) ? String(object.projectName) : "", status: isSet(object.status) ? runStatusFromJSON(object.status) : 0, projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", + projectVersionName: isSet(object.projectVersionName) ? String(object.projectVersionName) : "", policyViolations: isSet(object.policyViolations) ? policyViolationsFilterFromJSON(object.policyViolations) : 0, policyStatus: isSet(object.policyStatus) ? policyStatusFilterFromJSON(object.policyStatus) : 0, policyGates: isSet(object.policyGates) ? policyGatesFilterFromJSON(object.policyGates) : 0, @@ -2046,6 +2070,7 @@ export const WorkflowRunServiceListRequest = { message.projectName !== undefined && (obj.projectName = message.projectName); message.status !== undefined && (obj.status = runStatusToJSON(message.status)); message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); + message.projectVersionName !== undefined && (obj.projectVersionName = message.projectVersionName); message.policyViolations !== undefined && (obj.policyViolations = policyViolationsFilterToJSON(message.policyViolations)); message.policyStatus !== undefined && (obj.policyStatus = policyStatusFilterToJSON(message.policyStatus)); @@ -2067,6 +2092,7 @@ export const WorkflowRunServiceListRequest = { message.projectName = object.projectName ?? ""; message.status = object.status ?? 0; message.projectVersion = object.projectVersion ?? ""; + message.projectVersionName = object.projectVersionName ?? ""; message.policyViolations = object.policyViolations ?? 0; message.policyStatus = object.policyStatus ?? 0; message.policyGates = object.policyGates ?? 0; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.jsonschema.json index fddb11cbc..6569c244c 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.jsonschema.json @@ -69,10 +69,14 @@ "type": "string" }, "^(project_version)$": { - "description": "by project version", + "description": "by project version (UUID).\n Deprecated: use project_version_name together with project_name. A version\n name is unique only within a project, so filtering by name is the canonical\n and discoverable path; the UUID is kept for backward compatibility.", "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" }, + "^(project_version_name)$": { + "description": "by project version name (e.g. v1.2.0). Requires project_name, since a\n version name is unique only within a project.", + "type": "string" + }, "^(workflow_name)$": { "description": "Filters\n by workflow", "type": "string" @@ -149,10 +153,14 @@ "type": "string" }, "projectVersion": { - "description": "by project version", + "description": "by project version (UUID).\n Deprecated: use project_version_name together with project_name. A version\n name is unique only within a project, so filtering by name is the canonical\n and discoverable path; the UUID is kept for backward compatibility.", "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" }, + "projectVersionName": { + "description": "by project version name (e.g. v1.2.0). Requires project_name, since a\n version name is unique only within a project.", + "type": "string" + }, "status": { "anyOf": [ { diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.schema.json index 085a1971e..384217d0e 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowRunServiceListRequest.schema.json @@ -69,10 +69,14 @@ "type": "string" }, "^(projectVersion)$": { - "description": "by project version", + "description": "by project version (UUID).\n Deprecated: use project_version_name together with project_name. A version\n name is unique only within a project, so filtering by name is the canonical\n and discoverable path; the UUID is kept for backward compatibility.", "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" }, + "^(projectVersionName)$": { + "description": "by project version name (e.g. v1.2.0). Requires project_name, since a\n version name is unique only within a project.", + "type": "string" + }, "^(workflowName)$": { "description": "Filters\n by workflow", "type": "string" @@ -149,10 +153,14 @@ "type": "string" }, "project_version": { - "description": "by project version", + "description": "by project version (UUID).\n Deprecated: use project_version_name together with project_name. A version\n name is unique only within a project, so filtering by name is the canonical\n and discoverable path; the UUID is kept for backward compatibility.", "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" }, + "project_version_name": { + "description": "by project version name (e.g. v1.2.0). Requires project_name, since a\n version name is unique only within a project.", + "type": "string" + }, "status": { "anyOf": [ { diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 9ebb39ec8..e321b40d9 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -234,6 +234,8 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr cleanup() return nil, nil, err } + projectVersionRepo := data.NewProjectVersionRepo(dataData, logger) + projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, logger) policyevalbundleCache, err := policyevalbundle.New(contextContext, reloadableConnection, logger) if err != nil { cleanup3() @@ -246,6 +248,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr WorkflowUC: workflowUseCase, WorkflowContractUC: workflowContractUseCase, ProjectUC: projectUseCase, + ProjectVersionUC: projectVersionUseCase, CredsReader: readerWriter, CASClient: casClientUseCase, CASMappingUC: casMappingUseCase, @@ -265,8 +268,6 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr return nil, nil, err } prometheusUseCase := biz.NewPrometheusUseCase(v6, organizationUseCase, orgMetricsUseCase, logger) - projectVersionRepo := data.NewProjectVersionRepo(dataData, logger) - projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, logger) newAttestationServiceOpts := &service.NewAttestationServiceOpts{ WorkflowRunUC: workflowRunUseCase, WorkflowUC: workflowUseCase, diff --git a/app/controlplane/internal/service/workflowrun.go b/app/controlplane/internal/service/workflowrun.go index 768172349..1f9bf174b 100644 --- a/app/controlplane/internal/service/workflowrun.go +++ b/app/controlplane/internal/service/workflowrun.go @@ -43,6 +43,7 @@ type WorkflowRunService struct { workflowUseCase *biz.WorkflowUseCase workflowContractUseCase *biz.WorkflowContractUseCase projectUseCase *biz.ProjectUseCase + projectVersionUseCase *biz.ProjectVersionUseCase credsReader credentials.Reader casClient biz.CASClient casMappingUC *biz.CASMappingUseCase @@ -54,6 +55,7 @@ type NewWorkflowRunServiceOpts struct { WorkflowUC *biz.WorkflowUseCase WorkflowContractUC *biz.WorkflowContractUseCase ProjectUC *biz.ProjectUseCase + ProjectVersionUC *biz.ProjectVersionUseCase CredsReader credentials.Reader CASClient biz.CASClient CASMappingUC *biz.CASMappingUseCase @@ -68,6 +70,7 @@ func NewWorkflowRunService(opts *NewWorkflowRunServiceOpts) *WorkflowRunService workflowUseCase: opts.WorkflowUC, workflowContractUseCase: opts.WorkflowContractUC, projectUseCase: opts.ProjectUC, + projectVersionUseCase: opts.ProjectVersionUC, credsReader: opts.CredsReader, casClient: opts.CASClient, casMappingUC: opts.CASMappingUC, @@ -132,6 +135,9 @@ func (s *WorkflowRunService) List(ctx context.Context, req *pb.WorkflowRunServic visibleProjectIDs := s.visibleProjects(ctx) filters.ProjectIDs = visibleProjectIDs + // Track the resolved project so a project version can be looked up by name. + var projectID *uuid.UUID + // by workflow and project name if req.GetWorkflowName() != "" && req.GetProjectName() != "" { wf, err := s.workflowUseCase.FindByNameInOrg(ctx, currentOrg.ID, req.GetProjectName(), req.GetWorkflowName()) @@ -142,24 +148,46 @@ func (s *WorkflowRunService) List(ctx context.Context, req *pb.WorkflowRunServic } filters.WorkflowID = &wf.ID + projectID = &wf.ProjectID } else if req.GetProjectName() != "" { // by project name only - projectID, err := s.validateAndGetProjectID(ctx, currentOrg.ID, req.GetProjectName(), visibleProjectIDs) + pID, err := s.validateAndGetProjectID(ctx, currentOrg.ID, req.GetProjectName(), visibleProjectIDs) if err != nil { return nil, handleUseCaseErr(err, s.log) } // Override the filter to only include this specific project - filters.ProjectIDs = []uuid.UUID{projectID} + filters.ProjectIDs = []uuid.UUID{pID} + projectID = &pID } - if req.GetProjectVersion() != "" { - projectUUID, err := uuid.Parse(req.GetProjectVersion()) + // by project version + switch { + case req.GetProjectVersionName() != "": + // A version name is unique only within a project, so project_name is + // required. Enforced at the API layer (CEL) and re-checked here. + if projectID == nil { + return nil, errors.BadRequest("invalid", "project_name must be set when project_version_name is set") + } + + pv, err := s.projectVersionUseCase.FindByProjectAndVersion(ctx, projectID.String(), req.GetProjectVersionName()) if err != nil { - return nil, errors.BadRequest("invalid", "invalid project version") + return nil, handleUseCaseErr(err, s.log) } - filters.VersionID = &projectUUID + filters.VersionID = &pv.ID + default: + // Deprecated: honor the project_version UUID for clients that have not + // migrated to project_version_name. + //nolint:staticcheck // honoring the deprecated field for older clients + if rawVersion := req.GetProjectVersion(); rawVersion != "" { + projectUUID, err := uuid.Parse(rawVersion) + if err != nil { + return nil, errors.BadRequest("invalid", "invalid project version") + } + + filters.VersionID = &projectUUID + } } // by run status