diff --git a/api/router.go b/api/router.go index 5a01fdbe..f378c864 100644 --- a/api/router.go +++ b/api/router.go @@ -159,6 +159,7 @@ func Router(c *ctx.AptlyContext) http.Handler { root.GET("/snapshots/:name/packages", apiSnapshotsSearchPackages) root.DELETE("/snapshots/:name", apiSnapshotsDrop) root.GET("/snapshots/:name/diff/:withSnapshot", apiSnapshotsDiff) + root.POST("/snapshots/merge", apiSnapshotsMerge) } { diff --git a/api/snapshot.go b/api/snapshot.go index eabb222b..af905221 100644 --- a/api/snapshot.go +++ b/api/snapshot.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "strings" "github.com/aptly-dev/aptly/aptly" "github.com/aptly-dev/aptly/database" @@ -397,3 +398,80 @@ func apiSnapshotsSearchPackages(c *gin.Context) { showPackages(c, snapshot.RefList(), collectionFactory) } + +// POST /api/snapshots/merge +func apiSnapshotsMerge(c *gin.Context) { + var ( + err error + snapshot *deb.Snapshot + ) + + var body struct { + Destination string `binding:"required"` + Sources []string `binding:"required"` + } + + if c.Bind(&body) != nil { + return + } + + if len(body.Sources) < 1 { + AbortWithJSONError(c, http.StatusBadRequest, fmt.Errorf("At least one source snapshot is required")) + return + } + + latest := c.Request.URL.Query().Get("latest") == "1" + noRemove := c.Request.URL.Query().Get("no-remove") == "1" + overrideMatching := !latest && !noRemove + + if noRemove && latest { + AbortWithJSONError(c, http.StatusBadRequest, fmt.Errorf("no-remove and latest are mutually exclusive")) + return + } + + collectionFactory := context.NewCollectionFactory() + snapshotCollection := collectionFactory.SnapshotCollection() + + sources := make([]*deb.Snapshot, len(body.Sources)) + resources := make([]string, len(sources)) + for i := range body.Sources { + sources[i], err = snapshotCollection.ByName(body.Sources[i]) + if err != nil { + AbortWithJSONError(c, http.StatusNotFound, err) + return + } + + err = snapshotCollection.LoadComplete(sources[i]) + if err != nil { + AbortWithJSONError(c, http.StatusInternalServerError, err) + return + } + resources[i] = string(sources[i].ResourceKey()) + } + + maybeRunTaskInBackground(c, "Merge snapshot "+body.Destination, resources, func(out aptly.Progress, detail *task.Detail) (*task.ProcessReturnValue, error) { + result := sources[0].RefList() + for i := 1; i < len(sources); i++ { + result = result.Merge(sources[i].RefList(), overrideMatching, false) + } + + if latest { + result.FilterLatestRefs() + } + + sourceDescription := make([]string, len(sources)) + for i, s := range sources { + sourceDescription[i] = fmt.Sprintf("'%s'", s.Name) + } + + snapshot = deb.NewSnapshotFromRefList(body.Destination, sources, result, + fmt.Sprintf("Merged from sources: %s", strings.Join(sourceDescription, ", "))) + + err = collectionFactory.SnapshotCollection().Add(snapshot) + if err != nil { + return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to create snapshot: %s", err) + } + + return &task.ProcessReturnValue{Code: http.StatusCreated, Value: snapshot}, nil + }) +} diff --git a/system/t12_api/snapshots.py b/system/t12_api/snapshots.py index f51395f4..0d12066a 100644 --- a/system/t12_api/snapshots.py +++ b/system/t12_api/snapshots.py @@ -262,9 +262,9 @@ class SnapshotsAPITestDiff(APITest): self.check_equal(self.upload("/api/files/" + d, "libboost-program-options-dev_1.49.0.1_i386.deb").status_code, 200) - self.check_equal(self.post_task("/api/repos/" + repo_name + "/file/" + d).json()['State'], TASK_SUCCEEDED) + self.check_equal(self.post_task("/api/repos/" + repos[-1] + "/file/" + d).json()['State'], TASK_SUCCEEDED) - resp = self.post_task("/api/repos/" + repo_name + '/snapshots', json={'Name': snapshots[0]}) + resp = self.post_task("/api/repos/" + repos[-1] + '/snapshots', json={'Name': snapshots[0]}) self.check_equal(resp.json()['State'], TASK_SUCCEEDED) resp = self.post_task("/api/snapshots", json={'Name': snapshots[1]}) @@ -287,3 +287,100 @@ class SnapshotsAPITestDiff(APITest): resp = self.get("/api/snapshots/" + snapshots[1] + "/diff/" + snapshots[1]) self.check_equal(resp.status_code, 200) self.check_equal(resp.json(), []) + + +class SnapshotsAPITestMerge(APITest): + """ + POST /api/snapshots, GET /api/snapshots/merge, GET /api/snapshots/:name, DELETE /api/snapshots/:name + """ + + def check(self): + sources = [ + {"Description": "fun snapshot", "Name": self.random_name()} + for _ in range(2) + ] + + # create source snapshots + for source in sources: + resp = self.post_task("/api/snapshots", json=source) + self.check_equal(resp.json()["State"], TASK_SUCCEEDED) + + # create merge snapshot + merged_name = self.random_name() + resp = self.post_task( + "/api/snapshots/merge", + json={ + "Destination": merged_name, + "Sources": [source["Name"] for source in sources], + }, + ) + self.check_equal(resp.json()["State"], TASK_SUCCEEDED) + + # check merge snapshot + resp = self.get(f"/api/snapshots/{merged_name}") + self.check_equal(resp.status_code, 200) + source_list = ", ".join(f"'{source['Name']}'" for source in sources) + self.check_subset( + { + "Name": merged_name, + "Description": f"Merged from sources: {source_list}", + }, + resp.json(), + ) + + # remove merge snapshot + self.check_equal( + self.delete_task(f"/api/snapshots/{merged_name}").json()["State"], TASK_SUCCEEDED + ) + + # create merge snapshot without sources + merged_name = self.random_name() + resp = self.post( + "/api/snapshots/merge", json={"Destination": merged_name, "Sources": []} + ) + self.check_equal(resp.status_code, 400) + self.check_equal( + resp.json()["error"], "At least one source snapshot is required" + ) + self.check_equal(self.get(f"/api/snapshots/{merged_name}").status_code, 404) + + # create merge snapshot with non-existing source + merged_name = self.random_name() + non_existing_source = self.random_name() + resp = self.post( + "/api/snapshots/merge", + json={"Destination": merged_name, "Sources": [non_existing_source]}, + ) + self.check_equal( + resp.json()["error"], f"snapshot with name {non_existing_source} not found" + ) + self.check_equal(resp.status_code, 404) + + self.check_equal(self.get(f"/api/snapshots/{merged_name}").status_code, 404) + + # create merge snapshot with used name + merged_name = sources[0]["Name"] + resp = self.post( + "/api/snapshots/merge", + json={"Destination": merged_name, "Sources": [source["Name"] for source in sources]}, + ) + self.check_equal( + resp.json()["error"], + f"unable to create snapshot: snapshot with name {sources[0]['Name']} already exists", + ) + self.check_equal(resp.status_code, 500) + + # create merge snapshot with "latest" and "no-remove" flags (should fail) + merged_name = self.random_name() + resp = self.post( + "/api/snapshots/merge", + json={ + "Destination": merged_name, + "Sources": [source["Name"] for source in sources], + }, + params={"latest": "1", "no-remove": "1"}, + ) + self.check_equal( + resp.json()["error"], "no-remove and latest are mutually exclusive" + ) + self.check_equal(resp.status_code, 400)