From 5182f93476ea2735c717398cac980116e628b9c3 Mon Sep 17 00:00:00 2001 From: yannic rieger Date: Sun, 13 Sep 2026 17:50:21 +0200 Subject: [PATCH 1/3] store file hashes when building the flavor version --- controlplane/chunk/flavor.go | 112 +++++++++++++++--- controlplane/chunk/flavor_internal_test.go | 77 ++++++++++++ controlplane/chunk/flavor_test.go | 20 ++-- controlplane/chunk/repository.go | 2 + controlplane/errors/error.go | 1 + controlplane/postgres/flavor.go | 40 +++++-- internal/mock/chunk_repository.go | 52 +++++++- .../functional/controlplane/chunk_api_test.go | 57 ++++++--- test/testing.go | 44 +++++++ 9 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 controlplane/chunk/flavor_internal_test.go diff --git a/controlplane/chunk/flavor.go b/controlplane/chunk/flavor.go index bfafaa7f2..d238f06af 100644 --- a/controlplane/chunk/flavor.go +++ b/controlplane/chunk/flavor.go @@ -22,7 +22,9 @@ import ( "context" "errors" "fmt" + "io" "maps" + "os" "path/filepath" "slices" "sort" @@ -36,6 +38,7 @@ import ( "github.com/spacechunks/explorer/controlplane/job" "github.com/spacechunks/explorer/internal/file" "github.com/spacechunks/explorer/internal/resource" + "github.com/spacechunks/explorer/internal/tarhelper" "go.opentelemetry.io/otel/trace" ) @@ -136,9 +139,23 @@ func (s *svc) CreateFlavorVersion( prevVersion, err := s.repo.LatestFlavorVersion(ctx, flavorID) if err != nil { - return resource.FlavorVersion{}, - resource.FlavorVersionDiff{}, - fmt.Errorf("latest flavor version file hashes: %w", err) + // super, super, ugly, but as of right now i don't want to refactor + // (it returns ErrNotFound, if this is the first flavor version) + if errors.Is(err, apierrs.ErrNotFound) { + prevVersion.FilesUploaded = true + } else { + return resource.FlavorVersion{}, + resource.FlavorVersionDiff{}, + fmt.Errorf("latest flavor version file hashes: %w", err) + } + } + + // we do not allow creating a new flavor version when the previous one did not have their + // files uploaded, because we depend on the uploaded files, when building the image later. + // this is because we only upload what changed between versions. if the previous changes + // are not uploaded to s3, the build_image job will fail. + if !prevVersion.FilesUploaded { + return resource.FlavorVersion{}, resource.FlavorVersionDiff{}, apierrs.ErrPreviousFilesNotUploaded } newContentTree, err := file.HashTree(version.FileHashes) @@ -212,19 +229,6 @@ func (s *svc) CreateFlavorVersion( sortByPath(added) sortByPath(removed) - changes := make([]file.Hash, 0, len(changed)+len(added)) - changes = append(changes, changed...) - changes = append(changes, added...) - sortByPath(changes) - - all := make([]file.Hash, 0, len(unchanged)+len(changes)) - all = append(all, changes...) - all = append(all, unchanged...) - - sortByPath(all) - - version.FileHashes = all - created, err := s.repo.CreateFlavorVersion(ctx, flavorID, version, prevVersion.ID) if err != nil { return resource.FlavorVersion{}, resource.FlavorVersionDiff{}, fmt.Errorf("create flavor version: %w", err) @@ -300,6 +304,15 @@ func (s *svc) BuildFlavorVersion(ctx context.Context, versionID string) error { return apierrs.ErrFlavorFilesNotUploaded } + hashes, err := s.computeFileHashes(ctx, versionID) + if err != nil { + return fmt.Errorf("compute file hashes: %w", err) + } + + if err := s.repo.AddFlavorVersionFileHashes(ctx, versionID, hashes); err != nil { + return fmt.Errorf("add flavor version hashes: %w", err) + } + if err := s.repo.MarkFlavorVersionFilesUploaded(ctx, versionID); err != nil { return fmt.Errorf("mark files: %w", err) } @@ -412,3 +425,70 @@ func (s *svc) GetFlavor(ctx context.Context, id string) (resource.Flavor, error) return f, nil } + +func (s *svc) computeFileHashes(ctx context.Context, versionID string) ([]file.Hash, error) { + dir, err := os.MkdirTemp("", fmt.Sprintf("changeset-%s-*", versionID)) + if err != nil { + return nil, fmt.Errorf("create tmp dir: %w", err) + } + + set, err := os.Create(filepath.Join(dir, "changeset.tar.gz")) + if err != nil { + return nil, fmt.Errorf("tmp file: %w", err) + } + + defer set.Close() + + defer func() { + if err := os.RemoveAll(dir); err != nil { + s.logger.Error("failed to remove temp dir", "err", err) + } + }() + + if err := s.s3Store.WriteTo(ctx, blob.ChangeSetKey(versionID), set); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + if _, err := set.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("seek: %w", err) + } + + paths, err := tarhelper.Untar(set, dir) + if err != nil { + return nil, fmt.Errorf("untar: %w", err) + } + + hashes := make([]file.Hash, 0, len(paths)) + + for _, p := range paths { + if err := func() error { + f, err := os.Open(p) + if err != nil { + return fmt.Errorf("open: %w", err) + } + + defer f.Close() + + hash, err := file.ComputeHashStr(f) + if err != nil { + return fmt.Errorf("compute hash: %w", err) + } + + serverRootPath, err := filepath.Rel(dir, p) + if err != nil { + return fmt.Errorf("server root path: %w", err) + } + + hashes = append(hashes, file.Hash{ + Path: serverRootPath, + Hash: hash, + }) + + return nil + }(); err != nil { + return nil, err + } + } + + return hashes, nil +} diff --git a/controlplane/chunk/flavor_internal_test.go b/controlplane/chunk/flavor_internal_test.go new file mode 100644 index 000000000..c77eaa4c5 --- /dev/null +++ b/controlplane/chunk/flavor_internal_test.go @@ -0,0 +1,77 @@ +/* + Explorer Platform, a platform for hosting and discovering Minecraft servers. + Copyright (C) 2024 Yannic Rieger + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package chunk + +import ( + "context" + "io" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spacechunks/explorer/controlplane/blob" + "github.com/spacechunks/explorer/internal/file" + "github.com/spacechunks/explorer/internal/mock" + "github.com/spacechunks/explorer/test" + mocky "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestComputeFileHashes(t *testing.T) { + var ( + ctx = context.Background() + tarData = test.CreateTarGz(t, map[string]string{ + "server.properties": "bla", + "plugins/config.yaml": "lol", + }) + mockStore = mock.NewMockBlobS3Store(t) + versionID = "version-id" + expected = []file.Hash{ + { + Path: "server.properties", + Hash: "038d12a21e489bb2", + }, + { + Path: "plugins/config.yaml", + Hash: "7b75e34aa5423334", + }, + } + ) + + mockStore.EXPECT(). + WriteTo(mocky.Anything, blob.ChangeSetKey(versionID), mocky.Anything). + RunAndReturn(func(ctx context.Context, key string, w io.Writer) error { + _, err := w.Write(tarData) + require.NoError(t, err) + return nil + }) + + s := svc{ + s3Store: mockStore, + } + + actual, err := s.computeFileHashes(ctx, versionID) + require.NoError(t, err) + + file.SortHashes(expected) + file.SortHashes(actual) + + if d := cmp.Diff(expected, actual); d != "" { + t.Fatalf("mismatch (-want +got):\n%s", d) + } +} diff --git a/controlplane/chunk/flavor_test.go b/controlplane/chunk/flavor_test.go index a56b5cdf2..35e5eb18a 100644 --- a/controlplane/chunk/flavor_test.go +++ b/controlplane/chunk/flavor_test.go @@ -165,6 +165,10 @@ func TestCreateFlavorVersion(t *testing.T) { return version } + prevVersionFilesUploaded := fixture.FlavorVersion(func(tmp *resource.FlavorVersion) { + tmp.FilesUploaded = true + }) + tests := []struct { name string prevVersion resource.FlavorVersion @@ -182,7 +186,7 @@ func TestCreateFlavorVersion(t *testing.T) { }{ { name: "works", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.FileHashes = []file.Hash{ @@ -261,7 +265,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "cleans paths", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: uncleanPathVersion(), expected: ptr.Pointer(cleanedPathVersion()), expectedDiff: resource.FlavorVersionDiff{ @@ -326,7 +330,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "rejects relative traversal paths", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.FileHashes = []file.Hash{ @@ -390,7 +394,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "rejects absolute paths", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.FileHashes = []file.Hash{ @@ -445,7 +449,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version hash mismatch", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Hash = "some-not-matching-hash" v.FileHashes = []file.Hash{ @@ -501,7 +505,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version already exists", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(), prep: func( repo *mock.MockChunkRepository, @@ -528,7 +532,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "minecraft version unsupported", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(), prep: func( repo *mock.MockChunkRepository, @@ -559,7 +563,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "flavor deleted", - prevVersion: fixture.FlavorVersion(), + prevVersion: prevVersionFilesUploaded, newVersion: fixture.FlavorVersion(), prep: func( repo *mock.MockChunkRepository, diff --git a/controlplane/chunk/repository.go b/controlplane/chunk/repository.go index 5bf0059d3..ae5880e7e 100644 --- a/controlplane/chunk/repository.go +++ b/controlplane/chunk/repository.go @@ -22,6 +22,7 @@ import ( "context" "time" + "github.com/spacechunks/explorer/internal/file" "github.com/spacechunks/explorer/internal/resource" ) @@ -60,6 +61,7 @@ type Repository interface { MarkFlavorDeleted(ctx context.Context, id string) error FlavorByID(ctx context.Context, id string) (resource.Flavor, error) ChunkByFlavorID(ctx context.Context, flavorID string) (resource.Chunk, error) + AddFlavorVersionFileHashes(ctx context.Context, flavorVersionID string, hashes []file.Hash) error } type ArchiveRepository interface { diff --git a/controlplane/errors/error.go b/controlplane/errors/error.go index b5bf37c41..93aaf18ca 100644 --- a/controlplane/errors/error.go +++ b/controlplane/errors/error.go @@ -75,6 +75,7 @@ var ( ErrMinecraftVersionNotSupported = New(codes.FailedPrecondition, "minecraft version not found") ErrHashMismatch = New(codes.FailedPrecondition, "hash does not match") ErrFlavorFilesNotUploaded = New(codes.FailedPrecondition, "flavor files have not been uploaded") + ErrPreviousFilesNotUploaded = New(codes.FailedPrecondition, "files of the last flavor version have not been uploaded") ErrFlavorFilesUploaded = New(codes.AlreadyExists, "flavor files have already been uploaded") ErrChangeSetTarballTooBig = New(codes.InvalidArgument, "tarball size exceeds maximum allowed") ) diff --git a/controlplane/postgres/flavor.go b/controlplane/postgres/flavor.go index 56981d474..51cb443bb 100644 --- a/controlplane/postgres/flavor.go +++ b/controlplane/postgres/flavor.go @@ -120,7 +120,7 @@ func (db *DB) LatestFlavorVersion(ctx context.Context, flavorID string) (resourc latest, err := q.LatestFlavorVersionByFlavorID(ctx, flavorID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil + return apierrs.ErrNotFound } return fmt.Errorf("get flavor version: %w", err) } @@ -143,13 +143,14 @@ func (db *DB) LatestFlavorVersion(ctx context.Context, flavorID string) (resourc }) ret = resource.FlavorVersion{ - ID: latest.ID, - Version: latest.Version, - Hash: latest.Hash, - FileHashes: hashes, - CreatedAt: latest.CreatedAt, - MinPlayers: uint32(latest.MinPlayers), - MaxPlayers: uint32(latest.MaxPlayers), + ID: latest.ID, + Version: latest.Version, + Hash: latest.Hash, + FileHashes: hashes, + CreatedAt: latest.CreatedAt, + MinPlayers: uint32(latest.MinPlayers), + MaxPlayers: uint32(latest.MaxPlayers), + FilesUploaded: latest.FilesUploaded, } return nil @@ -193,6 +194,10 @@ func (db *DB) CreateFlavorVersion( return fmt.Errorf("create flavor version: %w", err) } + if len(version.FileHashes) == 0 { + return nil + } + dbHashes := make([]query.BulkInsertFlavorFileHashesParams, 0, len(version.FileHashes)) for _, f := range version.FileHashes { dbHashes = append(dbHashes, query.BulkInsertFlavorFileHashesParams{ @@ -472,3 +477,22 @@ func (db *DB) InsertJob(ctx context.Context, flavorVersionID string, status stri return nil }) } + +func (db *DB) AddFlavorVersionFileHashes(ctx context.Context, flavorVersionID string, hashes []file.Hash) error { + return db.do(ctx, func(q *query.Queries) error { + dbHashes := make([]query.BulkInsertFlavorFileHashesParams, 0, len(hashes)) + for _, f := range hashes { + dbHashes = append(dbHashes, query.BulkInsertFlavorFileHashesParams{ + FlavorVersionID: flavorVersionID, + FileHash: f.Hash, + FilePath: f.Path, + }) + } + + if err := db.bulkExecAndClose(q.BulkInsertFlavorFileHashes(ctx, dbHashes)); err != nil { + return fmt.Errorf("bulk insert flavor files: %w", err) + } + + return nil + }) +} diff --git a/internal/mock/chunk_repository.go b/internal/mock/chunk_repository.go index 751f25f58..eaa288068 100644 --- a/internal/mock/chunk_repository.go +++ b/internal/mock/chunk_repository.go @@ -5,9 +5,11 @@ package mock import ( context "context" - resource "github.com/spacechunks/explorer/internal/resource" + file "github.com/spacechunks/explorer/internal/file" mock "github.com/stretchr/testify/mock" + resource "github.com/spacechunks/explorer/internal/resource" + time "time" ) @@ -24,6 +26,54 @@ func (_m *MockChunkRepository) EXPECT() *MockChunkRepository_Expecter { return &MockChunkRepository_Expecter{mock: &_m.Mock} } +// AddFlavorVersionFileHashes provides a mock function with given fields: ctx, flavorVersionID, hashes +func (_m *MockChunkRepository) AddFlavorVersionFileHashes(ctx context.Context, flavorVersionID string, hashes []file.Hash) error { + ret := _m.Called(ctx, flavorVersionID, hashes) + + if len(ret) == 0 { + panic("no return value specified for AddFlavorVersionFileHashes") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, []file.Hash) error); ok { + r0 = rf(ctx, flavorVersionID, hashes) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockChunkRepository_AddFlavorVersionFileHashes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFlavorVersionFileHashes' +type MockChunkRepository_AddFlavorVersionFileHashes_Call struct { + *mock.Call +} + +// AddFlavorVersionFileHashes is a helper method to define mock.On call +// - ctx context.Context +// - flavorVersionID string +// - hashes []file.Hash +func (_e *MockChunkRepository_Expecter) AddFlavorVersionFileHashes(ctx interface{}, flavorVersionID interface{}, hashes interface{}) *MockChunkRepository_AddFlavorVersionFileHashes_Call { + return &MockChunkRepository_AddFlavorVersionFileHashes_Call{Call: _e.mock.On("AddFlavorVersionFileHashes", ctx, flavorVersionID, hashes)} +} + +func (_c *MockChunkRepository_AddFlavorVersionFileHashes_Call) Run(run func(ctx context.Context, flavorVersionID string, hashes []file.Hash)) *MockChunkRepository_AddFlavorVersionFileHashes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].([]file.Hash)) + }) + return _c +} + +func (_c *MockChunkRepository_AddFlavorVersionFileHashes_Call) Return(_a0 error) *MockChunkRepository_AddFlavorVersionFileHashes_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockChunkRepository_AddFlavorVersionFileHashes_Call) RunAndReturn(run func(context.Context, string, []file.Hash) error) *MockChunkRepository_AddFlavorVersionFileHashes_Call { + _c.Call.Return(run) + return _c +} + // AllChunkThumbnailHashes provides a mock function with given fields: ctx func (_m *MockChunkRepository) AllChunkThumbnailHashes(ctx context.Context) (map[string]string, error) { ret := _m.Called(ctx) diff --git a/test/functional/controlplane/chunk_api_test.go b/test/functional/controlplane/chunk_api_test.go index aef6d58d0..de6af455f 100644 --- a/test/functional/controlplane/chunk_api_test.go +++ b/test/functional/controlplane/chunk_api_test.go @@ -853,6 +853,10 @@ func TestCreateFlavorVersion(t *testing.T) { return version } + prevVersionFilesUploaded := fixture.FlavorVersion(func(tmp *resource.FlavorVersion) { + tmp.FilesUploaded = true + }) + tests := []struct { name string prevVersion *resource.FlavorVersion @@ -873,7 +877,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "create second version with changed files", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.FileHashes = []file.Hash{ @@ -915,7 +919,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "cleans paths", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: uncleanPathVersion(), expectedVersion: new(cleanedPathVersion()), diff: resource.FlavorVersionDiff{ @@ -941,7 +945,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "invalid paths", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.FileHashes = []file.Hash{ @@ -976,13 +980,13 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version already exists", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(), err: apierrs.ErrFlavorVersionExists.GRPCStatus().Err(), }, { name: "version hash mismatch", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.Hash = "wrong-hash" @@ -991,7 +995,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "unsupported minecraft version", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" v.MinecraftVersion = "abcdef" @@ -1000,7 +1004,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version starting with space does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = " hello" }), @@ -1009,7 +1013,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version starting with .. does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "..hello" }), @@ -1018,7 +1022,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version starting with ../ does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "../hello" }), @@ -1027,7 +1031,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version starting with ... does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "...hello" }), @@ -1036,7 +1040,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version ending with space does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "hello " }), @@ -1045,7 +1049,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version ending with .. does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "hello.." }), @@ -1054,7 +1058,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version ending with /.. does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "hello/.." }), @@ -1063,7 +1067,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version containing /../ does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "hello/../world" }), @@ -1072,7 +1076,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "version containing / does not work", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "hello/world" }), @@ -1081,7 +1085,7 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "minPlayers has to be greater than 0", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.MinPlayers = 0 }), @@ -1090,13 +1094,21 @@ func TestCreateFlavorVersion(t *testing.T) { }, { name: "maxPlayers has to be greater than 0", - prevVersion: new(fixture.FlavorVersion()), + prevVersion: new(prevVersionFilesUploaded), newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.MaxPlayers = 0 }), errCode: codes.InvalidArgument, errMsgContains: "maxPlayers: must be greater than 0", }, + { + name: "creating flavor version where the previous one has its files not uploaded fails", + prevVersion: new(fixture.FlavorVersion()), + newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { + v.Version = "v2" + }), + err: apierrs.ErrPreviousFilesNotUploaded.GRPCStatus().Err(), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1119,7 +1131,7 @@ func TestCreateFlavorVersion(t *testing.T) { client := cp.ChunkClient(t) if tt.prevVersion != nil { - _, err := client.CreateFlavorVersion(ctx, &chunkv1alpha1.CreateFlavorVersionRequest{ + re, err := client.CreateFlavorVersion(ctx, &chunkv1alpha1.CreateFlavorVersionRequest{ FlavorId: c.Flavors[0].ID, Version: tt.prevVersion.Version, Hash: tt.prevVersion.Hash, @@ -1129,6 +1141,15 @@ func TestCreateFlavorVersion(t *testing.T) { MaxPlayers: tt.prevVersion.MaxPlayers, }) require.NoError(t, err) + + _, err = cp.Postgres.Pool.Exec( + ctx, + `UPDATE flavor_versions SET files_uploaded = $1 WHERE id = $2`, + tt.prevVersion.FilesUploaded, + re.Version.Id, + ) + require.NoError(t, err) + } version := codec.FlavorVersionToTransport(tt.newVersion) diff --git a/test/testing.go b/test/testing.go index 4af63a2e8..cea9ff126 100644 --- a/test/testing.go +++ b/test/testing.go @@ -19,8 +19,10 @@ along with this program. If not, see . package test import ( + "archive/tar" "archive/zip" "bytes" + "compress/gzip" "context" "crypto/rand" "crypto/sha1" @@ -29,6 +31,7 @@ import ( "net" "net/url" "slices" + "sort" "testing" "time" @@ -104,3 +107,44 @@ func MustParseURL(t *testing.T, s string) *url.URL { require.NoError(t, err) return u } + +// CreateTarGz builds an in-memory tar.gz archive from a map of +// archive paths to file contents. Keys are written in sorted order so the +// resulting bytes are deterministic across runs (Go map iteration is not). +func CreateTarGz(t *testing.T, files map[string]string) []byte { + t.Helper() + + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for _, name := range names { + content := files[name] + hdr := &tar.Header{ + Name: name, + Mode: 00644, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + + err := tw.WriteHeader(hdr) + require.NoError(t, err) + + _, err = tw.Write([]byte(content)) + require.NoError(t, err) + } + + err := tw.Close() + require.NoError(t, err) + + err = gz.Close() + require.NoError(t, err) + + return buf.Bytes() +} From 24b3947689c47b2eeb99bde38b7bdcfac47fe0e4 Mon Sep 17 00:00:00 2001 From: yannic rieger Date: Sun, 13 Sep 2026 17:55:17 +0200 Subject: [PATCH 2/3] make fmt --- test/functional/controlplane/chunk_api_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/functional/controlplane/chunk_api_test.go b/test/functional/controlplane/chunk_api_test.go index de6af455f..c1fc0cebb 100644 --- a/test/functional/controlplane/chunk_api_test.go +++ b/test/functional/controlplane/chunk_api_test.go @@ -1104,10 +1104,10 @@ func TestCreateFlavorVersion(t *testing.T) { { name: "creating flavor version where the previous one has its files not uploaded fails", prevVersion: new(fixture.FlavorVersion()), - newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { + newVersion: fixture.FlavorVersion(func(v *resource.FlavorVersion) { v.Version = "v2" }), - err: apierrs.ErrPreviousFilesNotUploaded.GRPCStatus().Err(), + err: apierrs.ErrPreviousFilesNotUploaded.GRPCStatus().Err(), }, } for _, tt := range tests { From 155d580b4cf905818fae6e1427b5dc1dff531691 Mon Sep 17 00:00:00 2001 From: yannic rieger Date: Sun, 13 Sep 2026 17:59:33 +0200 Subject: [PATCH 3/3] make fmt --- Makefile | 1 + controlplane/errors/error.go | 8 +++++--- test/functional/controlplane/chunk_api_test.go | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 68635a786..e86738f06 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ generate: sqlc genproto mocks fmt: @find . -type f -name '*.go' \ -not -path './vendor/*' \ + -not -path './.idea/*' \ -not -name '*.pb.go' \ -exec gofmt -w {} + diff --git a/controlplane/errors/error.go b/controlplane/errors/error.go index 93aaf18ca..693a7f8d2 100644 --- a/controlplane/errors/error.go +++ b/controlplane/errors/error.go @@ -75,9 +75,11 @@ var ( ErrMinecraftVersionNotSupported = New(codes.FailedPrecondition, "minecraft version not found") ErrHashMismatch = New(codes.FailedPrecondition, "hash does not match") ErrFlavorFilesNotUploaded = New(codes.FailedPrecondition, "flavor files have not been uploaded") - ErrPreviousFilesNotUploaded = New(codes.FailedPrecondition, "files of the last flavor version have not been uploaded") - ErrFlavorFilesUploaded = New(codes.AlreadyExists, "flavor files have already been uploaded") - ErrChangeSetTarballTooBig = New(codes.InvalidArgument, "tarball size exceeds maximum allowed") + ErrPreviousFilesNotUploaded = New( + codes.FailedPrecondition, "files of the last flavor version have not been uploaded", + ) + ErrFlavorFilesUploaded = New(codes.AlreadyExists, "flavor files have already been uploaded") + ErrChangeSetTarballTooBig = New(codes.InvalidArgument, "tarball size exceeds maximum allowed") ) /* diff --git a/test/functional/controlplane/chunk_api_test.go b/test/functional/controlplane/chunk_api_test.go index c1fc0cebb..b541550a5 100644 --- a/test/functional/controlplane/chunk_api_test.go +++ b/test/functional/controlplane/chunk_api_test.go @@ -1149,7 +1149,6 @@ func TestCreateFlavorVersion(t *testing.T) { re.Version.Id, ) require.NoError(t, err) - } version := codec.FlavorVersionToTransport(tt.newVersion)