Skip to content

Commit

Permalink
Feature: Add Pull Snapshot API
Browse files Browse the repository at this point in the history
  • Loading branch information
reglim authored and neolynx committed Jun 8, 2024
1 parent 4bc2180 commit 5070caf
Show file tree
Hide file tree
Showing 3 changed files with 262 additions and 1 deletion.
2 changes: 2 additions & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func Router(c *ctx.AptlyContext) http.Handler {
}

{

api.GET("/publish", apiPublishList)
api.POST("/publish", apiPublishRepoOrSnapshot)
api.POST("/publish/:prefix", apiPublishRepoOrSnapshot)
Expand All @@ -169,6 +170,7 @@ func Router(c *ctx.AptlyContext) http.Handler {
api.DELETE("/snapshots/:name", apiSnapshotsDrop)
api.GET("/snapshots/:name/diff/:withSnapshot", apiSnapshotsDiff)
api.POST("/snapshots/merge", apiSnapshotsMerge)
api.POST("/snapshots/pull", apiSnapshotsPull)
}

{
Expand Down
164 changes: 164 additions & 0 deletions api/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package api
import (
"fmt"
"net/http"
"sort"
"strings"

"github.com/aptly-dev/aptly/aptly"
"github.com/aptly-dev/aptly/database"
"github.com/aptly-dev/aptly/deb"
"github.com/aptly-dev/aptly/query"
"github.com/aptly-dev/aptly/task"
"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -475,3 +477,165 @@ func apiSnapshotsMerge(c *gin.Context) {
return &task.ProcessReturnValue{Code: http.StatusCreated, Value: snapshot}, nil
})
}

// POST /api/snapshots/pull
func apiSnapshotsPull(c *gin.Context) {
var (
err error
destinationSnapshot *deb.Snapshot
)

var body struct {
Source string `binding:"required"`
To string `binding:"required"`
Destination string `binding:"required"`
Queries []string `binding:"required"`
Architectures []string
}

if err = c.BindJSON(&body); err != nil {
AbortWithJSONError(c, http.StatusBadRequest, err)
return
}

Check warning on line 499 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L497-L499

Added lines #L497 - L499 were not covered by tests

allMatches := c.Request.URL.Query().Get("all-matches") == "1"
dryRun := c.Request.URL.Query().Get("dry-run") == "1"
noDeps := c.Request.URL.Query().Get("no-deps") == "1"
noRemove := c.Request.URL.Query().Get("no-remove") == "1"

collectionFactory := context.NewCollectionFactory()

// Load <To> snapshot
toSnapshot, err := collectionFactory.SnapshotCollection().ByName(body.To)
if err != nil {
AbortWithJSONError(c, http.StatusNotFound, err)
return
}

Check warning on line 513 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L511-L513

Added lines #L511 - L513 were not covered by tests
err = collectionFactory.SnapshotCollection().LoadComplete(toSnapshot)
if err != nil {
AbortWithJSONError(c, http.StatusInternalServerError, err)
return
}

Check warning on line 518 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L516-L518

Added lines #L516 - L518 were not covered by tests

// Load <Source> snapshot
sourceSnapshot, err := collectionFactory.SnapshotCollection().ByName(body.Source)
if err != nil {
AbortWithJSONError(c, http.StatusNotFound, err)
return
}
err = collectionFactory.SnapshotCollection().LoadComplete(sourceSnapshot)
if err != nil {
AbortWithJSONError(c, http.StatusInternalServerError, err)
return
}

Check warning on line 530 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L528-L530

Added lines #L528 - L530 were not covered by tests

resources := []string{string(sourceSnapshot.ResourceKey()), string(toSnapshot.ResourceKey())}
taskName := fmt.Sprintf("Pull snapshot %s into %s and save as %s", body.Source, body.To, body.Destination)
maybeRunTaskInBackground(c, taskName, resources, func(out aptly.Progress, detail *task.Detail) (*task.ProcessReturnValue, error) {

Check warning on line 534 in api/snapshot.go

View workflow job for this annotation

GitHub Actions / lint

unused-parameter: parameter 'out' seems to be unused, consider removing or renaming it as _ (revive)
// convert snapshots to package list
toPackageList, err := deb.NewPackageListFromRefList(toSnapshot.RefList(), collectionFactory.PackageCollection(), context.Progress())
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, err
}

Check warning on line 539 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L538-L539

Added lines #L538 - L539 were not covered by tests
sourcePackageList, err := deb.NewPackageListFromRefList(sourceSnapshot.RefList(), collectionFactory.PackageCollection(), context.Progress())
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, err
}

Check warning on line 543 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L542-L543

Added lines #L542 - L543 were not covered by tests

toPackageList.PrepareIndex()
sourcePackageList.PrepareIndex()

var architecturesList []string

if len(context.ArchitecturesList()) > 0 {
architecturesList = context.ArchitecturesList()

Check warning on line 551 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L551

Added line #L551 was not covered by tests
} else {
architecturesList = toPackageList.Architectures(false)
}

architecturesList = append(architecturesList, body.Architectures...)
sort.Strings(architecturesList)

if len(architecturesList) == 0 {
err := fmt.Errorf("unable to determine list of architectures, please specify explicitly")
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, err
}

Check warning on line 562 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L560-L562

Added lines #L560 - L562 were not covered by tests

// Build architecture query: (arch == "i386" | arch == "amd64" | ...)
var archQuery deb.PackageQuery = &deb.FieldQuery{Field: "$Architecture", Relation: deb.VersionEqual, Value: ""}
for _, arch := range architecturesList {
archQuery = &deb.OrQuery{L: &deb.FieldQuery{Field: "$Architecture", Relation: deb.VersionEqual, Value: arch}, R: archQuery}
}

queries := make([]deb.PackageQuery, len(body.Queries))
for i, q := range body.Queries {
queries[i], err = query.Parse(q)
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, err
}

Check warning on line 575 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L574-L575

Added lines #L574 - L575 were not covered by tests
// Add architecture filter
queries[i] = &deb.AndQuery{L: queries[i], R: archQuery}
}

// Filter with dependencies as requested
destinationPackageList, err := sourcePackageList.FilterWithProgress(queries, !noDeps, toPackageList, context.DependencyOptions(), architecturesList, context.Progress())
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, err
}

Check warning on line 584 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L583-L584

Added lines #L583 - L584 were not covered by tests
destinationPackageList.PrepareIndex()

removedPackages := []string{}
addedPackages := []string{}
alreadySeen := map[string]bool{}

destinationPackageList.ForEachIndexed(func(pkg *deb.Package) error {
key := pkg.Architecture + "_" + pkg.Name
_, seen := alreadySeen[key]

// If we haven't seen such name-architecture pair and were instructed to remove, remove it
if !noRemove && !seen {
// Remove all packages with the same name and architecture
packageSearchResults := toPackageList.Search(deb.Dependency{Architecture: pkg.Architecture, Pkg: pkg.Name}, true)
for _, p := range packageSearchResults {
toPackageList.Remove(p)
removedPackages = append(removedPackages, p.String())
}

Check warning on line 602 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L592-L602

Added lines #L592 - L602 were not covered by tests
}

// If !allMatches, add only first matching name-arch package
if !seen || allMatches {
toPackageList.Add(pkg)
addedPackages = append(addedPackages, pkg.String())
}

Check warning on line 609 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L606-L609

Added lines #L606 - L609 were not covered by tests

alreadySeen[key] = true

return nil

Check warning on line 613 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L611-L613

Added lines #L611 - L613 were not covered by tests
})
alreadySeen = nil

if dryRun {
response := struct {
AddedPackages []string `json:"added_packages"`
RemovedPackages []string `json:"removed_packages"`
}{
AddedPackages: addedPackages,
RemovedPackages: removedPackages,
}

return &task.ProcessReturnValue{Code: http.StatusOK, Value: response}, nil
}

Check warning on line 627 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L618-L627

Added lines #L618 - L627 were not covered by tests

// Create <destination> snapshot
destinationSnapshot = deb.NewSnapshotFromPackageList(body.Destination, []*deb.Snapshot{toSnapshot, sourceSnapshot}, toPackageList,
fmt.Sprintf("Pulled into '%s' with '%s' as source, pull request was: '%s'", toSnapshot.Name, sourceSnapshot.Name, strings.Join(body.Queries, ", ")))

err = collectionFactory.SnapshotCollection().Add(destinationSnapshot)
if err != nil {
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, err
}

Check warning on line 636 in api/snapshot.go

View check run for this annotation

Codecov / codecov/patch

api/snapshot.go#L635-L636

Added lines #L635 - L636 were not covered by tests

return &task.ProcessReturnValue{Code: http.StatusCreated, Value: destinationSnapshot}, nil
})

}
97 changes: 96 additions & 1 deletion system/t12_api/snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class SnapshotsAPITestCreateShowEmpty(APITest):
"""
GET /api/snapshots/:name, POST /api/snapshots, GET /api/snapshots/:name/packages
"""

def check(self):
snapshot_name = self.random_name()
snapshot_desc = {'Description': 'fun snapshot',
Expand Down Expand Up @@ -35,6 +36,7 @@ class SnapshotsAPITestCreateFromRefs(APITest):
GET /api/snapshots/:name, POST /api/snapshots, GET /api/snapshots/:name/packages,
GET /api/snapshots
"""

def check(self):
snapshot_name = self.random_name()
snapshot_desc = {'Description': 'fun snapshot',
Expand Down Expand Up @@ -95,6 +97,7 @@ class SnapshotsAPITestCreateFromRepo(APITest):
"""
POST /api/repos, POST /api/repos/:name/snapshots, GET /api/snapshots/:name
"""

def check(self):
repo_name = self.random_name()
snapshot_name = self.random_name()
Expand Down Expand Up @@ -138,6 +141,7 @@ class SnapshotsAPITestCreateUpdate(APITest):
"""
POST /api/snapshots, PUT /api/snapshots/:name, GET /api/snapshots/:name
"""

def check(self):
snapshot_name = self.random_name()
snapshot_desc = {'Description': 'fun snapshot',
Expand Down Expand Up @@ -170,6 +174,7 @@ class SnapshotsAPITestCreateDelete(APITest):
"""
POST /api/snapshots, DELETE /api/snapshots/:name, GET /api/snapshots/:name
"""

def check(self):
snapshot_name = self.random_name()
snapshot_desc = {'Description': 'fun snapshot',
Expand Down Expand Up @@ -218,6 +223,7 @@ class SnapshotsAPITestSearch(APITest):
"""
POST /api/snapshots, GET /api/snapshots?sort=name, GET /api/snapshots/:name
"""

def check(self):

repo_name = self.random_name()
Expand Down Expand Up @@ -251,6 +257,7 @@ class SnapshotsAPITestDiff(APITest):
"""
GET /api/snapshot/:name/diff/:name2
"""

def check(self):
repos = [self.random_name() for x in range(2)]
snapshots = [self.random_name() for x in range(2)]
Expand Down Expand Up @@ -291,7 +298,7 @@ def check(self):

class SnapshotsAPITestMerge(APITest):
"""
POST /api/snapshots, GET /api/snapshots/merge, GET /api/snapshots/:name, DELETE /api/snapshots/:name
POST /api/snapshots, POST /api/snapshots/merge, GET /api/snapshots/:name, DELETE /api/snapshots/:name
"""

def check(self):
Expand Down Expand Up @@ -384,3 +391,91 @@ def check(self):
resp.json()["error"], "no-remove and latest are mutually exclusive"
)
self.check_equal(resp.status_code, 400)


class SnapshotsAPITestPull(APITest):
"""
POST /api/snapshots/pull, POST /api/snapshots, GET /api/snapshots/:name/packages?name=:package_name
"""

def check(self):
repo_with_libboost = self.random_name()
empty_repo = self.random_name()
snapshot_repo_with_libboost = self.random_name()
snapshot_empty_repo = self.random_name()

# create repo with file in it and snapshot of it
self.check_equal(self.post("/api/repos", json={"Name": repo_with_libboost}).status_code, 201)

dir_name = self.random_name()
self.check_equal(self.upload(f"/api/files/{dir_name}",
"libboost-program-options-dev_1.49.0.1_i386.deb").status_code, 200)
self.check_equal(self.post(f"/api/repos/{repo_with_libboost}/file/{dir_name}").status_code, 200)

resp = self.post(f"/api/repos/{repo_with_libboost}/snapshots", json={'Name': snapshot_repo_with_libboost})
self.check_equal(resp.status_code, 201)

# create empty repo and snapshot of it
self.check_equal(self.post("/api/repos", json={"Name": empty_repo}).status_code, 201)

resp = self.post(f"/api/repos/{empty_repo}/snapshots", json={'Name': snapshot_empty_repo})
self.check_equal(resp.status_code, 201)

# pull libboost from repo_with_libboost to empty_repo, save into snapshot_pull_libboost
snapshot_pull_libboost = self.random_name()
resp = self.post("/api/snapshots/pull", json={
'Source': snapshot_repo_with_libboost,
'To': snapshot_empty_repo,
'Destination': snapshot_pull_libboost,
'Queries': [
'libboost-program-options-dev'
],
'Architectures': [
'amd64'
'i386'
]
})
self.check_equal(resp.status_code, 201)
self.check_subset({
'Name': snapshot_pull_libboost,
'SourceKind': 'snapshot',
'Description': f"Pulled into '{snapshot_empty_repo}' with '{snapshot_repo_with_libboost}' as source, pull request was: 'libboost-program-options-dev'",
}, resp.json())

# check that snapshot_pull_libboost contains libboost
resp = self.get(f"/api/snapshots/{snapshot_pull_libboost}/packages?name=libboost-program-options-dev")
self.check_equal(resp.status_code, 200)

# pull from non-existing source
non_existing_source = self.random_name()
destination = self.random_name()
resp = self.post("/api/snapshots/pull", json={
'Source': non_existing_source,
'To': snapshot_empty_repo,
'Destination': destination,
'Queries': [
'Name (~ *)'
],
'Architectures': [
'all',
]
})
self.check_equal(resp.status_code, 404)
self.check_equal(resp.json()['error'], f"snapshot with name {non_existing_source} not found")

# pull to non-existing snapshot
non_existing_snapshot = self.random_name()
destination = self.random_name()
resp = self.post("/api/snapshots/pull", json={
'Source': non_existing_snapshot,
'To': snapshot_empty_repo,
'Destination': destination,
'Queries': [
'Name (~ *)'
],
'Architectures': [
'all',
]
})
self.check_equal(resp.status_code, 404)
self.check_equal(resp.json()['error'], f"snapshot with name {non_existing_snapshot} not found")

0 comments on commit 5070caf

Please sign in to comment.