Skip to content
Draft
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
487 changes: 487 additions & 0 deletions docs/superpowers/plans/2026-06-10-snapshot-rpm-diff.md

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions docs/superpowers/specs/2026-06-10-snapshot-rpm-diff-design.md
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions pkg/api/rpms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions pkg/clients/pulp_client/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions pkg/clients/pulp_client/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
136 changes: 136 additions & 0 deletions pkg/clients/pulp_client/pulp_client_mock.go

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

Loading
Loading