diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 350656274..4e2151e9e 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -224,7 +224,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr return nil, nil, err } projectVersionRepo := data.NewProjectVersionRepo(dataData, logger) - projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, logger) + projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, auditorUseCase, logger) policyevalbundleCache, err := policyevalbundle.New(contextContext, reloadableConnection, logger) if err != nil { cleanup3() diff --git a/app/controlplane/pkg/auditor/events/project.go b/app/controlplane/pkg/auditor/events/project.go index 01338a085..5ab5c0848 100644 --- a/app/controlplane/pkg/auditor/events/project.go +++ b/app/controlplane/pkg/auditor/events/project.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-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. @@ -164,6 +164,10 @@ type ProjectVersionUpdated struct { VersionID *uuid.UUID `json:"version_id,omitempty"` Version string `json:"version,omitempty"` NewVersion *string `json:"new_version,omitempty"` + // MarkedAsLatest reports that the update promoted this version to be the + // project's latest one. Always emitted, like Prerelease on the sibling + // events, so consumers can tell "not a promotion" from "older producer". + MarkedAsLatest bool `json:"marked_as_latest"` } func (p *ProjectVersionUpdated) ActionType() string { @@ -183,15 +187,17 @@ func (p *ProjectVersionUpdated) ActionInfo() (json.RawMessage, error) { } func (p *ProjectVersionUpdated) Description() string { - desc := fmt.Sprintf("%s has updated version '%s' for project '%s'", - auditor.GetActorIdentifier(), p.Version, p.ProjectName) - - if p.NewVersion != nil { - desc = fmt.Sprintf("%s has renamed version '%s' to '%s' for project '%s'", + switch { + case p.NewVersion != nil: + return fmt.Sprintf("%s has renamed version '%s' to '%s' for project '%s'", auditor.GetActorIdentifier(), p.Version, *p.NewVersion, p.ProjectName) + case p.MarkedAsLatest: + return fmt.Sprintf("%s has promoted version '%s' to latest for project '%s'", + auditor.GetActorIdentifier(), p.Version, p.ProjectName) + default: + return fmt.Sprintf("%s has updated version '%s' for project '%s'", + auditor.GetActorIdentifier(), p.Version, p.ProjectName) } - - return desc } // Helper function to make role names more user-friendly diff --git a/app/controlplane/pkg/auditor/events/project_test.go b/app/controlplane/pkg/auditor/events/project_test.go index 4527e4b1b..9bcf69f95 100644 --- a/app/controlplane/pkg/auditor/events/project_test.go +++ b/app/controlplane/pkg/auditor/events/project_test.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-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. @@ -93,6 +93,35 @@ func TestProjectEvents(t *testing.T) { actor: auditor.ActorTypeUser, actorID: userUUID, }, + { + name: "ProjectVersionUpdated without a specific change", + event: &events.ProjectVersionUpdated{ + ProjectBase: &events.ProjectBase{ + ProjectID: &projectUUID, + ProjectName: projectName, + }, + VersionID: &versionUUID, + Version: "v1.0.0", + }, + expected: "testdata/projects/project_version_updated_generic.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "ProjectVersionUpdated marked as latest", + event: &events.ProjectVersionUpdated{ + ProjectBase: &events.ProjectBase{ + ProjectID: &projectUUID, + ProjectName: projectName, + }, + VersionID: &versionUUID, + Version: "v1.0.0", + MarkedAsLatest: true, + }, + expected: "testdata/projects/project_version_marked_as_latest.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, { name: "ProjectMembershipAdded", event: &events.ProjectMembershipAdded{ diff --git a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json new file mode 100644 index 000000000..242f1cede --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json @@ -0,0 +1,19 @@ +{ + "ActionType": "ProjectVersionUpdated", + "TargetType": "Project", + "TargetID": "3089bb36-e27b-428b-8009-d015c8737c56", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "ActorName": "John Connor", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "John Connor has promoted version 'v1.0.0' to latest for project 'test-project'", + "Info": { + "project_id": "3089bb36-e27b-428b-8009-d015c8737c56", + "project_name": "test-project", + "version_id": "5089bb36-e27b-428b-8009-d015c8737c58", + "version": "v1.0.0", + "marked_as_latest": true + }, + "Digest": "sha256:02de4db635507f73918e0a5ad07039e3da666d323ab2de72bd106160a2bc7dca" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json index e0d5bb454..c973bbec9 100644 --- a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json +++ b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json @@ -13,7 +13,8 @@ "project_name": "test-project", "version_id": "5089bb36-e27b-428b-8009-d015c8737c58", "version": "v1.0.0", - "new_version": "v1.0.1" + "new_version": "v1.0.1", + "marked_as_latest": false }, - "Digest": "sha256:f86470cccd6d88b274433350b0ff3958b9f081a12bffcbcd20b6648d28182c1f" + "Digest": "sha256:3d2d27245dbe6e2629eb88259c56194e0a0c81820fa96e3391b316a511fe18c1" } \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json new file mode 100644 index 000000000..44cf7ea90 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json @@ -0,0 +1,19 @@ +{ + "ActionType": "ProjectVersionUpdated", + "TargetType": "Project", + "TargetID": "3089bb36-e27b-428b-8009-d015c8737c56", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "ActorName": "John Connor", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "John Connor has updated version 'v1.0.0' for project 'test-project'", + "Info": { + "project_id": "3089bb36-e27b-428b-8009-d015c8737c56", + "project_name": "test-project", + "version_id": "5089bb36-e27b-428b-8009-d015c8737c58", + "version": "v1.0.0", + "marked_as_latest": false + }, + "Digest": "sha256:2d922b6b3e952d19a82db0af8d72d8a12eed1afdf19d7eddd3be7c069b241597" +} \ No newline at end of file diff --git a/app/controlplane/pkg/biz/auditor.go b/app/controlplane/pkg/biz/auditor.go index 60f254ae8..831c3f7ca 100644 --- a/app/controlplane/pkg/biz/auditor.go +++ b/app/controlplane/pkg/biz/auditor.go @@ -34,17 +34,25 @@ type AuditorUseCase struct { dispatcher *auditor.Dispatcher } +// NewAuditorUseCase builds an AuditorUseCase from the NATS-backed publisher. +// It takes the concrete type because the publisher is nil when auditing is +// disabled, and a nil pointer assigned straight to an interface would leave the +// dispatcher holding a typed-nil that reports itself as enabled. func NewAuditorUseCase(p *auditor.AuditLogPublisher, logger log.Logger) *AuditorUseCase { - // keep the Publisher interface nil when the publisher is disabled so the - // dispatcher short-circuits instead of holding a typed-nil interface var publisher auditor.Publisher if p != nil { publisher = p } + return newAuditorUseCase(publisher, logger) +} + +// newAuditorUseCase builds an AuditorUseCase over any publisher. A nil +// publisher makes dispatching a no-op. +func newAuditorUseCase(p auditor.Publisher, logger log.Logger) *AuditorUseCase { return &AuditorUseCase{ log: log.NewHelper(log.With(logger, "component", "biz/auditor")), - dispatcher: auditor.NewDispatcher(publisher, logger), + dispatcher: auditor.NewDispatcher(p, logger), } } diff --git a/app/controlplane/pkg/biz/auditor_test.go b/app/controlplane/pkg/biz/auditor_test.go new file mode 100644 index 000000000..3590c40bc --- /dev/null +++ b/app/controlplane/pkg/biz/auditor_test.go @@ -0,0 +1,82 @@ +// +// 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 biz + +import ( + "context" + "encoding/json" + "io" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingPublisher captures the audit events a use case dispatches so tests +// can assert on them. The production publisher is NATS-backed, so this is the +// only way to observe dispatches without a broker. +type recordingPublisher struct { + published []*auditor.EventPayload +} + +func (p *recordingPublisher) Publish(data *auditor.EventPayload) error { + p.published = append(p.published, data) + return nil +} + +// assertSingleProjectVersionEvent checks that exactly one event was recorded, +// that it reports the given action for the given project, and that its payload +// describes the given version. wantMarkedAsLatest pins whether the event +// announces a promotion. +func (p *recordingPublisher) assertSingleProjectVersionEvent( + t *testing.T, wantAction string, project *Project, version *ProjectVersion, wantMarkedAsLatest bool, +) { + t.Helper() + + require.Len(t, p.published, 1) + got := p.published[0].Data + assert.Equal(t, wantAction, got.ActionType) + assert.Equal(t, &project.ID, got.TargetID) + assert.Equal(t, &project.OrgID, got.OrgID) + + var info struct { + VersionID *uuid.UUID `json:"version_id"` + Version string `json:"version"` + MarkedAsLatest bool `json:"marked_as_latest"` + } + require.NoError(t, json.Unmarshal(got.Info, &info)) + assert.Equal(t, &version.ID, info.VersionID) + assert.Equal(t, version.Version, info.Version) + assert.Equal(t, wantMarkedAsLatest, info.MarkedAsLatest) +} + +// newRecordingAuditor builds an AuditorUseCase backed by a recordingPublisher, +// through the same constructor production uses so the two cannot drift. +func newRecordingAuditor() (*AuditorUseCase, *recordingPublisher) { + publisher := &recordingPublisher{} + + return newAuditorUseCase(publisher, log.NewStdLogger(io.Discard)), publisher +} + +// ctxWithAPITokenActor returns a context carrying an actor, required by the +// audit entries that report on project resources. +func ctxWithAPITokenActor(ctx context.Context) context.Context { + return entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), Name: "test-token"}) +} diff --git a/app/controlplane/pkg/biz/projectversion.go b/app/controlplane/pkg/biz/projectversion.go index 21b3e90ec..017402ec9 100644 --- a/app/controlplane/pkg/biz/projectversion.go +++ b/app/controlplane/pkg/biz/projectversion.go @@ -20,6 +20,7 @@ import ( "io" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" "github.com/go-kratos/kratos/v2/log" @@ -51,24 +52,72 @@ type ProjectVersion struct { ProjectID uuid.UUID } +// ProjectVersionPromotion is the outcome of promoting a project version to be +// the latest one. +type ProjectVersionPromotion struct { + // Promoted reports whether the promotion changed which version is the + // latest one. It is false when the version already was the latest. + Promoted bool + // Version and Project describe the promotion for auditing purposes. Both are + // populated whenever the promotion succeeded, so that a committed promotion + // can always be reported. + Version *ProjectVersion + Project *Project +} + +// dispatchProjectVersionPromoted reports that a project version became the +// latest one for its project. Every path that promotes a version funnels +// through here: downstream consumers reconcile off this event, so a promotion +// must be as loud as a creation, otherwise anything tracking the latest version +// of the project silently falls behind. +// +// A promotion deliberately reuses ProjectVersionUpdated rather than declaring +// its own action type, and that choice is load-bearing. Subjects are published +// as "audit..", and consumers subscribe to a fixed +// list of them, so a new action type lands on a subject nobody is listening to +// and every promotion is dropped — the very failure this event exists to +// prevent, but silent. If this action type ever changes, the consumers must +// subscribe to the new subject and be released FIRST; MarkedAsLatest is what +// lets a consumer tell a promotion from a rename in the meantime. +func dispatchProjectVersionPromoted(ctx context.Context, auditorUC *AuditorUseCase, project *Project, version *ProjectVersion) { + if auditorUC == nil || project == nil || version == nil { + return + } + + auditorUC.Dispatch(ctx, &events.ProjectVersionUpdated{ + ProjectBase: &events.ProjectBase{ + ProjectID: &project.ID, + ProjectName: project.Name, + }, + VersionID: &version.ID, + Version: version.Version, + MarkedAsLatest: true, + }, &project.OrgID) +} + type ProjectVersionRepo interface { FindByProjectAndVersion(ctx context.Context, projectID uuid.UUID, version string) (*ProjectVersion, error) Update(ctx context.Context, versionID uuid.UUID, updates *ProjectVersionUpdateOpts) (*ProjectVersion, error) Create(ctx context.Context, projectID uuid.UUID, version string, prerelease bool) (*ProjectVersion, error) - MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error + MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) (*ProjectVersionPromotion, error) } type ProjectVersionUseCase struct { projectRepo ProjectVersionRepo + auditorUC *AuditorUseCase logger *log.Helper } -func NewProjectVersionUseCase(repo ProjectVersionRepo, l log.Logger) *ProjectVersionUseCase { +func NewProjectVersionUseCase(repo ProjectVersionRepo, auditorUC *AuditorUseCase, l log.Logger) *ProjectVersionUseCase { if l == nil { l = log.NewStdLogger(io.Discard) } - return &ProjectVersionUseCase{projectRepo: repo, logger: servicelogger.ScopedHelper(l, "biz/project-version")} + return &ProjectVersionUseCase{ + projectRepo: repo, + auditorUC: auditorUC, + logger: servicelogger.ScopedHelper(l, "biz/project-version"), + } } func (uc *ProjectVersionUseCase) FindByProjectAndVersion(ctx context.Context, projectID string, version string) (*ProjectVersion, error) { @@ -100,8 +149,13 @@ func (uc *ProjectVersionUseCase) UpdateReleaseStatus(ctx context.Context, versio return uc.projectRepo.Update(ctx, versionUUID, &ProjectVersionUpdateOpts{Prerelease: &preReleaseValue}) } -// MarkAsLatest promotes a pre-release version to latest. The platform repo builds the -// "project version mark-latest" CLI command and service endpoint on top of this method. +// MarkAsLatest promotes a pre-release version to latest. +// +// Nothing calls this today: no service in this repository uses it, and the +// platform implements its own mark-latest over its own repositories rather than +// this use case. It is kept as the biz-layer entry point for the operation, and +// it dispatches the promotion event so that a future caller cannot reintroduce +// the silent-transition gap this method used to have. func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, versionID string) error { ctx, span := otelx.Start(ctx, projectVersionTracer, "ProjectVersionUseCase.MarkAsLatest") defer span.End() @@ -116,7 +170,16 @@ func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, ve return NewErrInvalidUUID(err) } - return uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID) + promotion, err := uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID) + if err != nil { + return err + } + + if promotion.Promoted { + dispatchProjectVersionPromoted(ctx, uc.auditorUC, promotion.Project, promotion.Version) + } + + return nil } func (uc *ProjectVersionUseCase) Create(ctx context.Context, projectID, version string, prerelease bool) (*ProjectVersion, error) { diff --git a/app/controlplane/pkg/biz/projectversion_audit_test.go b/app/controlplane/pkg/biz/projectversion_audit_test.go new file mode 100644 index 000000000..a3ceb8a35 --- /dev/null +++ b/app/controlplane/pkg/biz/projectversion_audit_test.go @@ -0,0 +1,80 @@ +// +// 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 biz + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// stubProjectVersionRepo returns a canned promotion outcome. Only MarkAsLatest +// is exercised; any other call panics on the nil embedded interface. +type stubProjectVersionRepo struct { + ProjectVersionRepo + promotion *ProjectVersionPromotion +} + +func (s *stubProjectVersionRepo) MarkAsLatest(_ context.Context, _, _ uuid.UUID) (*ProjectVersionPromotion, error) { + return s.promotion, nil +} + +// Promoting a version out of band must be as loud as promoting it through an +// attestation, so that whatever tracks the latest version of a project follows. +func TestMarkAsLatestDispatchesProjectVersionUpdated(t *testing.T) { + project := &Project{ID: uuid.New(), Name: "test-project", OrgID: uuid.New()} + version := &ProjectVersion{ID: uuid.New(), Version: "v1.0.0", Prerelease: true, Latest: true} + + testCases := []struct { + name string + promoted bool + wantAction string // empty means nothing should be dispatched + }{ + { + name: "version promoted", + promoted: true, + wantAction: events.ProjectVersionUpdatedActionType, + }, + { + name: "version already was the latest", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + auditorUC, publisher := newRecordingAuditor() + repo := &stubProjectVersionRepo{promotion: &ProjectVersionPromotion{ + Promoted: tc.promoted, + Version: version, + Project: project, + }} + + uc := NewProjectVersionUseCase(repo, auditorUC, nil) + err := uc.MarkAsLatest(ctxWithAPITokenActor(context.Background()), project.ID.String(), version.ID.String()) + require.NoError(t, err) + + if tc.wantAction == "" { + require.Empty(t, publisher.published) + return + } + + publisher.assertSingleProjectVersionEvent(t, tc.wantAction, project, version, true) + }) + } +} diff --git a/app/controlplane/pkg/biz/projectversion_integration_test.go b/app/controlplane/pkg/biz/projectversion_integration_test.go index 6a9fcec77..8910d7742 100644 --- a/app/controlplane/pkg/biz/projectversion_integration_test.go +++ b/app/controlplane/pkg/biz/projectversion_integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -146,6 +147,33 @@ func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatest() { require.False(t, v2Demoted.Latest) } +// The repository is what knows whether the promotion actually moved the latest +// pointer, so it reports it back for the use case to audit. +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestReportsPromotion() { + t := s.T() + ctx := context.Background() + repo := data.NewProjectVersionRepo(s.Data, s.L) + + v1, err := s.ProjectVersion.Create(ctx, s.project.ID.String(), "1.0.0", true) + require.NoError(t, err) + _, err = s.ProjectVersion.Create(ctx, s.project.ID.String(), "2.0.0", true) + require.NoError(t, err) + + promotion, err := repo.MarkAsLatest(ctx, s.project.ID, v1.ID) + require.NoError(t, err) + require.True(t, promotion.Promoted) + require.Equal(t, v1.ID, promotion.Version.ID) + require.Equal(t, "1.0.0", promotion.Version.Version) + require.True(t, promotion.Version.Latest) + require.Equal(t, s.project.ID, promotion.Project.ID) + require.Equal(t, s.org.ID, promotion.Project.OrgID.String()) + + // Promoting the version that already is the latest changes nothing + promotion, err = repo.MarkAsLatest(ctx, s.project.ID, v1.ID) + require.NoError(t, err) + require.False(t, promotion.Promoted) +} + func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestReleasedVersionError() { t := s.T() ctx := context.Background() diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 187fea5ee..7b0d68699 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -166,7 +166,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t return nil, nil, err } projectVersionRepo := data.NewProjectVersionRepo(dataData, logger) - projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, logger) + projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, auditorUseCase, logger) groupUseCase := biz.NewGroupUseCase(logger, groupRepo, membershipRepo, userRepo, orgInvitationUseCase, auditorUseCase, orgInvitationRepo, authzUseCase, membershipUseCase) projectUseCase := biz.NewProjectsUseCase(logger, projectsRepo, membershipRepo, auditorUseCase, groupUseCase, membershipUseCase, orgInvitationUseCase, orgInvitationRepo, authzUseCase) testingRepos := &TestingRepos{ diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 86fcde717..8b6dea686 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -88,9 +88,13 @@ const ( ) type WorkflowRunRepoCreateResult struct { - Run *WorkflowRun - Project *Project + Run *WorkflowRun + Project *Project + // VersionCreated reports that the run created the project version. VersionCreated bool + // VersionPromoted reports that the run promoted an already existing project + // version to be the latest one. Mutually exclusive with VersionCreated. + VersionPromoted bool } type WorkflowRunRepo interface { @@ -323,8 +327,9 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat return nil, err } - // Dispatch audit event for project version creation if a new version was created - if result.VersionCreated && uc.auditorUC != nil && result.Project != nil { + // Report the project version transition this run caused, if any. + switch { + case result.VersionCreated && uc.auditorUC != nil && result.Project != nil: uc.auditorUC.Dispatch(ctx, &events.ProjectVersionCreated{ ProjectBase: &events.ProjectBase{ ProjectID: &result.Project.ID, @@ -334,6 +339,8 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat Version: result.Run.ProjectVersion.Version, Prerelease: result.Run.ProjectVersion.Prerelease, }, &result.Project.OrgID) + case result.VersionPromoted: + dispatchProjectVersionPromoted(ctx, uc.auditorUC, result.Project, result.Run.ProjectVersion) } return result.Run, nil diff --git a/app/controlplane/pkg/biz/workflowrun_audit_test.go b/app/controlplane/pkg/biz/workflowrun_audit_test.go new file mode 100644 index 000000000..d581aef12 --- /dev/null +++ b/app/controlplane/pkg/biz/workflowrun_audit_test.go @@ -0,0 +1,100 @@ +// +// 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 biz + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// stubWorkflowRunRepo returns a canned creation result. Only Create is +// exercised; any other call panics on the nil embedded interface. +type stubWorkflowRunRepo struct { + WorkflowRunRepo + result *WorkflowRunRepoCreateResult +} + +func (s *stubWorkflowRunRepo) Create(_ context.Context, _ *WorkflowRunRepoCreateOpts) (*WorkflowRunRepoCreateResult, error) { + return s.result, nil +} + +// A project version only reaches "latest" through a creation or a promotion, +// and the products that track the latest version of a project reconcile off +// these audit events, so both transitions must be reported. +func TestWorkflowRunCreateDispatchesProjectVersionEvents(t *testing.T) { + project := &Project{ID: uuid.New(), Name: "test-project", OrgID: uuid.New()} + version := &ProjectVersion{ID: uuid.New(), Version: "v1.0.0", Prerelease: true, Latest: true} + + testCases := []struct { + name string + versionCreated bool + versionPromoted bool + wantAction string // empty means nothing should be dispatched + wantMarkedAsLatest bool + }{ + { + name: "version created", + versionCreated: true, + wantAction: events.ProjectVersionCreatedActionType, + }, + { + name: "existing version promoted to latest", + versionPromoted: true, + wantAction: events.ProjectVersionUpdatedActionType, + wantMarkedAsLatest: true, + }, + { + name: "version neither created nor promoted", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + auditorUC, publisher := newRecordingAuditor() + repo := &stubWorkflowRunRepo{result: &WorkflowRunRepoCreateResult{ + Project: project, + Run: &WorkflowRun{ID: uuid.New(), ProjectVersion: version}, + VersionCreated: tc.versionCreated, + VersionPromoted: tc.versionPromoted, + }} + + uc, err := NewWorkflowRunUseCase(&WorkflowRunUseCaseOpts{WfrRepo: repo, AuditorUC: auditorUC}) + require.NoError(t, err) + + _, err = uc.Create(ctxWithAPITokenActor(context.Background()), &WorkflowRunCreateOpts{ + WorkflowID: uuid.NewString(), + CASBackendID: uuid.New(), + ContractRevision: &WorkflowContractWithVersion{ + Contract: &WorkflowContract{LatestRevision: 1}, + Version: &WorkflowContractVersion{ID: uuid.New(), Revision: 1}, + }, + ProjectVersion: version.Version, + }) + require.NoError(t, err) + + if tc.wantAction == "" { + require.Empty(t, publisher.published) + return + } + + publisher.assertSingleProjectVersionEvent(t, tc.wantAction, project, version, tc.wantMarkedAsLatest) + }) + } +} diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 4eb9199a3..36309e509 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -1018,6 +1018,60 @@ func (s *workflowRunIntegrationTestSuite) TestContractInformation() { }) } +// The repository is the only layer that can tell a version creation apart from +// the promotion of an existing one, so it must report the promotion back to the +// use case, which is what turns it into an audit event. +func (s *workflowRunIntegrationTestSuite) TestCreateReportsVersionPromotion() { + ctx := context.Background() + markTrue, markFalse := true, false + + createOpts := func(version string, markAsLatest *bool) *biz.WorkflowRunRepoCreateOpts { + return &biz.WorkflowRunRepoCreateOpts{ + WorkflowID: s.workflowOrg1.ID, + SchemaVersionID: s.contractVersion.Version.ID, + RunURL: "runURL", + RunnerType: "runnerType", + Backends: []uuid.UUID{s.casBackend.ID}, + LatestRevision: s.contractVersion.Contract.LatestRevision, + UsedRevision: s.contractVersion.Version.Revision, + ProjectVersion: version, + MarkAsLatest: markAsLatest, + } + } + + s.Run("creating a version is reported as a creation, not a promotion", func() { + result, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-created", &markTrue)) + s.Require().NoError(err) + s.True(result.VersionCreated) + s.False(result.VersionPromoted) + s.True(result.Run.ProjectVersion.Latest) + }) + + s.Run("re-attesting the version that already is the latest is not a promotion", func() { + result, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-noop", &markTrue)) + s.Require().NoError(err) + s.Require().True(result.Run.ProjectVersion.Latest) + + result, err = s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-noop", &markTrue)) + s.Require().NoError(err) + s.False(result.VersionCreated) + s.False(result.VersionPromoted) + }) + + s.Run("promoting an existing non-latest version is reported as a promotion", func() { + _, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-old", &markFalse)) + s.Require().NoError(err) + _, err = s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-new", &markTrue)) + s.Require().NoError(err) + + result, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-old", &markTrue)) + s.Require().NoError(err) + s.False(result.VersionCreated) + s.True(result.VersionPromoted) + s.True(result.Run.ProjectVersion.Latest) + }) +} + // Run the tests func TestWorkflowRunUseCase(t *testing.T) { suite.Run(t, new(workflowRunIntegrationTestSuite)) diff --git a/app/controlplane/pkg/data/projectversion.go b/app/controlplane/pkg/data/projectversion.go index 0e10ee0f5..dfc3b285d 100644 --- a/app/controlplane/pkg/data/projectversion.go +++ b/app/controlplane/pkg/data/projectversion.go @@ -17,6 +17,7 @@ package data import ( "context" + "fmt" "time" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" @@ -143,12 +144,27 @@ func createProjectVersionWithTx(ctx context.Context, tx *ent.Tx, projectID uuid. Save(ctx) } -func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error { +func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) (*biz.ProjectVersionPromotion, error) { ctx, span := otelx.Start(ctx, projectVersionRepoTracer, "ProjectVersionRepo.MarkAsLatest") defer span.End() - return WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { - v, err := tx.ProjectVersion.Query(). + // The audit context is read up front, before anything is written: the + // project's name and organization are stable, and reading them here keeps + // them off both the locked window and the post-commit path. A promotion that + // commits can then always be reported, whereas a read placed after the + // commit could fail and strand the promotion with no event — and the retry + // would see it as already latest and stay silent forever. + p, err := r.data.DB.Project.Get(ctx, projectID) + if err != nil { + if ent.IsNotFound(err) { + return nil, biz.NewErrNotFound("Project") + } + return nil, fmt.Errorf("loading project: %w", err) + } + + var promotion *biz.ProjectVersionPromotion + if err := WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { + v, err := tx.ProjectVersion.Query().ForUpdate(). Where(projectversion.ID(versionID), projectversion.ProjectID(projectID), projectversion.DeletedAtIsNil()). Only(ctx) if err != nil { @@ -158,28 +174,63 @@ func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versio return err } - if !v.Prerelease { - return biz.NewErrValidationStr("cannot promote a released version to latest") + promoted, err := promoteVersionToLatestWithTx(ctx, tx, v) + if err != nil { + return err + } + + // v is the pre-update snapshot and latest is the only field the + // promotion touches, so this describes the committed row. + version := entProjectVersionToBiz(v) + version.Latest = true + + promotion = &biz.ProjectVersionPromotion{ + Promoted: promoted, + Version: version, + Project: entProjectToBiz(p), } - return promoteVersionToLatestWithTx(ctx, tx, projectID, versionID) - }) + return nil + }); err != nil { + return nil, err + } + + return promotion, nil } -func promoteVersionToLatestWithTx(ctx context.Context, tx *ent.Tx, projectID, versionID uuid.UUID) error { +// promoteVersionToLatestWithTx makes v the only latest version of its project. +// v must have been read in this transaction with a row lock, which serialises +// concurrent promotions of that same version. +// +// It reports whether v's own latest flag changed, which callers use to decide +// whether the transition is worth announcing. +// +// The lock covers v alone, not the project's other versions, so promotions of +// two different versions of the same project are not serialised against each +// other and can both report success. Closing that needs a lock on the project +// row or a unique partial index on (project_id) WHERE latest. +func promoteVersionToLatestWithTx(ctx context.Context, tx *ent.Tx, v *ent.ProjectVersion) (bool, error) { + if !v.Prerelease { + return false, biz.NewErrValidationStr("cannot promote a released version to latest") + } + if err := tx.ProjectVersion.Update(). Where( - projectversion.ProjectID(projectID), + projectversion.ProjectID(v.ProjectID), projectversion.DeletedAtIsNil(), projectversion.Latest(true), ).SetLatest(false).Exec(ctx); err != nil { - return err + return false, err } - return tx.ProjectVersion.UpdateOneID(versionID). + if err := tx.ProjectVersion.UpdateOneID(v.ID). SetLatest(true). SetUpdatedAt(time.Now()). - Exec(ctx) + Exec(ctx); err != nil { + return false, err + } + + return !v.Latest, nil } func findProjectVersionWithClient(ctx context.Context, client *ent.Client, projectID uuid.UUID, version string) (*ent.ProjectVersion, error) { diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index 6406a184f..b761d1d06 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -88,7 +88,7 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC } var p *ent.WorkflowRun - versionCreated := false + var versionCreated, versionPromoted bool // Create version and workflow in a transaction if err = WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { markAsLatest := opts.MarkAsLatest != nil && *opts.MarkAsLatest @@ -118,12 +118,12 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC } if markAsLatest { - if !fresh.Prerelease { - return biz.NewErrValidationStr("cannot promote a released version to latest") - } - - if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil { - return fmt.Errorf("promoting version to latest: %w", err) + // Returned unwrapped so that the released-version validation + // error reads identically here and on the explicit promotion + // endpoint, which surfaces it to the user verbatim. + versionPromoted, err = promoteVersionToLatestWithTx(ctx, tx, fresh) + if err != nil { + return err } } } @@ -189,9 +189,10 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC run.ProjectVersion = entProjectVersionToBiz(version) return &biz.WorkflowRunRepoCreateResult{ - Project: entProjectToBiz(project), - Run: run, - VersionCreated: versionCreated, + Project: entProjectToBiz(project), + Run: run, + VersionCreated: versionCreated, + VersionPromoted: versionPromoted, }, nil }