Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controlplane/cmd/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 14 additions & 8 deletions app/controlplane/pkg/auditor/events/project.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
31 changes: 30 additions & 1 deletion app/controlplane/pkg/auditor/events/project_test.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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{
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
14 changes: 11 additions & 3 deletions app/controlplane/pkg/biz/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
82 changes: 82 additions & 0 deletions app/controlplane/pkg/biz/auditor_test.go
Original file line number Diff line number Diff line change
@@ -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"})
}
75 changes: 69 additions & 6 deletions app/controlplane/pkg/biz/projectversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.<target_type>.<action_type>", 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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
Loading
Loading