Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: Feature: Add Pull Snapshot API #1162

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@
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 @@
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(_ aptly.Progress, _ *task.Detail) (*task.ProcessReturnValue, error) {
// 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")