From 147d0627c62c0cf666de2ff21029b46b7817bde3 Mon Sep 17 00:00:00 2001 From: Kate Zaprazna Date: Wed, 10 Jun 2026 15:25:55 +0200 Subject: [PATCH 1/5] feat: add SnapshotRpmDiff API types for snapshot diff endpoint --- pkg/api/rpms.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/api/rpms.go b/pkg/api/rpms.go index 374a69b8c..9fc276e44 100644 --- a/pkg/api/rpms.go +++ b/pkg/api/rpms.go @@ -22,6 +22,22 @@ type SnapshotRpm struct { Summary string `json:"summary"` // The summary of the rpm } +type SnapshotRpmDiff struct { + Name string `json:"name"` // The rpm package name + Arch string `json:"arch"` // The architecture of the rpm + Version string `json:"version"` // The version of the rpm + Release string `json:"release"` // The release of the rpm + Epoch string `json:"epoch"` // The epoch of the rpm + Summary string `json:"summary"` // The summary of the rpm + Status string `json:"status"` // "added" or "removed" +} + +type SnapshotRpmDiffCollectionResponse struct { + Data []SnapshotRpmDiff `json:"data"` // List of rpm diffs + Meta ResponseMetadata `json:"meta"` // Metadata about the request + Links Links `json:"links"` // Links to other pages of results +} + type RepositoryRpmCollectionResponse struct { Data []RepositoryRpm `json:"data"` // List of rpms Meta ResponseMetadata `json:"meta"` // Metadata about the request @@ -119,3 +135,8 @@ func (r *SnapshotRpmCollectionResponse) SetMetadata(meta ResponseMetadata, links r.Meta = meta r.Links = links } + +func (r *SnapshotRpmDiffCollectionResponse) SetMetadata(meta ResponseMetadata, links Links) { + r.Meta = meta + r.Links = links +} From e9a1d2fb55991cfc0d4d059ea7550818b5ef84eb Mon Sep 17 00:00:00 2001 From: Kate Zaprazna Date: Wed, 10 Jun 2026 15:27:49 +0200 Subject: [PATCH 2/5] feat: add Pulp client methods for fetching added/removed packages by version Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/clients/pulp_client/interfaces.go | 2 + pkg/clients/pulp_client/package.go | 66 ++++++++++ pkg/clients/pulp_client/pulp_client_mock.go | 136 ++++++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/pkg/clients/pulp_client/interfaces.go b/pkg/clients/pulp_client/interfaces.go index 7899b6129..c952f5c17 100644 --- a/pkg/clients/pulp_client/interfaces.go +++ b/pkg/clients/pulp_client/interfaces.go @@ -50,6 +50,8 @@ type PulpClient interface { CreatePackage(ctx context.Context, artifactHref *string, uploadHref *string) (string, error) LookupPackage(ctx context.Context, sha256sum string) (*string, error) ListVersionAllPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) + ListVersionAllAddedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) + ListVersionAllRemovedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) // Rpm Repository CreateRpmRepository(ctx context.Context, uuid string, rpmRemotePulpRef *string) (*zest.RpmRpmRepositoryResponse, error) diff --git a/pkg/clients/pulp_client/package.go b/pkg/clients/pulp_client/package.go index c05c0147e..5cfaca93f 100644 --- a/pkg/clients/pulp_client/package.go +++ b/pkg/clients/pulp_client/package.go @@ -95,3 +95,69 @@ func (r *pulpDaoImpl) ListVersionAllPackages(ctx context.Context, versionHref st } return pkgs, nil } + +func (r *pulpDaoImpl) ListVersionAddedPackages(ctx context.Context, versionHref string, offset, limit int32) (pkgs []zest.RpmPackageResponse, total int, err error) { + ctx, client, err := getZestClient(ctx) + if err != nil { + return pkgs, 0, err + } + resp, httpResp, err := client.ContentPackagesAPI.ContentRpmPackagesList(ctx, r.domainName).RepositoryVersionAdded(versionHref).Limit(limit).Fields(RpmFields).Offset(offset).Execute() + if httpResp != nil { + defer httpResp.Body.Close() + } + if err != nil { + return pkgs, 0, errorWithResponseBody("error listing added packages for version", httpResp, err) + } + return resp.Results, int(resp.Count), err +} + +func (r *pulpDaoImpl) ListVersionAllAddedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) { + initial := int32(0) + limit := int32(300) + pkgs, total, err := r.ListVersionAddedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + for len(pkgs) < total { + initial += limit + pkgList, _, err := r.ListVersionAddedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + pkgs = append(pkgs, pkgList...) + } + return pkgs, nil +} + +func (r *pulpDaoImpl) ListVersionRemovedPackages(ctx context.Context, versionHref string, offset, limit int32) (pkgs []zest.RpmPackageResponse, total int, err error) { + ctx, client, err := getZestClient(ctx) + if err != nil { + return pkgs, 0, err + } + resp, httpResp, err := client.ContentPackagesAPI.ContentRpmPackagesList(ctx, r.domainName).RepositoryVersionRemoved(versionHref).Limit(limit).Fields(RpmFields).Offset(offset).Execute() + if httpResp != nil { + defer httpResp.Body.Close() + } + if err != nil { + return pkgs, 0, errorWithResponseBody("error listing removed packages for version", httpResp, err) + } + return resp.Results, int(resp.Count), err +} + +func (r *pulpDaoImpl) ListVersionAllRemovedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) { + initial := int32(0) + limit := int32(300) + pkgs, total, err := r.ListVersionRemovedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + for len(pkgs) < total { + initial += limit + pkgList, _, err := r.ListVersionRemovedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + pkgs = append(pkgs, pkgList...) + } + return pkgs, nil +} diff --git a/pkg/clients/pulp_client/pulp_client_mock.go b/pkg/clients/pulp_client/pulp_client_mock.go index 5ae49444f..0f63fe0bf 100644 --- a/pkg/clients/pulp_client/pulp_client_mock.go +++ b/pkg/clients/pulp_client/pulp_client_mock.go @@ -2437,6 +2437,74 @@ func (_c *MockPulpClient_ListDistributions_Call) RunAndReturn(run func(ctx conte return _c } +// ListVersionAllAddedPackages provides a mock function for the type MockPulpClient +func (_mock *MockPulpClient) ListVersionAllAddedPackages(ctx context.Context, versionHref string) ([]zest.RpmPackageResponse, error) { + ret := _mock.Called(ctx, versionHref) + + if len(ret) == 0 { + panic("no return value specified for ListVersionAllAddedPackages") + } + + var r0 []zest.RpmPackageResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]zest.RpmPackageResponse, error)); ok { + return returnFunc(ctx, versionHref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []zest.RpmPackageResponse); ok { + r0 = returnFunc(ctx, versionHref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]zest.RpmPackageResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, versionHref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPulpClient_ListVersionAllAddedPackages_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVersionAllAddedPackages' +type MockPulpClient_ListVersionAllAddedPackages_Call struct { + *mock.Call +} + +// ListVersionAllAddedPackages is a helper method to define mock.On call +// - ctx context.Context +// - versionHref string +func (_e *MockPulpClient_Expecter) ListVersionAllAddedPackages(ctx interface{}, versionHref interface{}) *MockPulpClient_ListVersionAllAddedPackages_Call { + return &MockPulpClient_ListVersionAllAddedPackages_Call{Call: _e.mock.On("ListVersionAllAddedPackages", ctx, versionHref)} +} + +func (_c *MockPulpClient_ListVersionAllAddedPackages_Call) Run(run func(ctx context.Context, versionHref string)) *MockPulpClient_ListVersionAllAddedPackages_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPulpClient_ListVersionAllAddedPackages_Call) Return(pkgs []zest.RpmPackageResponse, err error) *MockPulpClient_ListVersionAllAddedPackages_Call { + _c.Call.Return(pkgs, err) + return _c +} + +func (_c *MockPulpClient_ListVersionAllAddedPackages_Call) RunAndReturn(run func(ctx context.Context, versionHref string) ([]zest.RpmPackageResponse, error)) *MockPulpClient_ListVersionAllAddedPackages_Call { + _c.Call.Return(run) + return _c +} + // ListVersionAllPackages provides a mock function for the type MockPulpClient func (_mock *MockPulpClient) ListVersionAllPackages(ctx context.Context, versionHref string) ([]zest.RpmPackageResponse, error) { ret := _mock.Called(ctx, versionHref) @@ -2505,6 +2573,74 @@ func (_c *MockPulpClient_ListVersionAllPackages_Call) RunAndReturn(run func(ctx return _c } +// ListVersionAllRemovedPackages provides a mock function for the type MockPulpClient +func (_mock *MockPulpClient) ListVersionAllRemovedPackages(ctx context.Context, versionHref string) ([]zest.RpmPackageResponse, error) { + ret := _mock.Called(ctx, versionHref) + + if len(ret) == 0 { + panic("no return value specified for ListVersionAllRemovedPackages") + } + + var r0 []zest.RpmPackageResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]zest.RpmPackageResponse, error)); ok { + return returnFunc(ctx, versionHref) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []zest.RpmPackageResponse); ok { + r0 = returnFunc(ctx, versionHref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]zest.RpmPackageResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, versionHref) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPulpClient_ListVersionAllRemovedPackages_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVersionAllRemovedPackages' +type MockPulpClient_ListVersionAllRemovedPackages_Call struct { + *mock.Call +} + +// ListVersionAllRemovedPackages is a helper method to define mock.On call +// - ctx context.Context +// - versionHref string +func (_e *MockPulpClient_Expecter) ListVersionAllRemovedPackages(ctx interface{}, versionHref interface{}) *MockPulpClient_ListVersionAllRemovedPackages_Call { + return &MockPulpClient_ListVersionAllRemovedPackages_Call{Call: _e.mock.On("ListVersionAllRemovedPackages", ctx, versionHref)} +} + +func (_c *MockPulpClient_ListVersionAllRemovedPackages_Call) Run(run func(ctx context.Context, versionHref string)) *MockPulpClient_ListVersionAllRemovedPackages_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPulpClient_ListVersionAllRemovedPackages_Call) Return(pkgs []zest.RpmPackageResponse, err error) *MockPulpClient_ListVersionAllRemovedPackages_Call { + _c.Call.Return(pkgs, err) + return _c +} + +func (_c *MockPulpClient_ListVersionAllRemovedPackages_Call) RunAndReturn(run func(ctx context.Context, versionHref string) ([]zest.RpmPackageResponse, error)) *MockPulpClient_ListVersionAllRemovedPackages_Call { + _c.Call.Return(run) + return _c +} + // Livez provides a mock function for the type MockPulpClient func (_mock *MockPulpClient) Livez(ctx context.Context) error { ret := _mock.Called(ctx) From c3a95bfacf2f9b2d19d2cf22702a7a22bfe8230b Mon Sep 17 00:00:00 2001 From: Kate Zaprazna Date: Wed, 10 Jun 2026 15:33:53 +0200 Subject: [PATCH 3/5] feat: add GET /snapshots/:uuid/rpms/diff endpoint This endpoint lists RPM packages added and removed in a repository snapshot. Key features: - Fetches added and removed packages from Pulp - Merges and sorts results (by name, then status, then version) - Supports search filtering on package name (case-insensitive) - Supports pagination via limit/offset - Route registered before /snapshots/:uuid/rpms to avoid greedy matching Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/handler/rpms.go | 120 +++++++++++++++++++++++++++++++++++++++ pkg/handler/rpms_test.go | 48 ++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/pkg/handler/rpms.go b/pkg/handler/rpms.go index baa65101e..c71868f44 100644 --- a/pkg/handler/rpms.go +++ b/pkg/handler/rpms.go @@ -2,13 +2,17 @@ package handler import ( "net/http" + "sort" + "strings" "github.com/content-services/content-sources-backend/pkg/api" + "github.com/content-services/content-sources-backend/pkg/clients/pulp_client" "github.com/content-services/content-sources-backend/pkg/dao" ce "github.com/content-services/content-sources-backend/pkg/errors" "github.com/content-services/content-sources-backend/pkg/rbac" "github.com/content-services/content-sources-backend/pkg/utils" "github.com/content-services/tang/pkg/tangy" + zest "github.com/content-services/zest/release/v2026" "github.com/labstack/echo/v4" ) @@ -23,6 +27,7 @@ func RegisterRpmRoutes(engine *echo.Group, rDao *dao.DaoRegistry) { addRepoRoute(engine, http.MethodGet, "/repositories/:uuid/rpms", rh.listRepositoriesRpm, rbac.RbacVerbRead) addRepoRoute(engine, http.MethodPost, "/rpms/names", rh.searchRpmByName, rbac.RbacVerbRead) + addRepoRoute(engine, http.MethodGet, "/snapshots/:uuid/rpms/diff", rh.listSnapshotRpmDiff, rbac.RbacVerbRead) addRepoRoute(engine, http.MethodGet, "/snapshots/:uuid/rpms", rh.listSnapshotRpm, rbac.RbacVerbRead) addRepoRoute(engine, http.MethodGet, "/snapshots/:uuid/errata", rh.listSnapshotErrata, rbac.RbacVerbRead) addRepoRoute(engine, http.MethodPost, "/snapshots/rpms/names", rh.searchSnapshotRPMs, rbac.RbacVerbRead) @@ -320,3 +325,118 @@ func (rh *RpmHandler) listTemplateErrata(c echo.Context) error { return c.JSON(200, setCollectionResponseMetadata(&api.SnapshotErrataCollectionResponse{Data: data}, c, int64(total))) } + +// listSnapshotRpmDiff godoc +// @Summary List Snapshot RPM Diff +// @ID listSnapshotRpmDiff +// @Description List RPM packages added and removed in a repository snapshot. +// @Tags rpms +// @Accept json +// @Produce json +// @Param uuid path string true "Snapshot ID." +// @Param limit query int false "Number of items to include in response. Use it to control the number of items, particularly when dealing with large datasets. Default value: `100`." +// @Param offset query int false "Starting point for retrieving a subset of results. Determines how many items to skip from the beginning of the result set. Default value:`0`." +// @Param search query string false "Term to filter and retrieve items that match the specified search criteria. Search term can include name." +// @Success 200 {object} api.SnapshotRpmDiffCollectionResponse +// @Failure 400 {object} ce.ErrorResponse +// @Failure 401 {object} ce.ErrorResponse +// @Failure 404 {object} ce.ErrorResponse +// @Failure 500 {object} ce.ErrorResponse +// @Router /snapshots/{uuid}/rpms/diff [get] +func (rh *RpmHandler) listSnapshotRpmDiff(c echo.Context) error { + err := CheckSnapshotAccessible(c.Request().Context()) + if err != nil { + return err + } + + snapshotUUID := c.Param("uuid") + _, orgID := getAccountIdOrgId(c) + page := ParsePagination(c) + search := c.QueryParam("search") + + snap, err := rh.Dao.Snapshot.FetchModel(c.Request().Context(), snapshotUUID, false) + if err != nil { + return ce.NewErrorResponse(ce.HttpCodeForDaoError(err), "Error fetching snapshot", err.Error()) + } + + domainName, err := rh.Dao.Domain.Fetch(c.Request().Context(), orgID) + if err != nil { + return ce.NewErrorResponse(ce.HttpCodeForDaoError(err), "Error fetching domain", err.Error()) + } + + pulpClient := pulp_client.GetPulpClientWithDomain(domainName) + + added, err := pulpClient.ListVersionAllAddedPackages(c.Request().Context(), snap.VersionHref) + if err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error fetching added packages from Pulp", err.Error()) + } + + removed, err := pulpClient.ListVersionAllRemovedPackages(c.Request().Context(), snap.VersionHref) + if err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error fetching removed packages from Pulp", err.Error()) + } + + merged := mergeRpmDiffs(added, removed, search) + + total := int64(len(merged)) + start := page.Offset + end := page.Offset + page.Limit + if start > int(total) { + start = int(total) + } + if end > int(total) { + end = int(total) + } + paged := merged[start:end] + + return c.JSON(http.StatusOK, setCollectionResponseMetadata(&api.SnapshotRpmDiffCollectionResponse{Data: paged}, c, total)) +} + +func mergeRpmDiffs(added, removed []zest.RpmPackageResponse, search string) []api.SnapshotRpmDiff { + var merged []api.SnapshotRpmDiff + searchLower := strings.ToLower(search) + + for _, pkg := range removed { + name := pkg.GetName() + if search != "" && !strings.Contains(strings.ToLower(name), searchLower) { + continue + } + merged = append(merged, api.SnapshotRpmDiff{ + Name: name, + Arch: pkg.GetArch(), + Version: pkg.GetVersion(), + Release: pkg.GetRelease(), + Epoch: pkg.GetEpoch(), + Summary: pkg.GetSummary(), + Status: "removed", + }) + } + + for _, pkg := range added { + name := pkg.GetName() + if search != "" && !strings.Contains(strings.ToLower(name), searchLower) { + continue + } + merged = append(merged, api.SnapshotRpmDiff{ + Name: name, + Arch: pkg.GetArch(), + Version: pkg.GetVersion(), + Release: pkg.GetRelease(), + Epoch: pkg.GetEpoch(), + Summary: pkg.GetSummary(), + Status: "added", + }) + } + + sort.Slice(merged, func(i, j int) bool { + if merged[i].Name != merged[j].Name { + return merged[i].Name < merged[j].Name + } + if merged[i].Status != merged[j].Status { + return merged[i].Status == "removed" + } + return merged[i].Version < merged[j].Version + }) + + return merged +} diff --git a/pkg/handler/rpms_test.go b/pkg/handler/rpms_test.go index 6df77e54e..f24a639f3 100644 --- a/pkg/handler/rpms_test.go +++ b/pkg/handler/rpms_test.go @@ -19,6 +19,7 @@ import ( test_handler "github.com/content-services/content-sources-backend/pkg/test/handler" "github.com/content-services/content-sources-backend/pkg/utils" "github.com/content-services/tang/pkg/tangy" + zest "github.com/content-services/zest/release/v2026" "github.com/labstack/echo/v4" echo_middleware "github.com/labstack/echo/v4/middleware" "github.com/redhatinsights/platform-go-middlewares/v2/identity" @@ -924,6 +925,53 @@ func (suite *RpmSuite) TestListTemplateErrata() { } } +func (suite *RpmSuite) TestMergeRpmDiffs() { + t := suite.T() + + curlAdded := zest.RpmPackageResponse{} + curlAdded.SetName("curl") + curlAdded.SetArch("x86_64") + curlAdded.SetVersion("8.5.0") + curlAdded.SetRelease("1.el9") + curlAdded.SetEpoch("0") + curlAdded.SetSummary("A utility for getting files from remote servers") + + bashAdded := zest.RpmPackageResponse{} + bashAdded.SetName("bash") + bashAdded.SetArch("x86_64") + bashAdded.SetVersion("5.2.26") + bashAdded.SetRelease("4.el9") + bashAdded.SetEpoch("0") + bashAdded.SetSummary("The GNU Bourne Again shell") + + bashRemoved := zest.RpmPackageResponse{} + bashRemoved.SetName("bash") + bashRemoved.SetArch("x86_64") + bashRemoved.SetVersion("5.2.15") + bashRemoved.SetRelease("3.el9") + bashRemoved.SetEpoch("0") + bashRemoved.SetSummary("The GNU Bourne Again shell") + + merged := mergeRpmDiffs( + []zest.RpmPackageResponse{curlAdded, bashAdded}, + []zest.RpmPackageResponse{bashRemoved}, + "", + ) + + require.Len(t, merged, 3) + // bash removed (old) comes first + assert.Equal(t, "bash", merged[0].Name) + assert.Equal(t, "removed", merged[0].Status) + assert.Equal(t, "5.2.15", merged[0].Version) + // bash added (new) comes second + assert.Equal(t, "bash", merged[1].Name) + assert.Equal(t, "added", merged[1].Status) + assert.Equal(t, "5.2.26", merged[1].Version) + // curl added comes last (alphabetically after bash) + assert.Equal(t, "curl", merged[2].Name) + assert.Equal(t, "added", merged[2].Status) +} + func TestRpmSuite(t *testing.T) { suite.Run(t, new(RpmSuite)) } From f73f4872c91b88013d522cfd8c1fc98fbe9d524d Mon Sep 17 00:00:00 2001 From: Kate Zaprazna Date: Wed, 10 Jun 2026 15:36:23 +0200 Subject: [PATCH 4/5] test: add search and empty diff tests for mergeRpmDiffs Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/handler/rpms_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/pkg/handler/rpms_test.go b/pkg/handler/rpms_test.go index f24a639f3..d9c543c28 100644 --- a/pkg/handler/rpms_test.go +++ b/pkg/handler/rpms_test.go @@ -972,6 +972,37 @@ func (suite *RpmSuite) TestMergeRpmDiffs() { assert.Equal(t, "added", merged[2].Status) } +func (suite *RpmSuite) TestMergeRpmDiffsSearch() { + t := suite.T() + + bashPkg := zest.RpmPackageResponse{} + bashPkg.SetName("bash") + bashPkg.SetArch("x86_64") + bashPkg.SetVersion("5.2.26") + bashPkg.SetRelease("4.el9") + bashPkg.SetEpoch("0") + bashPkg.SetSummary("The GNU Bourne Again shell") + + curlPkg := zest.RpmPackageResponse{} + curlPkg.SetName("curl") + curlPkg.SetArch("x86_64") + curlPkg.SetVersion("8.5.0") + curlPkg.SetRelease("1.el9") + curlPkg.SetEpoch("0") + curlPkg.SetSummary("A utility for getting files from remote servers") + + merged := mergeRpmDiffs([]zest.RpmPackageResponse{bashPkg, curlPkg}, []zest.RpmPackageResponse{}, "curl") + + require.Len(t, merged, 1) + assert.Equal(t, "curl", merged[0].Name) + assert.Equal(t, "added", merged[0].Status) +} + +func (suite *RpmSuite) TestMergeRpmDiffsEmpty() { + merged := mergeRpmDiffs([]zest.RpmPackageResponse{}, []zest.RpmPackageResponse{}, "") + assert.Empty(suite.T(), merged) +} + func TestRpmSuite(t *testing.T) { suite.Run(t, new(RpmSuite)) } From 33634aa5bd520b38615a430c1aa4a1f6cce4e40f Mon Sep 17 00:00:00 2001 From: Kate Zaprazna Date: Wed, 10 Jun 2026 16:14:21 +0200 Subject: [PATCH 5/5] feat: snapshot RPM diff implementation plan --- .../plans/2026-06-10-snapshot-rpm-diff.md | 487 ++++++++++++++++++ .../2026-06-10-snapshot-rpm-diff-design.md | 112 ++++ 2 files changed, 599 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-snapshot-rpm-diff.md create mode 100644 docs/superpowers/specs/2026-06-10-snapshot-rpm-diff-design.md diff --git a/docs/superpowers/plans/2026-06-10-snapshot-rpm-diff.md b/docs/superpowers/plans/2026-06-10-snapshot-rpm-diff.md new file mode 100644 index 000000000..2bd2ef0c4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-snapshot-rpm-diff.md @@ -0,0 +1,487 @@ +# Snapshot RPM Diff Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `GET /snapshots/:snapshot_uuid/rpms/diff` endpoint that returns the individual RPM packages added and removed in a snapshot, merged into a single alphabetically-sorted paginated list. + +**Architecture:** The handler fetches the snapshot's `VersionHref` from the DB, then calls two new Pulp client methods (`ListVersionAllAddedPackages` / `ListVersionAllRemovedPackages`) which use Pulp's `RepositoryVersionAdded` / `RepositoryVersionRemoved` query filters on `ContentRpmPackagesList`. The handler merges both lists, tags each entry with a status, sorts by name (removed before added within the same name), optionally filters by search term, and paginates in-memory. + +**Tech Stack:** Go, Echo HTTP framework, Pulp RPM API (via zest client v2026), mockery for mock generation + +--- + +### Task 1: Add API types for snapshot RPM diff + +**Files:** +- Modify: `pkg/api/rpms.go` + +- [ ] **Step 1: Add `SnapshotRpmDiff` struct** + +Add the following types after the existing `SnapshotRpm` struct (around line 23) in `pkg/api/rpms.go`: + +```go +type SnapshotRpmDiff struct { + Name string `json:"name"` // The rpm package name + Arch string `json:"arch"` // The architecture of the rpm + Version string `json:"version"` // The version of the rpm + Release string `json:"release"` // The release of the rpm + Epoch string `json:"epoch"` // The epoch of the rpm + Summary string `json:"summary"` // The summary of the rpm + Status string `json:"status"` // "added" or "removed" +} + +type SnapshotRpmDiffCollectionResponse struct { + Data []SnapshotRpmDiff `json:"data"` // List of rpm diffs + Meta ResponseMetadata `json:"meta"` // Metadata about the request + Links Links `json:"links"` // Links to other pages of results +} + +func (r *SnapshotRpmDiffCollectionResponse) SetMetadata(meta ResponseMetadata, links Links) { + r.Meta = meta + r.Links = links +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./pkg/api/...` +Expected: clean compile, no errors + +- [ ] **Step 3: Commit** + +```bash +git add pkg/api/rpms.go +git commit -m "feat: add SnapshotRpmDiff API types for snapshot diff endpoint" +``` + +--- + +### Task 2: Add Pulp client methods for fetching added/removed packages + +**Files:** +- Modify: `pkg/clients/pulp_client/interfaces.go` +- Modify: `pkg/clients/pulp_client/package.go` + +- [ ] **Step 1: Add methods to the PulpClient interface** + +In `pkg/clients/pulp_client/interfaces.go`, add the following two methods under the `// Package` section (after line 52, after `ListVersionAllPackages`): + +```go + ListVersionAllAddedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) + ListVersionAllRemovedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) +``` + +- [ ] **Step 2: Implement the helper methods in package.go** + +Add the following functions at the end of `pkg/clients/pulp_client/package.go`: + +```go +func (r *pulpDaoImpl) ListVersionAddedPackages(ctx context.Context, versionHref string, offset, limit int32) (pkgs []zest.RpmPackageResponse, total int, err error) { + ctx, client, err := getZestClient(ctx) + if err != nil { + return pkgs, 0, err + } + resp, httpResp, err := client.ContentPackagesAPI.ContentRpmPackagesList(ctx, r.domainName).RepositoryVersionAdded(versionHref).Limit(limit).Fields(RpmFields).Offset(offset).Execute() + if httpResp != nil { + defer httpResp.Body.Close() + } + if err != nil { + return pkgs, 0, errorWithResponseBody("error listing added packages for version", httpResp, err) + } + return resp.Results, int(resp.Count), err +} + +func (r *pulpDaoImpl) ListVersionAllAddedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) { + initial := int32(0) + limit := int32(300) + pkgs, total, err := r.ListVersionAddedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + for len(pkgs) < total { + initial += limit + pkgList, _, err := r.ListVersionAddedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + pkgs = append(pkgs, pkgList...) + } + return pkgs, nil +} + +func (r *pulpDaoImpl) ListVersionRemovedPackages(ctx context.Context, versionHref string, offset, limit int32) (pkgs []zest.RpmPackageResponse, total int, err error) { + ctx, client, err := getZestClient(ctx) + if err != nil { + return pkgs, 0, err + } + resp, httpResp, err := client.ContentPackagesAPI.ContentRpmPackagesList(ctx, r.domainName).RepositoryVersionRemoved(versionHref).Limit(limit).Fields(RpmFields).Offset(offset).Execute() + if httpResp != nil { + defer httpResp.Body.Close() + } + if err != nil { + return pkgs, 0, errorWithResponseBody("error listing removed packages for version", httpResp, err) + } + return resp.Results, int(resp.Count), err +} + +func (r *pulpDaoImpl) ListVersionAllRemovedPackages(ctx context.Context, versionHref string) (pkgs []zest.RpmPackageResponse, err error) { + initial := int32(0) + limit := int32(300) + pkgs, total, err := r.ListVersionRemovedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + for len(pkgs) < total { + initial += limit + pkgList, _, err := r.ListVersionRemovedPackages(ctx, versionHref, initial, limit) + if err != nil { + return nil, err + } + pkgs = append(pkgs, pkgList...) + } + return pkgs, nil +} +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./pkg/clients/pulp_client/...` +Expected: clean compile, no errors + +- [ ] **Step 4: Regenerate mocks** + +Run: `make mock` +Expected: `pkg/clients/pulp_client/pulp_client_mock.go` is updated with new mock methods for `ListVersionAllAddedPackages` and `ListVersionAllRemovedPackages`. + +- [ ] **Step 5: Verify mocks compile** + +Run: `go build ./pkg/clients/pulp_client/...` +Expected: clean compile + +- [ ] **Step 6: Commit** + +```bash +git add pkg/clients/pulp_client/interfaces.go pkg/clients/pulp_client/package.go pkg/clients/pulp_client/pulp_client_mock.go +git commit -m "feat: add Pulp client methods for fetching added/removed packages by version" +``` + +--- + +### Task 3: Add the snapshot RPM diff handler and route + +**Files:** +- Modify: `pkg/handler/rpms.go` +- Modify: `pkg/handler/rpms_test.go` + +- [ ] **Step 1: Write the `mergeRpmDiffs` unit test first** + +The `mergeRpmDiffs` function is the core custom logic (merge, sort, search filter). Test it directly without needing HTTP or Pulp mocking. Add the following test to `pkg/handler/rpms_test.go`: + +```go +func (suite *RpmSuite) TestMergeRpmDiffs() { + t := suite.T() + + curlAdded := zest.RpmPackageResponse{} + curlAdded.SetName("curl") + curlAdded.SetArch("x86_64") + curlAdded.SetVersion("8.5.0") + curlAdded.SetRelease("1.el9") + curlAdded.SetEpoch("0") + curlAdded.SetSummary("A utility for getting files from remote servers") + + bashAdded := zest.RpmPackageResponse{} + bashAdded.SetName("bash") + bashAdded.SetArch("x86_64") + bashAdded.SetVersion("5.2.26") + bashAdded.SetRelease("4.el9") + bashAdded.SetEpoch("0") + bashAdded.SetSummary("The GNU Bourne Again shell") + + bashRemoved := zest.RpmPackageResponse{} + bashRemoved.SetName("bash") + bashRemoved.SetArch("x86_64") + bashRemoved.SetVersion("5.2.15") + bashRemoved.SetRelease("3.el9") + bashRemoved.SetEpoch("0") + bashRemoved.SetSummary("The GNU Bourne Again shell") + + merged := mergeRpmDiffs( + []zest.RpmPackageResponse{curlAdded, bashAdded}, + []zest.RpmPackageResponse{bashRemoved}, + "", + ) + + require.Len(t, merged, 3) + // bash removed (old) comes first + assert.Equal(t, "bash", merged[0].Name) + assert.Equal(t, "removed", merged[0].Status) + assert.Equal(t, "5.2.15", merged[0].Version) + // bash added (new) comes second + assert.Equal(t, "bash", merged[1].Name) + assert.Equal(t, "added", merged[1].Status) + assert.Equal(t, "5.2.26", merged[1].Version) + // curl added comes last (alphabetically after bash) + assert.Equal(t, "curl", merged[2].Name) + assert.Equal(t, "added", merged[2].Status) +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./pkg/handler/ -run TestRpmSuite/TestMergeRpmDiffs -v` +Expected: FAIL — `mergeRpmDiffs` is not defined yet. + +- [ ] **Step 3: Add imports to rpms.go** + +Add the following imports to `pkg/handler/rpms.go` (merge into the existing import block): + +```go + "sort" + "strings" + + "github.com/content-services/content-sources-backend/pkg/clients/pulp_client" + zest "github.com/content-services/zest/release/v2026" +``` + +- [ ] **Step 4: Register the new route** + +In `pkg/handler/rpms.go`, inside the `RegisterRpmRoutes` function, add the following line after the existing snapshot routes (after line 28): + +```go + addRepoRoute(engine, http.MethodGet, "/snapshots/:uuid/rpms/diff", rh.listSnapshotRpmDiff, rbac.RbacVerbRead) +``` + +**Important:** This route MUST be registered BEFORE the existing `/snapshots/:uuid/rpms` route (line 26), because Echo matches routes greedily. If `/snapshots/:uuid/rpms` is registered first, requests to `/snapshots/:uuid/rpms/diff` could be matched by the `:uuid` capturing `uuid/rpms/diff`. Move the new route above line 26. + +- [ ] **Step 5: Implement the handler** + +Add the following handler method to `pkg/handler/rpms.go`: + +```go +// listSnapshotRpmDiff godoc +// @Summary List Snapshot RPM Diff +// @ID listSnapshotRpmDiff +// @Description List RPM packages added and removed in a repository snapshot. +// @Tags rpms +// @Accept json +// @Produce json +// @Param uuid path string true "Snapshot ID." +// @Param limit query int false "Number of items to include in response. Use it to control the number of items, particularly when dealing with large datasets. Default value: `100`." +// @Param offset query int false "Starting point for retrieving a subset of results. Determines how many items to skip from the beginning of the result set. Default value:`0`." +// @Param search query string false "Term to filter and retrieve items that match the specified search criteria. Search term can include name." +// @Success 200 {object} api.SnapshotRpmDiffCollectionResponse +// @Failure 400 {object} ce.ErrorResponse +// @Failure 401 {object} ce.ErrorResponse +// @Failure 404 {object} ce.ErrorResponse +// @Failure 500 {object} ce.ErrorResponse +// @Router /snapshots/{uuid}/rpms/diff [get] +func (rh *RpmHandler) listSnapshotRpmDiff(c echo.Context) error { + err := CheckSnapshotAccessible(c.Request().Context()) + if err != nil { + return err + } + + snapshotUUID := c.Param("uuid") + _, orgID := getAccountIdOrgId(c) + page := ParsePagination(c) + search := c.QueryParam("search") + + snap, err := rh.Dao.Snapshot.FetchModel(c.Request().Context(), snapshotUUID, false) + if err != nil { + return ce.NewErrorResponse(ce.HttpCodeForDaoError(err), "Error fetching snapshot", err.Error()) + } + + domainName, err := rh.Dao.Domain.Fetch(c.Request().Context(), orgID) + if err != nil { + return ce.NewErrorResponse(ce.HttpCodeForDaoError(err), "Error fetching domain", err.Error()) + } + + pulpClient := pulp_client.GetPulpClientWithDomain(domainName) + + added, err := pulpClient.ListVersionAllAddedPackages(c.Request().Context(), snap.VersionHref) + if err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error fetching added packages from Pulp", err.Error()) + } + + removed, err := pulpClient.ListVersionAllRemovedPackages(c.Request().Context(), snap.VersionHref) + if err != nil { + return ce.NewErrorResponse(http.StatusInternalServerError, "Error fetching removed packages from Pulp", err.Error()) + } + + merged := mergeRpmDiffs(added, removed, search) + + total := int64(len(merged)) + start := page.Offset + end := page.Offset + page.Limit + if start > int(total) { + start = int(total) + } + if end > int(total) { + end = int(total) + } + paged := merged[start:end] + + return c.JSON(http.StatusOK, setCollectionResponseMetadata(&api.SnapshotRpmDiffCollectionResponse{Data: paged}, c, total)) +} + +func mergeRpmDiffs(added, removed []zest.RpmPackageResponse, search string) []api.SnapshotRpmDiff { + var merged []api.SnapshotRpmDiff + searchLower := strings.ToLower(search) + + for _, pkg := range removed { + name := pkg.GetName() + if search != "" && !strings.Contains(strings.ToLower(name), searchLower) { + continue + } + merged = append(merged, api.SnapshotRpmDiff{ + Name: name, + Arch: pkg.GetArch(), + Version: pkg.GetVersion(), + Release: pkg.GetRelease(), + Epoch: pkg.GetEpoch(), + Summary: pkg.GetSummary(), + Status: "removed", + }) + } + + for _, pkg := range added { + name := pkg.GetName() + if search != "" && !strings.Contains(strings.ToLower(name), searchLower) { + continue + } + merged = append(merged, api.SnapshotRpmDiff{ + Name: name, + Arch: pkg.GetArch(), + Version: pkg.GetVersion(), + Release: pkg.GetRelease(), + Epoch: pkg.GetEpoch(), + Summary: pkg.GetSummary(), + Status: "added", + }) + } + + sort.Slice(merged, func(i, j int) bool { + if merged[i].Name != merged[j].Name { + return merged[i].Name < merged[j].Name + } + if merged[i].Status != merged[j].Status { + return merged[i].Status == "removed" + } + return merged[i].Version < merged[j].Version + }) + + return merged +} +``` + +- [ ] **Step 6: Verify the mergeRpmDiffs test passes** + +Run: `go test ./pkg/handler/ -run TestRpmSuite/TestMergeRpmDiffs -v` +Expected: PASS — bash removed first, bash added second, curl added last. + +- [ ] **Step 7: Commit** + +```bash +git add pkg/handler/rpms.go pkg/handler/rpms_test.go +git commit -m "feat: add GET /snapshots/:uuid/rpms/diff endpoint" +``` + +--- + +### Task 4: Add test for search filtering on mergeRpmDiffs + +**Files:** +- Modify: `pkg/handler/rpms_test.go` + +- [ ] **Step 1: Write the search filter test** + +Add the following test to `pkg/handler/rpms_test.go`: + +```go +func (suite *RpmSuite) TestMergeRpmDiffsSearch() { + t := suite.T() + + bashPkg := zest.RpmPackageResponse{} + bashPkg.SetName("bash") + bashPkg.SetArch("x86_64") + bashPkg.SetVersion("5.2.26") + bashPkg.SetRelease("4.el9") + bashPkg.SetEpoch("0") + bashPkg.SetSummary("The GNU Bourne Again shell") + + curlPkg := zest.RpmPackageResponse{} + curlPkg.SetName("curl") + curlPkg.SetArch("x86_64") + curlPkg.SetVersion("8.5.0") + curlPkg.SetRelease("1.el9") + curlPkg.SetEpoch("0") + curlPkg.SetSummary("A utility for getting files from remote servers") + + merged := mergeRpmDiffs([]zest.RpmPackageResponse{bashPkg, curlPkg}, []zest.RpmPackageResponse{}, "curl") + + require.Len(t, merged, 1) + assert.Equal(t, "curl", merged[0].Name) + assert.Equal(t, "added", merged[0].Status) +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./pkg/handler/ -run TestRpmSuite/TestMergeRpmDiffsSearch -v` +Expected: PASS — only curl is returned because the search filter excludes bash. + +- [ ] **Step 3: Commit** + +```bash +git add pkg/handler/rpms_test.go +git commit -m "test: add search filter test for mergeRpmDiffs" +``` + +--- + +### Task 5: Add test for empty diff + +**Files:** +- Modify: `pkg/handler/rpms_test.go` + +- [ ] **Step 1: Write empty diff test** + +Add the following test to `pkg/handler/rpms_test.go`: + +```go +func (suite *RpmSuite) TestMergeRpmDiffsEmpty() { + merged := mergeRpmDiffs([]zest.RpmPackageResponse{}, []zest.RpmPackageResponse{}, "") + assert.Empty(suite.T(), merged) +} +``` + +- [ ] **Step 2: Run all RPM handler tests** + +Run: `go test ./pkg/handler/ -run TestRpmSuite -v` +Expected: All tests pass, including the existing ones. + +- [ ] **Step 3: Commit** + +```bash +git add pkg/handler/rpms_test.go +git commit -m "test: add empty diff test for mergeRpmDiffs" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Run all tests in the repository** + +Run: `go test ./...` +Expected: All tests pass. No regressions. + +- [ ] **Step 2: Run linter if available** + +Run: `make lint` (or `golangci-lint run` if available) +Expected: No new lint errors. + +- [ ] **Step 3: Verify the full build** + +Run: `go build ./cmd/...` +Expected: Clean compile. diff --git a/docs/superpowers/specs/2026-06-10-snapshot-rpm-diff-design.md b/docs/superpowers/specs/2026-06-10-snapshot-rpm-diff-design.md new file mode 100644 index 000000000..c0510d686 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-snapshot-rpm-diff-design.md @@ -0,0 +1,112 @@ +# Snapshot RPM Diff API + +## Problem + +Users need to see which RPM packages were added and removed between consecutive repository snapshots. The frontend will render this as a diff view (green for added, red for removed), with updated packages (same name, different version) grouped together. + +## Data Source + +Pulp's `ContentRpmPackagesList` API supports filtering by `repository_version_added` and `repository_version_removed`. These filters return the individual RPM packages that were added or removed in a specific repository version (relative to its predecessor). This is the same data that Pulp's `ContentSummary` counts reference, but at the individual package level. + +## API Endpoint + +**Route:** `GET /snapshots/:snapshot_uuid/rpms/diff` + +**Query parameters:** + +| Parameter | Type | Default | Description | +|-----------|--------|---------|-------------| +| `offset` | int | 0 | Pagination offset | +| `limit` | int | 100 | Pagination limit | +| `search` | string | (none) | Filter by package name | + +**Response:** + +```json +{ + "data": [ + { + "name": "bash", + "arch": "x86_64", + "version": "5.2.15", + "release": "3.el9", + "epoch": "0", + "summary": "The GNU Bourne Again shell", + "status": "removed" + }, + { + "name": "bash", + "arch": "x86_64", + "version": "5.2.26", + "release": "4.el9", + "epoch": "0", + "summary": "The GNU Bourne Again shell", + "status": "added" + }, + { + "name": "curl", + "arch": "x86_64", + "version": "8.5.0", + "release": "1.el9", + "epoch": "0", + "summary": "A utility for getting files from remote servers", + "status": "added" + } + ], + "meta": { "limit": 100, "offset": 0, "count": 3 }, + "links": { "first": "...", "last": "..." } +} +``` + +**Sorting/grouping:** +- Primary sort: package name (alphabetical ascending) +- Within same name: `"removed"` before `"added"` (old version appears above new version) + +## New API Types + +- `SnapshotRpmDiff` — extends `SnapshotRpm` fields with `Status string` (`"added"` or `"removed"`) +- `SnapshotRpmDiffCollectionResponse` — wraps `[]SnapshotRpmDiff` with standard `Meta` and `Links` + +## Pulp Client Layer + +Two new methods on the `PulpClient` interface, following the existing `ListVersionPackages` pattern: + +```go +ListVersionAddedPackages(ctx context.Context, versionHref string, offset, limit int32) ([]zest.RpmPackageResponse, int, error) +ListVersionRemovedPackages(ctx context.Context, versionHref string, offset, limit int32) ([]zest.RpmPackageResponse, int, error) +``` + +These call `ContentRpmPackagesList` with `.RepositoryVersionAdded(versionHref)` and `.RepositoryVersionRemoved(versionHref)` respectively, using the same `RpmFields` field filter as the existing package listing. + +## Handler Data Flow + +1. Handler receives request, extracts `snapshot_uuid` from path +2. Looks up snapshot from DB via `Snapshot.FetchModel()` to get `VersionHref` +3. Resolves Pulp domain via `Domain.Fetch(orgID)` +4. Fetches **all** added packages from Pulp (paginating through Pulp's results internally) +5. Fetches **all** removed packages from Pulp (same) +6. Optionally filters both lists by `search` query param (name substring match) +7. Merges into a single list, tagging each entry with `status: "added"` or `"removed"` +8. Sorts alphabetically by name, then `"removed"` before `"added"` within same name +9. Applies `offset`/`limit` pagination on the merged list +10. Returns standard collection response + +**Why fetch all from Pulp and paginate in-memory:** The merged, alphabetically-grouped list cannot be produced by paginating Pulp's added and removed results separately. For typical snapshot diffs (tens to low thousands of changed packages), fetching all is practical. + +## Files to Modify + +| File | Change | +|------|--------| +| `pkg/clients/pulp_client/interfaces.go` | Add `ListVersionAddedPackages` and `ListVersionRemovedPackages` to `PulpClient` interface | +| `pkg/clients/pulp_client/package.go` | Implement the two new methods | +| `pkg/clients/pulp_client/pulp_client_mock.go` | Add mock implementations | +| `pkg/api/rpms.go` | Add `SnapshotRpmDiff` and `SnapshotRpmDiffCollectionResponse` types | +| `pkg/handler/rpms.go` | Add `snapshotRpmDiff` handler, register route in `RegisterRpmRoutes` | +| `pkg/handler/rpms_test.go` | Tests for the new handler | + +## What Is NOT in Scope + +- Comparing arbitrary snapshot pairs (only consecutive snapshots via Pulp's built-in diff) +- Content types other than RPM packages (no advisories, modules, etc.) +- Database migrations or new models +- Frontend implementation (grouping updated packages by name is a frontend concern)