Skip to content

Commit 50b9663

Browse files
committed
Add edit mirror API endpoint
1 parent a65f79e commit 50b9663

4 files changed

Lines changed: 303 additions & 0 deletions

File tree

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,4 @@ List of contributors, in chronological order:
8080
* Roman Lebedev (https://github.com/LebedevRI)
8181
* Brian Witt (https://github.com/bwitt)
8282
* Ales Bregar (https://github.com/abregar)
83+
* Tom Nguyen (https://github.com/lecafard)

api/mirror.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,36 @@ func getVerifier(keyRings []string) (pgp.Verifier, error) {
3131
return verifier, nil
3232
}
3333

34+
// stringSlicesEqual compares two string slices for equality (order matters)
35+
func stringSlicesEqual(a, b []string) bool {
36+
if len(a) != len(b) {
37+
return false
38+
}
39+
for i := range a {
40+
if a[i] != b[i] {
41+
return false
42+
}
43+
}
44+
return true
45+
}
46+
47+
// uniqueStrings returns a new slice with only unique strings from the input, sorted
48+
func uniqueStrings(input []string) []string {
49+
if len(input) == 0 {
50+
return input
51+
}
52+
seen := make(map[string]struct{}, len(input))
53+
result := make([]string, 0, len(input))
54+
for _, s := range input {
55+
if _, ok := seen[s]; !ok {
56+
seen[s] = struct{}{}
57+
result = append(result, s)
58+
}
59+
}
60+
sort.Strings(result)
61+
return result
62+
}
63+
3464
// @Summary List Mirrors
3565
// @Description **Show list of currently available mirrors**
3666
// @Description Each mirror is returned as in “show” API.
@@ -330,6 +360,128 @@ func apiMirrorsPackages(c *gin.Context) {
330360
}
331361
}
332362

363+
type mirrorEditParams struct {
364+
// Package query that is applied to mirror packages
365+
Filter *string ` json:"Filter" example:"xserver-xorg"`
366+
// Set "true" to include dependencies of matching packages when filtering
367+
FilterWithDeps *bool ` json:"FilterWithDeps"`
368+
// Set "true" to mirror installer files
369+
DownloadInstaller *bool `json:"DownloadInstaller"`
370+
// Set "true" to mirror source packages
371+
DownloadSources *bool ` json:"DownloadSources"`
372+
// Set "true" to mirror udeb files
373+
DownloadUdebs *bool ` json:"DownloadUdebs"`
374+
// URL of the archive to mirror
375+
ArchiveURL *string ` json:"ArchiveURL" example:"http://deb.debian.org/debian"`
376+
// Comma separated list of architectures
377+
Architectures *[]string `json:"Architectures" example:"amd64"`
378+
// Gpg keyring(s) for verifying Release file if a mirror update is required.
379+
Keyrings []string ` json:"Keyrings" example:"trustedkeys.gpg"`
380+
// Set "true" to skip the verification of Release file signatures
381+
IgnoreSignatures *bool ` json:"IgnoreSignatures"`
382+
}
383+
384+
// @Summary Edit Mirror
385+
// @Description **Edit mirror config**
386+
// @Tags Mirrors
387+
// @Param name path string true "mirror name to edit"
388+
// @Consume json
389+
// @Param request body mirrorEditParams true "Parameters"
390+
// @Produce json
391+
// @Success 200 {object} deb.RemoteRepo "Mirror was edited successfully"
392+
// @Failure 400 {object} Error "Bad Request"
393+
// @Failure 404 {object} Error "Mirror not found"
394+
// @Failure 409 {object} Error "Aptly db locked"
395+
// @Failure 500 {object} Error "Internal Error"
396+
// @Router /api/mirrors/{name} [post]
397+
func apiMirrorsEdit(c *gin.Context) {
398+
var (
399+
err error
400+
b mirrorEditParams
401+
repo *deb.RemoteRepo
402+
)
403+
404+
collectionFactory := context.NewCollectionFactory()
405+
collection := collectionFactory.RemoteRepoCollection()
406+
407+
name := c.Params.ByName("name")
408+
repo, err = collection.ByName(name)
409+
if err != nil {
410+
AbortWithJSONError(c, 404, fmt.Errorf("unable to edit: %s", err))
411+
return
412+
}
413+
414+
err = repo.CheckLock()
415+
if err != nil {
416+
AbortWithJSONError(c, 409, fmt.Errorf("unable to edit: %s", err))
417+
return
418+
}
419+
420+
if c.Bind(&b) != nil {
421+
return
422+
}
423+
424+
fetchMirror := false
425+
ignoreSignatures := context.Config().GpgDisableVerify
426+
427+
if b.Filter != nil {
428+
repo.Filter = *b.Filter
429+
}
430+
if b.FilterWithDeps != nil {
431+
repo.FilterWithDeps = *b.FilterWithDeps
432+
}
433+
if b.DownloadInstaller != nil {
434+
repo.DownloadInstaller = *b.DownloadInstaller
435+
}
436+
if b.DownloadSources != nil {
437+
repo.DownloadSources = *b.DownloadSources
438+
}
439+
if b.DownloadUdebs != nil {
440+
repo.DownloadUdebs = *b.DownloadUdebs
441+
}
442+
if b.ArchiveURL != nil && *b.ArchiveURL != repo.ArchiveRoot {
443+
repo.SetArchiveRoot(*b.ArchiveURL)
444+
fetchMirror = true
445+
}
446+
if b.Architectures != nil {
447+
uniqueArchitectures := uniqueStrings(*b.Architectures)
448+
if !stringSlicesEqual(uniqueArchitectures, uniqueStrings(repo.Architectures)) {
449+
repo.Architectures = uniqueArchitectures
450+
fetchMirror = true
451+
}
452+
}
453+
if b.IgnoreSignatures != nil {
454+
ignoreSignatures = *b.IgnoreSignatures
455+
}
456+
457+
if repo.IsFlat() && repo.DownloadUdebs {
458+
AbortWithJSONError(c, 400, fmt.Errorf("unable to edit: flat mirrors don't support udebs"))
459+
return
460+
}
461+
462+
if fetchMirror {
463+
verifier, err := getVerifier(b.Keyrings)
464+
if err != nil {
465+
AbortWithJSONError(c, 500, fmt.Errorf("unable to initialize GPG verifier: %s", err))
466+
return
467+
}
468+
469+
err = repo.Fetch(context.Downloader(), verifier, ignoreSignatures)
470+
if err != nil {
471+
AbortWithJSONError(c, 500, fmt.Errorf("unable to edit: %s", err))
472+
return
473+
}
474+
}
475+
476+
err = collection.Update(repo)
477+
if err != nil {
478+
AbortWithJSONError(c, 500, fmt.Errorf("unable to edit: %s", err))
479+
return
480+
}
481+
482+
c.JSON(200, repo)
483+
}
484+
333485
type mirrorUpdateParams struct {
334486
// Change mirror name to `Name`
335487
Name string ` json:"Name" example:"mirror1"`

api/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ func Router(c *ctx.AptlyContext) http.Handler {
158158
api.GET("/mirrors/:name", apiMirrorsShow)
159159
api.GET("/mirrors/:name/packages", apiMirrorsPackages)
160160
api.POST("/mirrors", apiMirrorsCreate)
161+
api.POST("/mirrors/:name", apiMirrorsEdit)
161162
api.PUT("/mirrors/:name", apiMirrorsUpdate)
162163
api.DELETE("/mirrors/:name", apiMirrorsDrop)
163164
}

system/t12_api/mirrors.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,152 @@ def check(self):
151151
'IgnoreSignatures': True}
152152
resp = self.put_task("/api/mirrors/" + mirror_name, json=mirror_desc)
153153
self.check_task(resp)
154+
155+
156+
class MirrorsAPITestEdit(APITest):
157+
"""
158+
POST /api/mirrors/{name} - Edit mirror configuration
159+
"""
160+
def check(self):
161+
# Create a mirror first
162+
mirror_name = self.random_name()
163+
mirror_desc = {'Name': mirror_name,
164+
'ArchiveURL': 'http://repo.aptly.info/system-tests/packagecloud.io/varnishcache/varnish30/debian/',
165+
'IgnoreSignatures': True,
166+
'Distribution': 'wheezy',
167+
'Components': ['main'],
168+
'Architectures': ['amd64']}
169+
170+
resp = self.post("/api/mirrors", json=mirror_desc)
171+
self.check_equal(resp.status_code, 201)
172+
173+
# Test editing basic properties (Filter, FilterWithDeps, Download options)
174+
edit_params = {
175+
'Filter': 'varnish',
176+
'FilterWithDeps': True,
177+
'DownloadSources': True,
178+
'DownloadInstaller': False,
179+
'DownloadUdebs': False
180+
}
181+
182+
resp = self.post("/api/mirrors/" + mirror_name, json=edit_params)
183+
self.check_equal(resp.status_code, 200)
184+
self.check_subset({
185+
'Name': mirror_name,
186+
'Filter': 'varnish',
187+
'FilterWithDeps': True,
188+
'DownloadSources': True
189+
}, resp.json())
190+
191+
# Verify the changes persisted
192+
resp = self.get("/api/mirrors/" + mirror_name)
193+
self.check_equal(resp.status_code, 200)
194+
self.check_subset({
195+
'Filter': 'varnish',
196+
'FilterWithDeps': True,
197+
'DownloadSources': True
198+
}, resp.json())
199+
200+
# Test editing with empty filter to clear it
201+
edit_params = {'Filter': ''}
202+
resp = self.post("/api/mirrors/" + mirror_name, json=edit_params)
203+
self.check_equal(resp.status_code, 200)
204+
self.check_equal(resp.json()['Filter'], '')
205+
206+
207+
class MirrorsAPITestEditNotFound(APITest):
208+
"""
209+
POST /api/mirrors/{name} - Edit non-existent mirror
210+
"""
211+
def check(self):
212+
resp = self.post("/api/mirrors/non-existent-mirror", json={'Filter': 'test'})
213+
self.check_equal(resp.status_code, 404)
214+
self.check_in('unable to edit', resp.json()['error'])
215+
216+
217+
class MirrorsAPITestEditArchitectures(APITest):
218+
"""
219+
POST /api/mirrors/{name} - Edit mirror architectures (triggers fetch)
220+
"""
221+
def check(self):
222+
# Create a mirror
223+
mirror_name = self.random_name()
224+
mirror_desc = {'Name': mirror_name,
225+
'ArchiveURL': 'http://repo.aptly.info/system-tests/security.debian.org/debian-security/',
226+
'IgnoreSignatures': True,
227+
'Distribution': 'buster/updates',
228+
'Components': ['main'],
229+
'Architectures': ['amd64']}
230+
231+
resp = self.post("/api/mirrors", json=mirror_desc)
232+
self.check_equal(resp.status_code, 201)
233+
234+
# Edit architectures (should trigger a fetch)
235+
edit_params = {
236+
'Architectures': ['amd64', 'i386'],
237+
'IgnoreSignatures': True
238+
}
239+
240+
resp = self.post("/api/mirrors/" + mirror_name, json=edit_params)
241+
self.check_equal(resp.status_code, 200)
242+
243+
# Verify architectures were updated
244+
resp = self.get("/api/mirrors/" + mirror_name)
245+
self.check_equal(resp.status_code, 200)
246+
architectures = resp.json()['Architectures']
247+
self.check_equal(sorted(architectures), ['amd64', 'i386'])
248+
249+
250+
class MirrorsAPITestEditArchiveURL(APITest):
251+
"""
252+
POST /api/mirrors/{name} - Edit mirror archive URL (triggers fetch)
253+
"""
254+
def check(self):
255+
# Create a mirror
256+
mirror_name = self.random_name()
257+
mirror_desc = {'Name': mirror_name,
258+
'ArchiveURL': 'http://repo.aptly.info/system-tests/ftp.ru.debian.org/debian',
259+
'IgnoreSignatures': True,
260+
'Distribution': 'bookworm',
261+
'Components': ['main'],
262+
'Architectures': ['amd64']}
263+
264+
resp = self.post("/api/mirrors", json=mirror_desc)
265+
self.check_equal(resp.status_code, 201)
266+
267+
# Edit archive URL (should trigger a fetch)
268+
edit_params = {
269+
'ArchiveURL': 'http://repo.aptly.info/system-tests/ftp.ch.debian.org/debian',
270+
'IgnoreSignatures': True
271+
}
272+
273+
resp = self.post("/api/mirrors/" + mirror_name, json=edit_params)
274+
self.check_equal(resp.status_code, 200)
275+
276+
# Verify URL was updated
277+
resp = self.get("/api/mirrors/" + mirror_name)
278+
self.check_equal(resp.status_code, 200)
279+
self.check_equal(resp.json()['ArchiveRoot'], 'http://repo.aptly.info/system-tests/ftp.ch.debian.org/debian/')
280+
281+
282+
class MirrorsAPITestEditFlatMirrorUdebs(APITest):
283+
"""
284+
POST /api/mirrors/{name} - Edit flat mirror with udebs (should fail)
285+
"""
286+
def check(self):
287+
# Create a flat mirror
288+
mirror_name = self.random_name()
289+
mirror_desc = {'Name': mirror_name,
290+
'ArchiveURL': 'http://repo.aptly.info/system-tests/cloud.r-project.org/bin/linux/debian/bullseye-cran40/',
291+
'IgnoreSignatures': True,
292+
'Architectures': ['amd64']}
293+
294+
resp = self.post("/api/mirrors", json=mirror_desc)
295+
self.check_equal(resp.status_code, 201)
296+
297+
# Try to enable udebs on a flat mirror (should fail)
298+
edit_params = {'DownloadUdebs': True}
299+
300+
resp = self.post("/api/mirrors/" + mirror_name, json=edit_params)
301+
self.check_equal(resp.status_code, 400)
302+
self.check_in("flat mirrors don't support udebs", resp.json()['error'])

0 commit comments

Comments
 (0)