diff --git a/AUTHORS b/AUTHORS index 67aa1eb22..f5b04e62b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -80,3 +80,4 @@ List of contributors, in chronological order: * Roman Lebedev (https://github.com/LebedevRI) * Brian Witt (https://github.com/bwitt) * Ales Bregar (https://github.com/abregar) +* Tom Nguyen (https://github.com/lecafard) \ No newline at end of file diff --git a/api/mirror.go b/api/mirror.go index 60c8f23c7..38b36b11c 100644 --- a/api/mirror.go +++ b/api/mirror.go @@ -31,6 +31,36 @@ func getVerifier(keyRings []string) (pgp.Verifier, error) { return verifier, nil } +// stringSlicesEqual compares two string slices for equality (order matters) +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// uniqueStrings returns a new slice with only unique strings from the input, sorted +func uniqueStrings(input []string) []string { + if len(input) == 0 { + return input + } + seen := make(map[string]struct{}, len(input)) + result := make([]string, 0, len(input)) + for _, s := range input { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + sort.Strings(result) + return result +} + // @Summary List Mirrors // @Description **Show list of currently available mirrors** // @Description Each mirror is returned as in “show” API. @@ -330,6 +360,128 @@ func apiMirrorsPackages(c *gin.Context) { } } +type mirrorEditParams struct { + // Package query that is applied to mirror packages + Filter *string ` json:"Filter" example:"xserver-xorg"` + // Set "true" to include dependencies of matching packages when filtering + FilterWithDeps *bool ` json:"FilterWithDeps"` + // Set "true" to mirror installer files + DownloadInstaller *bool `json:"DownloadInstaller"` + // Set "true" to mirror source packages + DownloadSources *bool ` json:"DownloadSources"` + // Set "true" to mirror udeb files + DownloadUdebs *bool ` json:"DownloadUdebs"` + // URL of the archive to mirror + ArchiveURL *string ` json:"ArchiveURL" example:"http://deb.debian.org/debian"` + // Comma separated list of architectures + Architectures *[]string `json:"Architectures" example:"amd64"` + // Gpg keyring(s) for verifying Release file if a mirror update is required. + Keyrings []string ` json:"Keyrings" example:"trustedkeys.gpg"` + // Set "true" to skip the verification of Release file signatures + IgnoreSignatures *bool ` json:"IgnoreSignatures"` +} + +// @Summary Edit Mirror +// @Description **Edit mirror config** +// @Tags Mirrors +// @Param name path string true "mirror name to edit" +// @Consume json +// @Param request body mirrorEditParams true "Parameters" +// @Produce json +// @Success 200 {object} deb.RemoteRepo "Mirror was edited successfully" +// @Failure 400 {object} Error "Bad Request" +// @Failure 404 {object} Error "Mirror not found" +// @Failure 409 {object} Error "Aptly db locked" +// @Failure 500 {object} Error "Internal Error" +// @Router /api/mirrors/{name} [post] +func apiMirrorsEdit(c *gin.Context) { + var ( + err error + b mirrorEditParams + repo *deb.RemoteRepo + ) + + collectionFactory := context.NewCollectionFactory() + collection := collectionFactory.RemoteRepoCollection() + + name := c.Params.ByName("name") + repo, err = collection.ByName(name) + if err != nil { + AbortWithJSONError(c, 404, fmt.Errorf("unable to edit: %s", err)) + return + } + + err = repo.CheckLock() + if err != nil { + AbortWithJSONError(c, 409, fmt.Errorf("unable to edit: %s", err)) + return + } + + if c.Bind(&b) != nil { + return + } + + fetchMirror := false + ignoreSignatures := context.Config().GpgDisableVerify + + if b.Filter != nil { + repo.Filter = *b.Filter + } + if b.FilterWithDeps != nil { + repo.FilterWithDeps = *b.FilterWithDeps + } + if b.DownloadInstaller != nil { + repo.DownloadInstaller = *b.DownloadInstaller + } + if b.DownloadSources != nil { + repo.DownloadSources = *b.DownloadSources + } + if b.DownloadUdebs != nil { + repo.DownloadUdebs = *b.DownloadUdebs + } + if b.ArchiveURL != nil && *b.ArchiveURL != repo.ArchiveRoot { + repo.SetArchiveRoot(*b.ArchiveURL) + fetchMirror = true + } + if b.Architectures != nil { + uniqueArchitectures := uniqueStrings(*b.Architectures) + if !stringSlicesEqual(uniqueArchitectures, uniqueStrings(repo.Architectures)) { + repo.Architectures = uniqueArchitectures + fetchMirror = true + } + } + if b.IgnoreSignatures != nil { + ignoreSignatures = *b.IgnoreSignatures + } + + if repo.IsFlat() && repo.DownloadUdebs { + AbortWithJSONError(c, 400, fmt.Errorf("unable to edit: flat mirrors don't support udebs")) + return + } + + if fetchMirror { + verifier, err := getVerifier(b.Keyrings) + if err != nil { + AbortWithJSONError(c, 500, fmt.Errorf("unable to initialize GPG verifier: %s", err)) + return + } + + err = repo.Fetch(context.Downloader(), verifier, ignoreSignatures) + if err != nil { + AbortWithJSONError(c, 500, fmt.Errorf("unable to edit: %s", err)) + return + } + } + + err = collection.Update(repo) + if err != nil { + AbortWithJSONError(c, 500, fmt.Errorf("unable to edit: %s", err)) + return + } + + c.JSON(200, repo) +} + type mirrorUpdateParams struct { // Change mirror name to `Name` Name string ` json:"Name" example:"mirror1"` diff --git a/api/router.go b/api/router.go index 3cd7d4271..db9c517ec 100644 --- a/api/router.go +++ b/api/router.go @@ -158,6 +158,7 @@ func Router(c *ctx.AptlyContext) http.Handler { api.GET("/mirrors/:name", apiMirrorsShow) api.GET("/mirrors/:name/packages", apiMirrorsPackages) api.POST("/mirrors", apiMirrorsCreate) + api.POST("/mirrors/:name", apiMirrorsEdit) api.PUT("/mirrors/:name", apiMirrorsUpdate) api.DELETE("/mirrors/:name", apiMirrorsDrop) } diff --git a/system/t12_api/mirrors.py b/system/t12_api/mirrors.py index 76104f398..a38efe3ef 100644 --- a/system/t12_api/mirrors.py +++ b/system/t12_api/mirrors.py @@ -151,3 +151,152 @@ def check(self): 'IgnoreSignatures': True} resp = self.put_task("/api/mirrors/" + mirror_name, json=mirror_desc) self.check_task(resp) + + +class MirrorsAPITestEdit(APITest): + """ + POST /api/mirrors/{name} - Edit mirror configuration + """ + def check(self): + # Create a mirror first + mirror_name = self.random_name() + mirror_desc = {'Name': mirror_name, + 'ArchiveURL': 'http://repo.aptly.info/system-tests/packagecloud.io/varnishcache/varnish30/debian/', + 'IgnoreSignatures': True, + 'Distribution': 'wheezy', + 'Components': ['main'], + 'Architectures': ['amd64']} + + resp = self.post("/api/mirrors", json=mirror_desc) + self.check_equal(resp.status_code, 201) + + # Test editing basic properties (Filter, FilterWithDeps, Download options) + edit_params = { + 'Filter': 'varnish', + 'FilterWithDeps': True, + 'DownloadSources': True, + 'DownloadInstaller': False, + 'DownloadUdebs': False + } + + resp = self.post("/api/mirrors/" + mirror_name, json=edit_params) + self.check_equal(resp.status_code, 200) + self.check_subset({ + 'Name': mirror_name, + 'Filter': 'varnish', + 'FilterWithDeps': True, + 'DownloadSources': True + }, resp.json()) + + # Verify the changes persisted + resp = self.get("/api/mirrors/" + mirror_name) + self.check_equal(resp.status_code, 200) + self.check_subset({ + 'Filter': 'varnish', + 'FilterWithDeps': True, + 'DownloadSources': True + }, resp.json()) + + # Test editing with empty filter to clear it + edit_params = {'Filter': ''} + resp = self.post("/api/mirrors/" + mirror_name, json=edit_params) + self.check_equal(resp.status_code, 200) + self.check_equal(resp.json()['Filter'], '') + + +class MirrorsAPITestEditNotFound(APITest): + """ + POST /api/mirrors/{name} - Edit non-existent mirror + """ + def check(self): + resp = self.post("/api/mirrors/non-existent-mirror", json={'Filter': 'test'}) + self.check_equal(resp.status_code, 404) + self.check_in('unable to edit', resp.json()['error']) + + +class MirrorsAPITestEditArchitectures(APITest): + """ + POST /api/mirrors/{name} - Edit mirror architectures (triggers fetch) + """ + def check(self): + # Create a mirror + mirror_name = self.random_name() + mirror_desc = {'Name': mirror_name, + 'ArchiveURL': 'http://repo.aptly.info/system-tests/security.debian.org/debian-security/', + 'IgnoreSignatures': True, + 'Distribution': 'buster/updates', + 'Components': ['main'], + 'Architectures': ['amd64']} + + resp = self.post("/api/mirrors", json=mirror_desc) + self.check_equal(resp.status_code, 201) + + # Edit architectures (should trigger a fetch) + edit_params = { + 'Architectures': ['amd64', 'i386'], + 'IgnoreSignatures': True + } + + resp = self.post("/api/mirrors/" + mirror_name, json=edit_params) + self.check_equal(resp.status_code, 200) + + # Verify architectures were updated + resp = self.get("/api/mirrors/" + mirror_name) + self.check_equal(resp.status_code, 200) + architectures = resp.json()['Architectures'] + self.check_equal(sorted(architectures), ['amd64', 'i386']) + + +class MirrorsAPITestEditArchiveURL(APITest): + """ + POST /api/mirrors/{name} - Edit mirror archive URL (triggers fetch) + """ + def check(self): + # Create a mirror + mirror_name = self.random_name() + mirror_desc = {'Name': mirror_name, + 'ArchiveURL': 'http://repo.aptly.info/system-tests/ftp.ru.debian.org/debian', + 'IgnoreSignatures': True, + 'Distribution': 'bookworm', + 'Components': ['main'], + 'Architectures': ['amd64']} + + resp = self.post("/api/mirrors", json=mirror_desc) + self.check_equal(resp.status_code, 201) + + # Edit archive URL (should trigger a fetch) + edit_params = { + 'ArchiveURL': 'http://repo.aptly.info/system-tests/ftp.ch.debian.org/debian', + 'IgnoreSignatures': True + } + + resp = self.post("/api/mirrors/" + mirror_name, json=edit_params) + self.check_equal(resp.status_code, 200) + + # Verify URL was updated + resp = self.get("/api/mirrors/" + mirror_name) + self.check_equal(resp.status_code, 200) + self.check_equal(resp.json()['ArchiveRoot'], 'http://repo.aptly.info/system-tests/ftp.ch.debian.org/debian/') + + +class MirrorsAPITestEditFlatMirrorUdebs(APITest): + """ + POST /api/mirrors/{name} - Edit flat mirror with udebs (should fail) + """ + def check(self): + # Create a flat mirror + mirror_name = self.random_name() + mirror_desc = {'Name': mirror_name, + 'ArchiveURL': 'http://repo.aptly.info/system-tests/cloud.r-project.org/bin/linux/debian/bullseye-cran40/', + 'IgnoreSignatures': True, + 'Architectures': ['amd64']} + + resp = self.post("/api/mirrors", json=mirror_desc) + self.check_equal(resp.status_code, 201) + + # Try to enable udebs on a flat mirror (should fail) + edit_params = {'DownloadUdebs': True} + + resp = self.post("/api/mirrors/" + mirror_name, json=edit_params) + self.check_equal(resp.status_code, 400) + self.check_in("flat mirrors don't support udebs", resp.json()['error'])