Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions service/internal/builds/builds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,3 +798,84 @@ func TestLoadIndexBuilderEditions(t *testing.T) {
}
}
}

// A board that goes split-only stops publishing its plain NOR package; the
// index must stop offering the last one it kept. A board publishing both, an
// edition with no split package and a board whose build failed keep theirs.
func TestLoadIndexDropsPlainSupersededBySplit(t *testing.T) {
pool := dbtest.New(t)
ctx := context.Background()
d1 := time.Date(2026, 10, 21, 18, 0, 0, 0, time.UTC)
build := func(id string, at time.Time, names ...string) *Payload {
p := &Payload{Schema: 1, Source: "firmware",
Build: Build{ID: id, Release: id, SHA: strings.Repeat("a", 40), BuiltAt: at, PublishedAt: at.Add(time.Hour)}}
for _, n := range names {
p.Assets = append(p.Assets, Asset{Name: n, Size: 7_000_000, SHA256: sum})
}
return p
}
old := build("nightly-20261021-57ae948", d1,
"openipc.hi3516ev300-nor-lite.tgz", "openipc.hi3516ev300-nor-ultimate.tgz",
"openipc.hi3516ev300-nor-neo.tgz", "openipc.gk7205v200-nor-lite.tgz",
"openipc.hi3516cv300-nor-lite.tgz")
cur := build("nightly-20261022-020f895", d1.Add(24*time.Hour),
"openipc.hi3516ev300-nor-lite-split.tgz", "openipc.hi3516ev300-nor-ultimate-split.tgz",
"openipc.hi3516ev300-nor-neo.tgz",
"openipc.gk7205v200-nor-lite.tgz", "openipc.gk7205v200-nor-lite-split.tgz")
for _, p := range []*Payload{old, cur} {
if _, err := Save(ctx, pool, p, "test"); err != nil {
t.Fatal(err)
}
}
idx, err := LoadIndex(ctx, pool)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"openipc.hi3516ev300-nor-lite.tgz", "openipc.hi3516ev300-nor-ultimate.tgz"} {
if a, ok := idx.Asset(name); ok {
t.Errorf("%s is still offered, from %s", name, a.Release)
}
}
for name, release := range map[string]string{
"openipc.hi3516ev300-nor-lite-split.tgz": "nightly-20261022-020f895",
"openipc.hi3516ev300-nor-neo.tgz": "nightly-20261022-020f895",
"openipc.gk7205v200-nor-lite.tgz": "nightly-20261022-020f895",
"openipc.gk7205v200-nor-lite-split.tgz": "nightly-20261022-020f895",
// its build failed on the 22nd: the last good package stays
"openipc.hi3516cv300-nor-lite.tgz": "nightly-20261021-57ae948",
} {
if a, ok := idx.Asset(name); !ok || a.Release != release {
t.Errorf("%s: %+v %v, want it from %s", name, a, ok, release)
}
}
}

// Two builds with one timestamp rank by id, in LoadIndex's query and in the
// split check alike: the later id's split package replaces the earlier's
// plain one.
func TestLoadIndexSplitSupersedesOnTiedTimestamp(t *testing.T) {
pool := dbtest.New(t)
ctx := context.Background()
at := time.Date(2026, 10, 22, 18, 0, 0, 0, time.UTC)
for id, name := range map[string]string{
"nightly-20261022-aaaaaaa": "openipc.hi3518ev300-nor-lite.tgz",
"nightly-20261022-bbbbbbb": "openipc.hi3518ev300-nor-lite-split.tgz",
} {
p := &Payload{Schema: 1, Source: "firmware",
Build: Build{ID: id, Release: id, SHA: strings.Repeat("a", 40), BuiltAt: at, PublishedAt: at.Add(time.Hour)},
Assets: []Asset{{Name: name, Size: 7_000_000, SHA256: sum}}}
if _, err := Save(ctx, pool, p, "test"); err != nil {
t.Fatal(err)
}
}
idx, err := LoadIndex(ctx, pool)
if err != nil {
t.Fatal(err)
}
if a, ok := idx.Asset("openipc.hi3518ev300-nor-lite.tgz"); ok {
t.Errorf("plain lite still offered, from %s", a.Release)
}
if _, ok := idx.Asset("openipc.hi3518ev300-nor-lite-split.tgz"); !ok {
t.Errorf("split lite missing")
}
}
43 changes: 41 additions & 2 deletions service/internal/builds/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,32 @@ func LoadIndex(ctx context.Context, pool *pgxpool.Pool) (*firmware.Index, error)
}

rows, err := pool.Query(ctx, `
SELECT DISTINCT ON (a.name) a.name, a.size, a.sha256, b.release
SELECT DISTINCT ON (a.name) a.name, a.size, a.sha256, b.release, b.built_at, b.id
FROM build_assets a JOIN builds b ON b.id = a.build_id
WHERE b.source IN ('firmware', 'uboot')
ORDER BY a.name, b.built_at DESC, b.id DESC`)
if err != nil {
return nil, err
}
var assets []firmware.Asset
built := map[string]buildOrder{}
for rows.Next() {
var a firmware.Asset
var sha string
if err := rows.Scan(&a.Name, &a.Size, &sha, &a.Release); err != nil {
var at buildOrder
if err := rows.Scan(&a.Name, &a.Size, &sha, &a.Release, &at.at, &at.id); err != nil {
rows.Close()
return nil, err
}
a.Digest = "sha256:" + sha
assets = append(assets, a)
built[a.Name] = at
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
assets = dropSupersededPlain(assets, built)
builder, err := loadBuilderEditions(ctx, pool)
if err != nil {
return nil, err
Expand Down Expand Up @@ -272,3 +276,38 @@ func (s *Source) listen(ctx context.Context) error {
s.reload(ctx, "build "+n.Payload)
}
}

// dropSupersededPlain leaves out a plain NOR package whose split package was
// published by a newer build than the plain one's newest. A board that goes
// split-only (hi3516ev300 and hi3518ev300 lite and ultimate) stops
// publishing the plain package, but the index keeps each name's newest
// retained build, so the last plain package would otherwise stay on offer as
// fixed slots for the whole retention period. A board that publishes both
// does so in the same build and keeps both; a board whose build failed
// publishes neither and keeps both as they were.
func dropSupersededPlain(assets []firmware.Asset, built map[string]buildOrder) []firmware.Asset {
out := assets[:0]
for _, a := range assets {
if board, storage, edition, ok := ParseAssetName(a.Name); ok && storage == "nor" {
if at, split := built[firmware.SplitName(board, edition)]; split && at.newer(built[a.Name]) {
continue
Comment thread
widgetii marked this conversation as resolved.
}
}
out = append(out, a)
}
return out
}

// buildOrder is where a build stands in the order the index query ranks
// them: built_at, then id when two builds share a timestamp.
type buildOrder struct {
at time.Time
id string
}

func (b buildOrder) newer(o buildOrder) bool {
if !b.at.Equal(o.at) {
return b.at.After(o.at)
}
return b.id > o.id
}
Loading