From 4c58bd51b85bd8f8f81ac62580c7feca06e1065c Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Wed, 22 Jul 2026 11:18:47 +0200 Subject: [PATCH 1/4] fix: fallbacks on current archive if old release not moved yet Signed-off-by: Paul Mars --- internal/archive/archive.go | 47 +++++++++++++++++++++---------- internal/archive/archive_test.go | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 7c90a41dd..2fa755115 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -189,28 +189,31 @@ var proArchiveInfo = map[string]struct { }, } -func archiveURL(pro, arch string, oldRelease bool) (string, *credentials, error) { +func archiveURL(pro, arch string, oldRelease bool) (string, string, *credentials, error) { if pro != "" { archiveInfo, ok := proArchiveInfo[pro] if !ok { - return "", nil, fmt.Errorf("invalid pro value: %q", pro) + return "", "", nil, fmt.Errorf("invalid pro value: %q", pro) } url := archiveInfo.BaseURL creds, err := findCredentials(url) if err != nil { - return "", nil, err + return "", "", nil, err } - return url, creds, nil + return url, "", creds, nil } - if oldRelease { - return ubuntuOldReleasesURL, nil, nil + current := ubuntuURL + if arch != "amd64" && arch != "i386" { + current = ubuntuPortsURL } - - if arch == "amd64" || arch == "i386" { - return ubuntuURL, nil, nil + if oldRelease { + // The old release may not have moved from archive.ubuntu.com + // to old-releases.ubuntu.com yet. So return the current + // archive as a fallback. + return ubuntuOldReleasesURL, current, nil, nil } - return ubuntuPortsURL, nil, nil + return current, "", nil, nil } func openUbuntu(options *Options) (Archive, error) { @@ -224,21 +227,37 @@ func openUbuntu(options *Options) (Archive, error) { return nil, fmt.Errorf("archive options missing version") } - baseURL, creds, err := archiveURL(options.Pro, options.Arch, options.OldRelease) + baseURL, fallbackURL, creds, err := archiveURL(options.Pro, options.Arch, options.OldRelease) if err != nil { return nil, err } archive := &ubuntuArchive{ options: *options, - cache: &cache.Cache{ - Dir: options.CacheDir, - }, + cache: &cache.Cache{Dir: options.CacheDir}, pubKeys: options.PubKeys, baseURL: baseURL, creds: creds, } + if fallbackURL != "" { + // Check if the release is in the expected archive, use the + // fallback one if not. + probe := &ubuntuIndex{ + label: options.Label, + version: options.Version, + arch: options.Arch, + suite: options.Suites[0], + archive: archive, + } + _, err := probe.fetch(probe.distPath("InRelease"), "", fetchDefault) + if err == errNotFound { + archive.baseURL = fallbackURL + } else if err != nil { + return nil, err + } + } + for _, suite := range options.Suites { var release control.Section for _, component := range options.Components { diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 90e3f0047..a59825da0 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -536,6 +536,54 @@ func (s *httpSuite) TestOpenUnmaintainedArchives(c *C) { c.Assert(err, IsNil) } +// TestOpenOldReleaseFallback verifies that when OldRelease is set and the +// old-releases mirror returns 404 (because the release has not yet been +// physically moved), Chisel uses the current archive. +func (s *httpSuite) TestOpenOldReleaseFallback(c *C) { + s.prepareArchive("plucky", "25.04", "amd64", []string{"main"}) + + // Override Do: return 404 for old-releases, delegate to s.Do (which + // serves the prepared content) for the current archive. + s.restore() + s.restore = archive.FakeDo(func(req *http.Request) (*http.Response, error) { + if strings.HasPrefix(req.URL.String(), "http://old-releases.ubuntu.com/ubuntu/") { + s.requestResults = append(s.requestResults, requestResult{path: req.URL.Path, status: 404}) + return &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + StatusCode: 404, + }, nil + } + return s.Do(req) + }) + + options := archive.Options{ + Label: "ubuntu", + Version: "25.04", + Arch: "amd64", + Suites: []string{"plucky"}, + Components: []string{"main"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + OldRelease: true, + } + + testArchive, err := archive.Open(&options) + c.Assert(err, IsNil) + + _, _, err = testArchive.Fetch("mypkg1") + c.Assert(err, IsNil) + + // Exactly one 404 (the probe to old-releases); all subsequent + // requests must be served by the current archive. + oldReleasesHits := 0 + for _, r := range s.requestResults { + if r.status == 404 { + oldReleasesHits++ + } + } + c.Assert(oldReleasesHits, Equals, 1) +} + type verifyArchiveReleaseTest struct { summary string pubKeys []*packet.PublicKey From fda7d25d0b840b7ac21cfa43ed715a6e9bdf9e7f Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Tue, 8 Sep 2026 09:41:13 +0200 Subject: [PATCH 2/4] refactor: implement cleaner fallback Signed-off-by: Paul Mars --- internal/archive/archive.go | 140 ++++++++++++++++--------------- internal/archive/archive_test.go | 4 +- 2 files changed, 73 insertions(+), 71 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 2fa755115..f17636224 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -157,6 +157,47 @@ func (a *ubuntuArchive) Info(pkg string) (*PackageInfo, error) { return info, nil } +// setupIndexes fetches the release and package indexes of every suite and +// component, registering the resulting indexes on the archive. +func (a *ubuntuArchive) setupIndexes() error { + for _, suite := range a.options.Suites { + var release control.Section + for _, component := range a.options.Components { + index := &ubuntuIndex{ + label: a.options.Label, + version: a.options.Version, + arch: a.options.Arch, + suite: suite, + component: component, + release: release, + archive: a, + } + if release == nil { + err := index.fetchRelease() + if err != nil { + return err + } + release = index.release + if !index.supportsArch(a.options.Arch) { + // Release does not support the specified architecture, do + // not add any of its indexes. + break + } + err = index.checkComponents(a.options.Components) + if err != nil { + return err + } + } + err := index.fetchIndex() + if err != nil { + return err + } + a.indexes = append(a.indexes, index) + } + } + return nil +} + const ubuntuURL = "http://archive.ubuntu.com/ubuntu/" const ubuntuOldReleasesURL = "http://old-releases.ubuntu.com/ubuntu/" const ubuntuPortsURL = "http://ports.ubuntu.com/ubuntu-ports/" @@ -189,18 +230,27 @@ var proArchiveInfo = map[string]struct { }, } -func archiveURL(pro, arch string, oldRelease bool) (string, string, *credentials, error) { +// candidateArchiveURLs returns the candidate base URLs of the archive, in order of +// preference, and the credentials used to access them, if any. +// +// Ubuntu releases are moved from the regular archive to +// old-releases.ubuntu.com after their end of life, but not immediately: +// until the move happens the release is only available from the regular +// archive. For such releases both archives are returned, so that the +// caller can fall back to the regular one when the release is not found +// in the old-releases one. +func candidateArchiveURLs(pro, arch string, oldRelease bool) ([]string, *credentials, error) { if pro != "" { archiveInfo, ok := proArchiveInfo[pro] if !ok { - return "", "", nil, fmt.Errorf("invalid pro value: %q", pro) + return nil, nil, fmt.Errorf("invalid pro value: %q", pro) } url := archiveInfo.BaseURL creds, err := findCredentials(url) if err != nil { - return "", "", nil, err + return nil, nil, err } - return url, "", creds, nil + return []string{url}, creds, nil } current := ubuntuURL @@ -208,12 +258,9 @@ func archiveURL(pro, arch string, oldRelease bool) (string, string, *credentials current = ubuntuPortsURL } if oldRelease { - // The old release may not have moved from archive.ubuntu.com - // to old-releases.ubuntu.com yet. So return the current - // archive as a fallback. - return ubuntuOldReleasesURL, current, nil, nil + return []string{ubuntuOldReleasesURL, current}, nil, nil } - return current, "", nil, nil + return []string{current}, nil, nil } func openUbuntu(options *Options) (Archive, error) { @@ -227,74 +274,29 @@ func openUbuntu(options *Options) (Archive, error) { return nil, fmt.Errorf("archive options missing version") } - baseURL, fallbackURL, creds, err := archiveURL(options.Pro, options.Arch, options.OldRelease) + candidates, creds, err := candidateArchiveURLs(options.Pro, options.Arch, options.OldRelease) if err != nil { return nil, err } - archive := &ubuntuArchive{ - options: *options, - cache: &cache.Cache{Dir: options.CacheDir}, - pubKeys: options.PubKeys, - baseURL: baseURL, - creds: creds, - } - - if fallbackURL != "" { - // Check if the release is in the expected archive, use the - // fallback one if not. - probe := &ubuntuIndex{ - label: options.Label, - version: options.Version, - arch: options.Arch, - suite: options.Suites[0], - archive: archive, + // Try the candidate archives in order until the release is found. + for _, baseURL := range candidates { + archive := &ubuntuArchive{ + options: *options, + cache: &cache.Cache{Dir: options.CacheDir}, + pubKeys: options.PubKeys, + baseURL: baseURL, + creds: creds, } - _, err := probe.fetch(probe.distPath("InRelease"), "", fetchDefault) - if err == errNotFound { - archive.baseURL = fallbackURL - } else if err != nil { - return nil, err + err := archive.setupIndexes() + if err == nil { + return archive, nil } - } - - for _, suite := range options.Suites { - var release control.Section - for _, component := range options.Components { - index := &ubuntuIndex{ - label: options.Label, - version: options.Version, - arch: options.Arch, - suite: suite, - component: component, - release: release, - archive: archive, - } - if release == nil { - err := index.fetchRelease() - if err != nil { - return nil, err - } - release = index.release - if !index.supportsArch(options.Arch) { - // Release does not support the specified architecture, do - // not add any of its indexes. - break - } - err = index.checkComponents(options.Components) - if err != nil { - return nil, err - } - } - err := index.fetchIndex() - if err != nil { - return nil, err - } - archive.indexes = append(archive.indexes, index) + if err != errNotFound { + return nil, err } } - - return archive, nil + return nil, errNotFound } func (index *ubuntuIndex) fetchRelease() error { diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index a59825da0..0ef306d5c 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -573,8 +573,8 @@ func (s *httpSuite) TestOpenOldReleaseFallback(c *C) { _, _, err = testArchive.Fetch("mypkg1") c.Assert(err, IsNil) - // Exactly one 404 (the probe to old-releases); all subsequent - // requests must be served by the current archive. + // Exactly one 404 (the InRelease fetch from old-releases); all + // subsequent requests must be served by the current archive. oldReleasesHits := 0 for _, r := range s.requestResults { if r.status == 404 { From 3b4878ec2a8f1e1467621aa47f75648c8b338dfc Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Thu, 10 Sep 2026 09:10:00 +0200 Subject: [PATCH 3/4] style: refine to a more idiomatic error handling --- internal/archive/archive.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index f17636224..6e7d054e5 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -289,12 +289,14 @@ func openUbuntu(options *Options) (Archive, error) { creds: creds, } err := archive.setupIndexes() - if err == nil { - return archive, nil + if err == errNotFound { + // Release not in this archive, try the next candidate. + continue } - if err != errNotFound { + if err != nil { return nil, err } + return archive, nil } return nil, errNotFound } From 973791a703ee6817f43f881dc98183b80d9ce088 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Thu, 10 Sep 2026 09:15:31 +0200 Subject: [PATCH 4/4] tests: cover no release found case and refine docs --- internal/archive/archive_test.go | 44 ++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 0ef306d5c..9045c24c3 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -536,16 +536,12 @@ func (s *httpSuite) TestOpenUnmaintainedArchives(c *C) { c.Assert(err, IsNil) } -// TestOpenOldReleaseFallback verifies that when OldRelease is set and the -// old-releases mirror returns 404 (because the release has not yet been -// physically moved), Chisel uses the current archive. func (s *httpSuite) TestOpenOldReleaseFallback(c *C) { s.prepareArchive("plucky", "25.04", "amd64", []string{"main"}) - // Override Do: return 404 for old-releases, delegate to s.Do (which - // serves the prepared content) for the current archive. - s.restore() - s.restore = archive.FakeDo(func(req *http.Request) (*http.Response, error) { + // The old-releases mirror 404s the release (it has not been physically + // moved yet); the current archive serves the prepared content. + do := func(req *http.Request) (*http.Response, error) { if strings.HasPrefix(req.URL.String(), "http://old-releases.ubuntu.com/ubuntu/") { s.requestResults = append(s.requestResults, requestResult{path: req.URL.Path, status: 404}) return &http.Response{ @@ -554,7 +550,9 @@ func (s *httpSuite) TestOpenOldReleaseFallback(c *C) { }, nil } return s.Do(req) - }) + } + restoreDo := archive.FakeDo(do) + defer restoreDo() options := archive.Options{ Label: "ubuntu", @@ -584,6 +582,36 @@ func (s *httpSuite) TestOpenOldReleaseFallback(c *C) { c.Assert(oldReleasesHits, Equals, 1) } +func (s *httpSuite) TestOpenOldReleaseNotFound(c *C) { + // No candidate archive distributes the release: accept requests from + // any host and 404 them all. + s.base = "" + s.status = 404 + + options := archive.Options{ + Label: "ubuntu", + Version: "25.04", + Arch: "amd64", + Suites: []string{"plucky"}, + Components: []string{"main"}, + CacheDir: c.MkDir(), + PubKeys: []*packet.PublicKey{s.pubKey}, + OldRelease: true, + } + + _, err := archive.Open(&options) + c.Assert(err, ErrorMatches, "cannot find archive data") + + // Both candidates must have been tried, one InRelease fetch each. + suites := 0 + for _, r := range s.requestResults { + if strings.HasSuffix(r.path, "/dists/plucky/InRelease") { + suites++ + } + } + c.Assert(suites, Equals, 2) +} + type verifyArchiveReleaseTest struct { summary string pubKeys []*packet.PublicKey