From 2f8577e7f351ad665bc37a3319de3a43665fd138 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 00:45:40 +0200 Subject: [PATCH 01/61] Add terraform state packages --- models/packages/descriptor.go | 2 + models/packages/package.go | 6 + modules/setting/packages.go | 2 + public/assets/img/svg/gitea-terraform.svg | 1 + routers/api/packages/api.go | 13 + routers/api/packages/terraform/terraform.go | 210 +++++++++++++++ .../api/packages/terraform/terraform_test.go | 65 +++++ services/forms/package_form.go | 2 +- services/packages/packages.go | 2 + templates/package/content/terraform.tmpl | 18 ++ templates/package/metadata/terraform.tmpl | 0 templates/package/shared/list.tmpl | 4 + templates/package/shared/view.tmpl | 2 + templates/swagger/v1_json.tmpl | 1 + .../api_packages_terraform_test.go | 247 ++++++++++++++++++ web_src/svg/gitea-terraform.svg | 1 + 16 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 public/assets/img/svg/gitea-terraform.svg create mode 100644 routers/api/packages/terraform/terraform.go create mode 100644 routers/api/packages/terraform/terraform_test.go create mode 100644 templates/package/content/terraform.tmpl create mode 100644 templates/package/metadata/terraform.tmpl create mode 100644 tests/integration/api_packages_terraform_test.go create mode 100644 web_src/svg/gitea-terraform.svg diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 1ea181c72320b..edd8ff2a072ba 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -203,6 +203,8 @@ func getPackageDescriptor(ctx context.Context, pv *PackageVersion, c *cache.Ephe metadata = &rubygems.Metadata{} case TypeSwift: metadata = &swift.Metadata{} + case TypeTerraform: + // terraform packages have no metadata case TypeVagrant: metadata = &vagrant.Metadata{} default: diff --git a/models/packages/package.go b/models/packages/package.go index 38d1cdcf66030..ae182d715ec48 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -51,6 +51,7 @@ const ( TypeRpm Type = "rpm" TypeRubyGems Type = "rubygems" TypeSwift Type = "swift" + TypeTerraform Type = "terraform" TypeVagrant Type = "vagrant" ) @@ -76,6 +77,7 @@ var TypeList = []Type{ TypeRpm, TypeRubyGems, TypeSwift, + TypeTerraform, TypeVagrant, } @@ -124,6 +126,8 @@ func (pt Type) Name() string { return "RubyGems" case TypeSwift: return "Swift" + case TypeTerraform: + return "Terraform" case TypeVagrant: return "Vagrant" } @@ -175,6 +179,8 @@ func (pt Type) SVGName() string { return "gitea-rubygems" case TypeSwift: return "gitea-swift" + case TypeTerraform: + return "gitea-terraform" case TypeVagrant: return "gitea-vagrant" } diff --git a/modules/setting/packages.go b/modules/setting/packages.go index b598424064832..e873aa3001caa 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -39,6 +39,7 @@ var ( LimitSizeRpm int64 LimitSizeRubyGems int64 LimitSizeSwift int64 + LimitSizeTerraform int64 LimitSizeVagrant int64 DefaultRPMSignEnabled bool @@ -86,6 +87,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) { Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM") Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS") Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT") + Packages.LimitSizeTerraform = mustBytes(sec, "LIMIT_SIZE_TERRAFORM") Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT") Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false) return nil diff --git a/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg new file mode 100644 index 0000000000000..022698dae6b8b --- /dev/null +++ b/public/assets/img/svg/gitea-terraform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index ae4ea7ea87afc..58bb477ed57fa 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -34,6 +34,7 @@ import ( "code.gitea.io/gitea/routers/api/packages/rpm" "code.gitea.io/gitea/routers/api/packages/rubygems" "code.gitea.io/gitea/routers/api/packages/swift" + "code.gitea.io/gitea/routers/api/packages/terraform" "code.gitea.io/gitea/routers/api/packages/vagrant" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" @@ -662,6 +663,18 @@ func CommonRoutes() *web.Router { r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) }, reqPackageAccess(perm.AccessModeRead)) }) + r.Group("/terraform", func() { + r.Group("/{packagename}", func() { + r.Delete("", reqPackageAccess(perm.AccessModeWrite), terraform.DeletePackage) + r.Group("/state/{filename}", func() { + r.Get("", terraform.DownloadPackageFile) + r.Group("", func() { + r.Put("", terraform.UploadPackage) + r.Delete("", terraform.DeletePackageFile) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + }) + }, reqPackageAccess(perm.AccessModeRead)) r.Group("/vagrant", func() { r.Group("/authenticate", func() { r.Get("", vagrant.CheckAuthenticate) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go new file mode 100644 index 0000000000000..d5be8f40af6bd --- /dev/null +++ b/routers/api/packages/terraform/terraform.go @@ -0,0 +1,210 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "code.gitea.io/gitea/modules/globallock" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "unicode" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" +) + +var ( + packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) + filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) + lockRelease globallock.ReleaseFunc = nil +) + +func apiError(ctx *context.Context, status int, obj any) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +// DownloadPackageFile serves the specific terraform package. +func DownloadPackageFile(ctx *context.Context) { + s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: ctx.PathParam("packagename"), + Version: ctx.PathParam("filename"), + }, + &packages_service.PackageFileInfo{ + Filename: "tfstate", + // CompositeKey: "state", + }, + ) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + helper.ServePackageFile(ctx, s, u, pf) +} + +func isValidPackageName(packageName string) bool { + if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) { + return false + } + return packageNameRegex.MatchString(packageName) && packageName != ".." +} + +func isValidFileName(filename string) bool { + return filenameRegex.MatchString(filename) && + strings.TrimSpace(filename) == filename && + filename != "." && filename != ".." +} + +// UploadPackage uploads the specific terraform package. +func UploadPackage(ctx *context.Context) { + packageName := ctx.PathParam("packagename") + filename := ctx.PathParam("filename") + + if !isValidPackageName(packageName) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) + return + } + + if !isValidFileName(filename) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid filename")) + return + } + + upload, needToClose, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if needToClose { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload) + if err != nil { + log.Error("Error creating hashed buffer: %v", err) + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer buf.Close() + + _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: packageName, + Version: filename, + }, + Creator: ctx.Doer, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: "tfstate", + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + OverwriteExisting: true, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +// DeletePackage deletes the specific terraform package. +func DeletePackage(ctx *context.Context) { + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeTerraform, + Name: ctx.PathParam("packagename"), + // Version: ctx.PathParam("filename"), + }, + ) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeletePackageFile deletes the specific file of a terraform package. +func DeletePackageFile(ctx *context.Context) { + pv, pf, err := func() (*packages_model.PackageVersion, *packages_model.PackageFile, error) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("packagename"), ctx.PathParam("filename")) + if err != nil { + return nil, nil, err + } + + pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, "tfstate", packages_model.EmptyFileKey) + if err != nil { + return nil, nil, err + } + + return pv, pf, nil + }() + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pfs) == 1 { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } else { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/packages/terraform/terraform_test.go b/routers/api/packages/terraform/terraform_test.go new file mode 100644 index 0000000000000..6e69100a28c7e --- /dev/null +++ b/routers/api/packages/terraform/terraform_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidatePackageName(t *testing.T) { + bad := []string{ + "", + ".", + "..", + "-", + "a?b", + "a b", + "a/b", + } + for _, name := range bad { + assert.False(t, isValidPackageName(name), "bad=%q", name) + } + + good := []string{ + "a", + "1", + "a-", + "a_b", + "c.d+", + } + for _, name := range good { + assert.True(t, isValidPackageName(name), "good=%q", name) + } +} + +func TestValidateFileName(t *testing.T) { + bad := []string{ + "", + ".", + "..", + "a?b", + "a/b", + " a", + "a ", + } + for _, name := range bad { + assert.False(t, isValidFileName(name), "bad=%q", name) + } + + good := []string{ + "-", + "a", + "1", + "a-", + "a_b", + "a b", + "c.d+", + `-_+=:;.()[]{}~!@#$%^& aA1`, + } + for _, name := range good { + assert.True(t, isValidFileName(name), "good=%q", name) + } +} diff --git a/services/forms/package_form.go b/services/forms/package_form.go index 9b6f9071647bc..d1a2b8587ccf5 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -15,7 +15,7 @@ import ( type PackageCleanupRuleForm struct { ID int64 Enabled bool - Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"` + Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,terraform,vagrant)"` KeepCount int `binding:"In(0,1,5,10,25,50,100)"` KeepPattern string `binding:"RegexPattern"` RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` diff --git a/services/packages/packages.go b/services/packages/packages.go index bd1d460fd3ba8..0736ef4b56e2d 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -393,6 +393,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p typeSpecificSize = setting.Packages.LimitSizeRubyGems case packages_model.TypeSwift: typeSpecificSize = setting.Packages.LimitSizeSwift + case packages_model.TypeTerraform: + typeSpecificSize = setting.Packages.LimitSizeTerraform case packages_model.TypeVagrant: typeSpecificSize = setting.Packages.LimitSizeVagrant } diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl new file mode 100644 index 0000000000000..3cd4941624911 --- /dev/null +++ b/templates/package/content/terraform.tmpl @@ -0,0 +1,18 @@ +{{if eq .PackageDescriptor.Package.Type "terraform"}} +

{{ctx.Locale.Tr "packages.installation"}}

+
+
+
+ +

+{{- range .PackageDescriptor.Files -}}
+curl -OJ 
+{{end -}}
+				
+
+
+ +
+
+
+{{end}} diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/templates/package/shared/list.tmpl b/templates/package/shared/list.tmpl index e621c04b438bd..3306d8aa945eb 100644 --- a/templates/package/shared/list.tmpl +++ b/templates/package/shared/list.tmpl @@ -20,7 +20,11 @@
+ {{if eq .Package.Type "terraform"}} + {{.Package.Name}} + {{else}} {{.Package.Name}} + {{end}} {{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}
diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl index 713e1bbfc5520..6b8bd9f817cff 100644 --- a/templates/package/shared/view.tmpl +++ b/templates/package/shared/view.tmpl @@ -32,6 +32,7 @@ {{template "package/content/rpm" .}} {{template "package/content/rubygems" .}} {{template "package/content/swift" .}} + {{template "package/content/terraform" .}} {{template "package/content/vagrant" .}}
@@ -63,6 +64,7 @@ {{template "package/metadata/rpm" .}} {{template "package/metadata/rubygems" .}} {{template "package/metadata/swift" .}} + {{template "package/metadata/terraform" .}} {{template "package/metadata/vagrant" .}} {{if not (and (eq .PackageDescriptor.Package.Type "container") .PackageDescriptor.Metadata.Manifests)}}
{{svg "octicon-database"}} {{FileSize .PackageDescriptor.CalculateBlobSize}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8758b5c0a1505..58e214a6300f5 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3549,6 +3549,7 @@ "rpm", "rubygems", "swift", + "terraform", "vagrant" ], "type": "string", diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go new file mode 100644 index 0000000000000..2a44570a3bc2d --- /dev/null +++ b/tests/integration/api_packages_terraform_test.go @@ -0,0 +1,247 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPackageTerraform(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "te-st_pac.kage" + filename := "fi-le_na.me" + content := []byte{1, 2, 3} + + url := fmt.Sprintf("/api/packages/%s/terraform/%s/state", user.Name, packageName) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.Nil(t, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, filename, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, "tfstate", pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + assert.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + t.Run("Exists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("Additional", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/dummy.bin", bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // Check deduplication + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 1) + // assert.Equal(t, pfs[0].BlobID, pfs[1].BlobID) + }) + + t.Run("InvalidParameter", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/terraform/%s/state/%s", user.Name, "invalid package name", filename), bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/terraform/%s/state/%s", user.Name, packageName, "inva|id.name"), bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + checkDownloadCount := func(count int64) { + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + assert.NoError(t, err) + assert.Len(t, pvs, 2) + assert.Equal(t, count, pvs[0].DownloadCount) + } + + checkDownloadCount(0) + + req := NewRequest(t, "GET", url+"/"+filename) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + + checkDownloadCount(1) + + req = NewRequest(t, "GET", url+"/dummy.bin") + MakeRequest(t, req, http.StatusOK) + + checkDownloadCount(1) + + t.Run("NotExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/not.found") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("RequireSignInView", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + + req = NewRequest(t, "GET", url+"/dummy.bin") + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("ServeDirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + if setting.Packages.Storage.Type != setting.MinioStorageType && setting.Packages.Storage.Type != setting.AzureBlobStorageType { + t.Skip("Test skipped for non-Minio-storage and non-AzureBlob-storage.") + return + } + + if setting.Packages.Storage.Type == setting.MinioStorageType { + if !setting.Packages.Storage.MinioConfig.ServeDirect { + old := setting.Packages.Storage.MinioConfig.ServeDirect + defer func() { + setting.Packages.Storage.MinioConfig.ServeDirect = old + }() + + setting.Packages.Storage.MinioConfig.ServeDirect = true + } + } else if setting.Packages.Storage.Type == setting.AzureBlobStorageType { + if !setting.Packages.Storage.AzureBlobConfig.ServeDirect { + old := setting.Packages.Storage.AzureBlobConfig.ServeDirect + defer func() { + setting.Packages.Storage.AzureBlobConfig.ServeDirect = old + }() + + setting.Packages.Storage.AzureBlobConfig.ServeDirect = true + } + } + + req := NewRequest(t, "GET", url+"/"+filename) + resp := MakeRequest(t, req, http.StatusSeeOther) + + checkDownloadCount(3) + + location := resp.Header().Get("Location") + assert.NotEmpty(t, location) + + resp2, err := (&http.Client{}).Get(location) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp2.StatusCode, location) + + body, err := io.ReadAll(resp2.Body) + assert.NoError(t, err) + assert.Equal(t, content, body) + + checkDownloadCount(3) + }) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("File", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", url+"/"+filename) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", url+"/"+filename). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", url+"/"+filename) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", url+"/"+filename). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + t.Run("RemovesVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "DELETE", url+"/dummy.bin"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + assert.NoError(t, err) + assert.Empty(t, pvs) + }) + }) + + t.Run("Version", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "DELETE", url+"/"+filename) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", url+"/"+filename). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + assert.NoError(t, err) + assert.Empty(t, pvs) + + req = NewRequest(t, "GET", url+"/"+filename) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", url). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + }) +} diff --git a/web_src/svg/gitea-terraform.svg b/web_src/svg/gitea-terraform.svg new file mode 100644 index 0000000000000..24d340f0f8c87 --- /dev/null +++ b/web_src/svg/gitea-terraform.svg @@ -0,0 +1 @@ + \ No newline at end of file From 1d7eaef20429d4c3043e6493d712259b2da9e342 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 00:47:11 +0200 Subject: [PATCH 02/61] Add terraform state lock --- modules/globallock/globallock.go | 4 ++ modules/globallock/locker.go | 2 + modules/globallock/memory_locker.go | 5 +++ modules/globallock/redis_locker.go | 13 ++++++ routers/api/packages/api.go | 4 ++ routers/api/packages/terraform/terraform.go | 46 +++++++++++++++++++++ 6 files changed, 74 insertions(+) diff --git a/modules/globallock/globallock.go b/modules/globallock/globallock.go index 24e91881bb338..470d5f88f0342 100644 --- a/modules/globallock/globallock.go +++ b/modules/globallock/globallock.go @@ -45,6 +45,10 @@ func TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) { return DefaultLocker().TryLock(ctx, key) } +func Unlock(ctx context.Context, key string) error { + return DefaultLocker().Unlock(ctx, key) +} + // LockAndDo tries to acquire a lock for the given key and then calls the given function. // It uses the default locker, and it will return an error if failed to acquire the lock. func LockAndDo(ctx context.Context, key string, f func(context.Context) error) error { diff --git a/modules/globallock/locker.go b/modules/globallock/locker.go index 682e24d052aba..2d399eec886ef 100644 --- a/modules/globallock/locker.go +++ b/modules/globallock/locker.go @@ -32,6 +32,8 @@ type Locker interface { // And if it fails to acquire the lock because it's already locked, not other reasons like redis is down, // it will return false without any error. TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) + + Unlock(ctx context.Context, key string) error } // ReleaseFunc is a function that releases a lock. diff --git a/modules/globallock/memory_locker.go b/modules/globallock/memory_locker.go index 3f818d8d43929..086986be6e00b 100644 --- a/modules/globallock/memory_locker.go +++ b/modules/globallock/memory_locker.go @@ -61,6 +61,11 @@ func (l *memoryLocker) TryLock(_ context.Context, key string) (bool, ReleaseFunc return false, func() {}, nil } +func (l *memoryLocker) Unlock(_ context.Context, key string) error { + l.locks.Delete(key) + return nil +} + func (l *memoryLocker) tryLock(key string) bool { _, loaded := l.locks.LoadOrStore(key, struct{}{}) return !loaded diff --git a/modules/globallock/redis_locker.go b/modules/globallock/redis_locker.go index 45dc769fd499b..37b7409a78ed6 100644 --- a/modules/globallock/redis_locker.go +++ b/modules/globallock/redis_locker.go @@ -64,6 +64,19 @@ func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFun return err == nil, f, err } +func (l *redisLocker) Unlock(ctx context.Context, key string) error { + mutex, ok := l.mutexM.Load(key) + if ok { + l.mutexM.Delete(key) + + // It's safe to ignore the error here, + // if it failed to unlock, it will be released automatically after the lock expires. + // Do not call mutex.UnlockContext(ctx) here, or it will fail to release when ctx has timed out. + _, _ = mutex.(*redsync.Mutex).Unlock() + } + return nil +} + // Close closes the locker. // It will stop extending the locks and refuse to acquire new locks. // In actual use, it is not necessary to call this function. diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 58bb477ed57fa..f18794f26687d 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -672,6 +672,10 @@ func CommonRoutes() *web.Router { r.Put("", terraform.UploadPackage) r.Delete("", terraform.DeletePackageFile) }, reqPackageAccess(perm.AccessModeWrite)) + r.Group("/lock", func() { + r.Post("", terraform.LockPackage) + r.Delete("", terraform.UnlockPackage) + }, reqPackageAccess(perm.AccessModeWrite)) }) }) }, reqPackageAccess(perm.AccessModeRead)) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index d5be8f40af6bd..0c4be7bd2b172 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -208,3 +208,49 @@ func DeletePackageFile(ctx *context.Context) { ctx.Status(http.StatusNoContent) } + +// LockPackage locks the specific terraform state. +func LockPackage(ctx *context.Context) { + packageName := ctx.PathParam("packagename") + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName, ctx.PathParam("filename")) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + //log.Error("pv: %+v", pv) + ok, _, err := globallock.TryLock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion)) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if !ok { + apiError(ctx, http.StatusLocked, err) + return + } + + ctx.Status(http.StatusOK) +} + +// UnlockPackage unlock the specific terraform state. +func UnlockPackage(ctx *context.Context) { + packageName := ctx.PathParam("packagename") + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName, ctx.PathParam("filename")) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + //log.Error("pv: %+v", pv) + _ = globallock.Unlock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion)) + + ctx.Status(http.StatusOK) +} From 321d9993ea40c500d4f185b0a0d1b163499fac60 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 01:03:12 +0200 Subject: [PATCH 03/61] Fix lint --- routers/api/packages/terraform/terraform.go | 3 +-- routers/api/v1/packages/package.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 0c4be7bd2b172..9a6d2eda8129a 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -4,7 +4,6 @@ package terraform import ( - "code.gitea.io/gitea/modules/globallock" "errors" "fmt" "net/http" @@ -12,6 +11,7 @@ import ( "strings" "unicode" + "code.gitea.io/gitea/modules/globallock" packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" @@ -23,7 +23,6 @@ import ( var ( packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) - lockRelease globallock.ReleaseFunc = nil ) func apiError(ctx *context.Context, status int, obj any) { diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index 41b7f2a43f67b..5928ad6c2e502 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -43,7 +43,7 @@ func ListPackages(ctx *context.APIContext) { // in: query // description: package type filter // type: string - // enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant] + // enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, terraform, vagrant] // - name: q // in: query // description: name filter From 092661179e594269791102cb9045f4c88afcf6b6 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 10:42:58 +0200 Subject: [PATCH 04/61] Regenerate gitea-terraform svg --- public/assets/img/svg/gitea-terraform.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg index 022698dae6b8b..82db40da8671e 100644 --- a/public/assets/img/svg/gitea-terraform.svg +++ b/public/assets/img/svg/gitea-terraform.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From c4e566bcfcc7ca2b9f41fd9e9f1694905cd72230 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 11:09:08 +0200 Subject: [PATCH 05/61] Better package setup details --- options/locale/locale_en-US.ini | 2 ++ templates/package/content/terraform.tmpl | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a8fabc9ca1014..2acac795e11d8 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3673,6 +3673,8 @@ rubygems.required.rubygems = Requires RubyGem version swift.registry = Setup this registry from the command line: swift.install = Add the package in your Package.swift file: swift.install2 = and run the following command: +terraform.install = Setup this registry to your backend config +terraform.install2 = and run the following command: vagrant.install = To add a Vagrant box, run the following command: settings.link = Link this package to a repository settings.link.description = If you link a package with a repository, the package is listed in the repository's package list. diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl index 3cd4941624911..e6fedf9ec4b76 100644 --- a/templates/package/content/terraform.tmpl +++ b/templates/package/content/terraform.tmpl @@ -3,13 +3,24 @@
- +

-{{- range .PackageDescriptor.Files -}}
-curl -OJ 
-{{end -}}
+terraform {
+  backend "http" {
+    address = ""
+    update_method = "PUT"
+    lock_address = ""
+    unlock_address = ""
+    lock_method = "POST"
+    unlock_method = "DELETE"
+  }
+}
 				
+
+ +
terraform init -migrate-state
+
From 4272ae50497a8b79c48687cf99b413ae274de502 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 11:10:29 +0200 Subject: [PATCH 06/61] lintfix no2 --- routers/api/packages/terraform/terraform.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 9a6d2eda8129a..4d2d441ec069e 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -11,8 +11,8 @@ import ( "strings" "unicode" - "code.gitea.io/gitea/modules/globallock" packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" "code.gitea.io/gitea/routers/api/packages/helper" @@ -21,8 +21,8 @@ import ( ) var ( - packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) - filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) + packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) + filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) ) func apiError(ctx *context.Context, status int, obj any) { @@ -221,7 +221,6 @@ func LockPackage(ctx *context.Context) { return } - //log.Error("pv: %+v", pv) ok, _, err := globallock.TryLock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion)) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -248,7 +247,6 @@ func UnlockPackage(ctx *context.Context) { return } - //log.Error("pv: %+v", pv) _ = globallock.Unlock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion)) ctx.Status(http.StatusOK) From e6f33beda61ec662b1522d7c20a8fddd6421a060 Mon Sep 17 00:00:00 2001 From: Andras Elso Date: Thu, 1 May 2025 11:37:18 +0200 Subject: [PATCH 07/61] fix indent --- templates/package/content/terraform.tmpl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl index e6fedf9ec4b76..afd46427a1411 100644 --- a/templates/package/content/terraform.tmpl +++ b/templates/package/content/terraform.tmpl @@ -6,14 +6,14 @@

 terraform {
-  backend "http" {
-    address = ""
-    update_method = "PUT"
-    lock_address = ""
-    unlock_address = ""
-    lock_method = "POST"
-    unlock_method = "DELETE"
-  }
+	backend "http" {
+		address = ""
+		update_method = "PUT"
+		lock_address = ""
+		unlock_address = ""
+		lock_method = "POST"
+		unlock_method = "DELETE"
+	}
 }
 				
From ea9b9310997080ba090ee342012e0de844c1c56b Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 17:39:03 +0100 Subject: [PATCH 08/61] port locale addition --- options/locale/locale_en-US.ini | 0 options/locale/locale_en-US.json | 2 ++ 2 files changed, 2 insertions(+) delete mode 100644 options/locale/locale_en-US.ini diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 72cdfbc40f1a6..a094198e790d3 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3561,6 +3561,8 @@ "packages.swift.registry": "Set up this registry from the command line:", "packages.swift.install": "Add the package in your Package.swift file:", "packages.swift.install2": "and run the following command:", + "packages.terraform.install": "Setup this registry to your backend config", + "packages.terraform.install2": "and run the following command:", "packages.vagrant.install": "To add a Vagrant box, run the following command:", "packages.settings.link": "Link this package to a repository", "packages.settings.link.description": "If you link a package with a repository, the package will appear in the repository's package list. Only repositories under the same owner can be linked. Leaving the field empty will remove the link.", From 5fc205fd8bbdfd9ff3de0215be8ea8b268732c74 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 18:11:43 +0100 Subject: [PATCH 09/61] replace function calls --- routers/api/packages/api.go | 2 +- routers/api/packages/terraform/terraform.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 67df9b118eb2a..70902ef44948e 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -516,7 +516,7 @@ func CommonRoutes() *web.Router { r.Group("/state/{filename}", func() { r.Get("", terraform.DownloadPackageFile) r.Group("", func() { - r.Put("", terraform.UploadPackage) + r.Post("", terraform.UploadPackage) r.Delete("", terraform.DeletePackageFile) }, reqPackageAccess(perm.AccessModeWrite)) r.Group("/lock", func() { diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 4d2d441ec069e..ba69a50889085 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -26,14 +26,13 @@ var ( ) func apiError(ctx *context.Context, status int, obj any) { - helper.LogAndProcessError(ctx, status, obj, func(message string) { - ctx.PlainText(status, message) - }) + message := helper.ProcessErrorForUser(ctx, status, obj) + ctx.PlainText(status, message) } // DownloadPackageFile serves the specific terraform package. func DownloadPackageFile(ctx *context.Context) { - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -45,6 +44,7 @@ func DownloadPackageFile(ctx *context.Context) { Filename: "tfstate", // CompositeKey: "state", }, + ctx.Req.Method, ) if err != nil { if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { From f2a4971b0581993d74040945d18970fbfa2ac79a Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 20:40:09 +0100 Subject: [PATCH 10/61] mirror gitlab routes, use serial as verison system --- routers/api/packages/api.go | 29 +++-- routers/api/packages/terraform/terraform.go | 119 +++++++++++++------- 2 files changed, 95 insertions(+), 53 deletions(-) diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 70902ef44948e..32f9bae0d7378 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -510,21 +510,20 @@ func CommonRoutes() *web.Router { r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) }, reqPackageAccess(perm.AccessModeRead)) }) - r.Group("/terraform", func() { - r.Group("/{packagename}", func() { - r.Delete("", reqPackageAccess(perm.AccessModeWrite), terraform.DeletePackage) - r.Group("/state/{filename}", func() { - r.Get("", terraform.DownloadPackageFile) - r.Group("", func() { - r.Post("", terraform.UploadPackage) - r.Delete("", terraform.DeletePackageFile) - }, reqPackageAccess(perm.AccessModeWrite)) - r.Group("/lock", func() { - r.Post("", terraform.LockPackage) - r.Delete("", terraform.UnlockPackage) - }, reqPackageAccess(perm.AccessModeWrite)) - }) - }) + // See https://docs.gitlab.com/ci/jobs/fine_grained_permissions/#terraform-state-endpoints + // For endpoint and permission reference + r.Group("/terraform/state/{name}", func() { + r.Get("", terraform.GetTerraformState) + r.Get("/versions/{serial}", terraform.GetTerraformStateBySerial) + r.Group("", func() { + r.Post("", terraform.UploadState) + r.Delete("", terraform.DeleteState) + r.Delete("/versions/{serial}", terraform.DeleteStateBySerial) + }, reqPackageAccess(perm.AccessModeWrite)) + r.Group("/lock", func() { + r.Post("", terraform.LockState) + r.Delete("", terraform.UnlockState) + }, reqPackageAccess(perm.AccessModeWrite)) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/vagrant", func() { r.Group("/authenticate", func() { diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index ba69a50889085..757cba00b7908 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -6,14 +6,18 @@ package terraform import ( "errors" "fmt" + "io" "net/http" "regexp" + "strconv" "strings" "unicode" packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/modules/globallock" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" "code.gitea.io/gitea/routers/api/packages/helper" "code.gitea.io/gitea/services/context" @@ -30,15 +34,43 @@ func apiError(ctx *context.Context, status int, obj any) { ctx.PlainText(status, message) } -// DownloadPackageFile serves the specific terraform package. -func DownloadPackageFile(ctx *context.Context) { +// GetTerraformState serves the latest version of the state +func GetTerraformState(ctx *context.Context) { + stateName := ctx.PathParam("name") + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packages_model.SearchValue{ExactMatch: true, Value: stateName}, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + }) + if err != nil { + // TODO: should this be some other fail? When does this error out? + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + log.Info("GetTerraformState: %v", pvs[0].Version) + streamState(ctx, stateName, pvs[0].Version) +} + +// GetTerraformStateBySerial serves a specific version of terraform state. +func GetTerraformStateBySerial(ctx *context.Context) { + streamState(ctx, ctx.PathParam("name"), ctx.PathParam("serial")) +} + +// streamState serves the terraform state file +func streamState(ctx *context.Context, name, serial string) { s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, PackageType: packages_model.TypeTerraform, - Name: ctx.PathParam("packagename"), - Version: ctx.PathParam("filename"), + Name: name, + Version: serial, }, &packages_service.PackageFileInfo{ Filename: "tfstate", @@ -71,21 +103,21 @@ func isValidFileName(filename string) bool { filename != "." && filename != ".." } -// UploadPackage uploads the specific terraform package. -func UploadPackage(ctx *context.Context) { - packageName := ctx.PathParam("packagename") - filename := ctx.PathParam("filename") +type TFState struct { + Version int `json:"version"` + TerraformVersion string `json:"terraform_version"` + Serial uint64 `json:"serial"` +} + +// UploadState uploads the specific terraform package. +func UploadState(ctx *context.Context) { + packageName := ctx.PathParam("name") if !isValidPackageName(packageName) { apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) return } - if !isValidFileName(filename) { - apiError(ctx, http.StatusBadRequest, errors.New("invalid filename")) - return - } - upload, needToClose, err := ctx.UploadStream() if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -103,6 +135,18 @@ func UploadPackage(ctx *context.Context) { } defer buf.Close() + var state *TFState + err = json.NewDecoder(buf).Decode(&state) + if err != nil { + log.Error("Error decoding json: %v", err) + apiError(ctx, http.StatusBadRequest, err) + return + } + + if _, err := buf.Seek(0, io.SeekStart); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } _, _, err = packages_service.CreatePackageOrAddFileToExisting( ctx, &packages_service.PackageCreationInfo{ @@ -110,7 +154,7 @@ func UploadPackage(ctx *context.Context) { Owner: ctx.Package.Owner, PackageType: packages_model.TypeTerraform, Name: packageName, - Version: filename, + Version: strconv.FormatUint(state.Serial, 10), }, Creator: ctx.Doer, }, @@ -163,8 +207,8 @@ func DeletePackage(ctx *context.Context) { ctx.Status(http.StatusNoContent) } -// DeletePackageFile deletes the specific file of a terraform package. -func DeletePackageFile(ctx *context.Context) { +// DeleteState deletes the specific file of a terraform package. +func DeleteState(ctx *context.Context) { pv, pf, err := func() (*packages_model.PackageVersion, *packages_model.PackageFile, error) { pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("packagename"), ctx.PathParam("filename")) if err != nil { @@ -208,20 +252,28 @@ func DeletePackageFile(ctx *context.Context) { ctx.Status(http.StatusNoContent) } -// LockPackage locks the specific terraform state. -func LockPackage(ctx *context.Context) { - packageName := ctx.PathParam("packagename") - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName, ctx.PathParam("filename")) +func DeleteStateBySerial(ctx *context.Context) { + serial := ctx.PathParam("serial") + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("name"), serial) if err != nil { - if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { - apiError(ctx, http.StatusNotFound, err) - return - } + // TODO: check for not exist apiError(ctx, http.StatusInternalServerError, err) return } - ok, _, err := globallock.TryLock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion)) + err = packages_service.DeletePackageVersionAndReferences(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + ctx.Status(http.StatusNoContent) +} + +// LockState locks the specific terraform state. +func LockState(ctx *context.Context) { + packageName := ctx.PathParam("name") + + ok, _, err := globallock.TryLock(ctx, fmt.Sprintf("%d/%s", ctx.Package.Owner.ID, packageName)) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -234,20 +286,11 @@ func LockPackage(ctx *context.Context) { ctx.Status(http.StatusOK) } -// UnlockPackage unlock the specific terraform state. -func UnlockPackage(ctx *context.Context) { - packageName := ctx.PathParam("packagename") - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName, ctx.PathParam("filename")) - if err != nil { - if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { - apiError(ctx, http.StatusNotFound, err) - return - } - apiError(ctx, http.StatusInternalServerError, err) - return - } +// UnlockState unlock the specific terraform state. +func UnlockState(ctx *context.Context) { + packageName := ctx.PathParam("name") - _ = globallock.Unlock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion)) + _ = globallock.Unlock(ctx, fmt.Sprintf("%d/%s", ctx.Package.Owner.ID, packageName)) ctx.Status(http.StatusOK) } From b7e51ee9e1cf0cc9cac7d76b972b4dbe9da2cb2d Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 20:52:58 +0100 Subject: [PATCH 11/61] update routes --- templates/package/content/terraform.tmpl | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl index afd46427a1411..2678bb9618483 100644 --- a/templates/package/content/terraform.tmpl +++ b/templates/package/content/terraform.tmpl @@ -4,18 +4,15 @@
-

-terraform {
+				
terraform {
 	backend "http" {
-		address = ""
-		update_method = "PUT"
-		lock_address = ""
-		unlock_address = ""
+		address = ""
+		lock_address = ""
+		unlock_address = ""
 		lock_method = "POST"
 		unlock_method = "DELETE"
 	}
-}
-				
+}
From d662d4b790d42f5826864e9524bfbe3125aa63dc Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 22:17:15 +0100 Subject: [PATCH 12/61] fix test a bit --- .../api_packages_terraform_test.go | 150 ++++++++---------- 1 file changed, 70 insertions(+), 80 deletions(-) diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 2a44570a3bc2d..4d0884141d3de 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -10,15 +10,14 @@ import ( "net/http" "testing" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPackageTerraform(t *testing.T) { @@ -27,109 +26,112 @@ func TestPackageTerraform(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) packageName := "te-st_pac.kage" - filename := "fi-le_na.me" - content := []byte{1, 2, 3} - - url := fmt.Sprintf("/api/packages/%s/terraform/%s/state", user.Name, packageName) + lineage := "bca3c5f6-01dc-cdad-5310-d1b12e02e430" + terraformVersion := "1.10.4" + resourceName := "hello" + resourceType := "null_resource" + + // Build the state JSON + state := `{ + "version": 4, + "terraform_version": "` + terraformVersion + `", + "serial": 1, + "lineage": "` + lineage + `", + "outPOSTs": {}, + "resources": [{ + "mode": "managed", + "type": "` + resourceType + `", + "name": "` + resourceName + `", + "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", + "instances": [{ + "schema_version": 0, + "attributes": { + "id": "3832416504545530133", + "triggers": null + }, + "sensitive_attributes": [] + }] + }], + "check_results": null + }` + content := []byte(state) + + url := fmt.Sprintf("/api/packages/%s/terraform/state/%s", user.Name, packageName) t.Run("Upload", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + req := NewRequestWithBody(t, "POST", url, bytes.NewReader(content)). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusCreated) - pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) assert.NoError(t, err) assert.Len(t, pvs, 1) - pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + pd, err := packages.GetPackageDescriptor(t.Context(), pvs[0]) assert.NoError(t, err) assert.Nil(t, pd.Metadata) assert.Equal(t, packageName, pd.Package.Name) - assert.Equal(t, filename, pd.Version.Version) + // assert.Equal(t, filename, pd.Version.Version) - pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + pfs, err := packages.GetFilesByVersionID(t.Context(), pvs[0].ID) assert.NoError(t, err) assert.Len(t, pfs, 1) assert.Equal(t, "tfstate", pfs[0].Name) assert.True(t, pfs[0].IsLead) - pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID) assert.NoError(t, err) assert.Equal(t, int64(len(content)), pb.Size) t.Run("Exists", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + req := NewRequestWithBody(t, "POST", url, bytes.NewReader(content)). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusCreated) }) - - t.Run("Additional", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - req := NewRequestWithBody(t, "PUT", url+"/dummy.bin", bytes.NewReader(content)). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusCreated) - - // Check deduplication - pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) - assert.NoError(t, err) - assert.Len(t, pfs, 1) - // assert.Equal(t, pfs[0].BlobID, pfs[1].BlobID) - }) - - t.Run("InvalidParameter", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/terraform/%s/state/%s", user.Name, "invalid package name", filename), bytes.NewReader(content)). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusBadRequest) - - req = NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/terraform/%s/state/%s", user.Name, packageName, "inva|id.name"), bytes.NewReader(content)). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusBadRequest) - }) + // TODO: Do we want multiple states in one package? + //t.Run("Additional", func(t *testing.T) { + // defer tests.PrintCurrentTest(t)() + // + // req := NewRequestWithBody(t, "POST", url+"/dummy.bin", bytes.NewReader(content)). + // AddBasicAuth(user.Name) + // MakeRequest(t, req, http.StatusCreated) + // + // // Check deduplication + // pfs, err := packages.GetFilesByVersionID(t.Context(), pvs[0].ID) + // assert.NoError(t, err) + // assert.Len(t, pfs, 1) + // // assert.Equal(t, pfs[0].BlobID, pfs[1].BlobID) + //}) }) t.Run("Download", func(t *testing.T) { defer tests.PrintCurrentTest(t)() checkDownloadCount := func(count int64) { - pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) - assert.NoError(t, err) - assert.Len(t, pvs, 2) + pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) + require.NoError(t, err) + assert.Len(t, pvs, 1) assert.Equal(t, count, pvs[0].DownloadCount) } checkDownloadCount(0) - req := NewRequest(t, "GET", url+"/"+filename) + req := NewRequest(t, "GET", url) resp := MakeRequest(t, req, http.StatusOK) assert.Equal(t, content, resp.Body.Bytes()) checkDownloadCount(1) - req = NewRequest(t, "GET", url+"/dummy.bin") - MakeRequest(t, req, http.StatusOK) - - checkDownloadCount(1) - - t.Run("NotExists", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - req := NewRequest(t, "GET", url+"/not.found") - MakeRequest(t, req, http.StatusNotFound) - }) - t.Run("RequireSignInView", func(t *testing.T) { defer tests.PrintCurrentTest(t)() defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() - req = NewRequest(t, "GET", url+"/dummy.bin") + req = NewRequest(t, "GET", url) MakeRequest(t, req, http.StatusUnauthorized) }) @@ -161,7 +163,7 @@ func TestPackageTerraform(t *testing.T) { } } - req := NewRequest(t, "GET", url+"/"+filename) + req := NewRequest(t, "GET", url) resp := MakeRequest(t, req, http.StatusSeeOther) checkDownloadCount(3) @@ -187,56 +189,44 @@ func TestPackageTerraform(t *testing.T) { t.Run("File", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "DELETE", url+"/"+filename) + req := NewRequest(t, "DELETE", url) MakeRequest(t, req, http.StatusUnauthorized) - req = NewRequest(t, "DELETE", url+"/"+filename). + req = NewRequest(t, "DELETE", url). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusNoContent) - req = NewRequest(t, "GET", url+"/"+filename) + req = NewRequest(t, "GET", url) MakeRequest(t, req, http.StatusNotFound) - req = NewRequest(t, "DELETE", url+"/"+filename). + req = NewRequest(t, "DELETE", url). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusNotFound) - pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) assert.NoError(t, err) - assert.Len(t, pvs, 1) - - t.Run("RemovesVersion", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - req = NewRequest(t, "DELETE", url+"/dummy.bin"). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusNoContent) - - pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) - assert.NoError(t, err) - assert.Empty(t, pvs) - }) + assert.Len(t, pvs, 0) }) t.Run("Version", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + req := NewRequestWithBody(t, "POST", url, bytes.NewReader(content)). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusCreated) - req = NewRequest(t, "DELETE", url+"/"+filename) + req = NewRequest(t, "DELETE", url) MakeRequest(t, req, http.StatusUnauthorized) - req = NewRequest(t, "DELETE", url+"/"+filename). + req = NewRequest(t, "DELETE", url). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusNoContent) - pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeTerraform) + pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) assert.NoError(t, err) assert.Empty(t, pvs) - req = NewRequest(t, "GET", url+"/"+filename) + req = NewRequest(t, "GET", url) MakeRequest(t, req, http.StatusNotFound) req = NewRequest(t, "DELETE", url). From 2d51d069326b17a2db145a16c34be1eab587d15c Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Feb 2026 22:56:19 +0100 Subject: [PATCH 13/61] remove direct serve test I think it's out of scope - if the feature doesn't work, terraform state isn't the place to check that --- .../api_packages_terraform_test.go | 51 +------------------ 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 4d0884141d3de..9b2cb6750f701 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -6,7 +6,6 @@ package integration import ( "bytes" "fmt" - "io" "net/http" "testing" @@ -16,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -134,53 +134,6 @@ func TestPackageTerraform(t *testing.T) { req = NewRequest(t, "GET", url) MakeRequest(t, req, http.StatusUnauthorized) }) - - t.Run("ServeDirect", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - - if setting.Packages.Storage.Type != setting.MinioStorageType && setting.Packages.Storage.Type != setting.AzureBlobStorageType { - t.Skip("Test skipped for non-Minio-storage and non-AzureBlob-storage.") - return - } - - if setting.Packages.Storage.Type == setting.MinioStorageType { - if !setting.Packages.Storage.MinioConfig.ServeDirect { - old := setting.Packages.Storage.MinioConfig.ServeDirect - defer func() { - setting.Packages.Storage.MinioConfig.ServeDirect = old - }() - - setting.Packages.Storage.MinioConfig.ServeDirect = true - } - } else if setting.Packages.Storage.Type == setting.AzureBlobStorageType { - if !setting.Packages.Storage.AzureBlobConfig.ServeDirect { - old := setting.Packages.Storage.AzureBlobConfig.ServeDirect - defer func() { - setting.Packages.Storage.AzureBlobConfig.ServeDirect = old - }() - - setting.Packages.Storage.AzureBlobConfig.ServeDirect = true - } - } - - req := NewRequest(t, "GET", url) - resp := MakeRequest(t, req, http.StatusSeeOther) - - checkDownloadCount(3) - - location := resp.Header().Get("Location") - assert.NotEmpty(t, location) - - resp2, err := (&http.Client{}).Get(location) - assert.NoError(t, err) - assert.Equal(t, http.StatusOK, resp2.StatusCode, location) - - body, err := io.ReadAll(resp2.Body) - assert.NoError(t, err) - assert.Equal(t, content, body) - - checkDownloadCount(3) - }) }) t.Run("Delete", func(t *testing.T) { @@ -205,7 +158,7 @@ func TestPackageTerraform(t *testing.T) { pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) assert.NoError(t, err) - assert.Len(t, pvs, 0) + assert.Empty(t, pvs) }) t.Run("Version", func(t *testing.T) { From 1273929c9dd7c021a12ef95827ed14a0464eceb5 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Wed, 25 Feb 2026 22:40:41 +0100 Subject: [PATCH 14/61] add database held locks rework tests for better coverage and defined behavior --- routers/api/packages/terraform/terraform.go | 269 ++++++++++++---- .../api_packages_terraform_test.go | 297 +++++++++++------- 2 files changed, 400 insertions(+), 166 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 757cba00b7908..a32c45d1213ea 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -11,6 +11,7 @@ import ( "regexp" "strconv" "strings" + "time" "unicode" packages_model "code.gitea.io/gitea/models/packages" @@ -53,7 +54,7 @@ func GetTerraformState(ctx *context.Context) { apiError(ctx, http.StatusNotFound, nil) return } - log.Info("GetTerraformState: %v", pvs[0].Version) + streamState(ctx, stateName, pvs[0].Version) } @@ -107,6 +108,8 @@ type TFState struct { Version int `json:"version"` TerraformVersion string `json:"terraform_version"` Serial uint64 `json:"serial"` + Lineage string `json:"lineage"` + // modules are ommited } // UploadState uploads the specific terraform package. @@ -143,11 +146,39 @@ func UploadState(ctx *context.Context) { return } + // Check lineage + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if p != nil { + // Check lock + props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(props) > 0 && props[0].Value != "" { + var existingLock LockInfo + if err := json.Unmarshal([]byte(props[0].Value), &existingLock); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if existingLock.ID != ctx.FormString("ID") { + ctx.Resp.Header().Set("Content-Type", "application/json") + ctx.Resp.WriteHeader(http.StatusLocked) + _, _ = ctx.Resp.Write([]byte(props[0].Value)) + return + } + } + } + if _, err := buf.Seek(0, io.SeekStart); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - _, _, err = packages_service.CreatePackageOrAddFileToExisting( + pv, _, err := packages_service.CreatePackageOrAddFileToExisting( ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ @@ -169,10 +200,10 @@ func UploadState(ctx *context.Context) { }, ) if err != nil { - switch err { - case packages_model.ErrDuplicatePackageFile: + switch { + case errors.Is(err, packages_model.ErrDuplicatePackageFile): apiError(ctx, http.StatusConflict, err) - case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize): apiError(ctx, http.StatusForbidden, err) default: apiError(ctx, http.StatusInternalServerError, err) @@ -180,50 +211,62 @@ func UploadState(ctx *context.Context) { return } + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, pv.PackageID, "terraform.lineage", state.Lineage); err != nil { + log.Error("InsertOrUpdateProperty: %v", err) + } + ctx.Status(http.StatusCreated) } -// DeletePackage deletes the specific terraform package. -func DeletePackage(ctx *context.Context) { - err := packages_service.RemovePackageVersionByNameAndVersion( - ctx, - ctx.Doer, - &packages_service.PackageInfo{ - Owner: ctx.Package.Owner, - PackageType: packages_model.TypeTerraform, - Name: ctx.PathParam("packagename"), - // Version: ctx.PathParam("filename"), - }, - ) +// DeleteStateBySerial deletes the specific serial of a terraform package as long as it's not the latest one. +func DeleteStateBySerial(ctx *context.Context) { + serial := ctx.PathParam("serial") + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("name"), serial) + if errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } else if err != nil { + + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packages_model.SearchValue{ExactMatch: true, Value: ctx.PathParam("name")}, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + }) if err != nil { - if errors.Is(err, packages_model.ErrPackageNotExist) { - apiError(ctx, http.StatusNotFound, err) - return - } apiError(ctx, http.StatusInternalServerError, err) return } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + if pvs[0].ID == pv.ID { + apiError(ctx, http.StatusForbidden, errors.New("cannot delete the latest version")) + return + } + err = packages_service.DeletePackageVersionAndReferences(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } ctx.Status(http.StatusNoContent) } // DeleteState deletes the specific file of a terraform package. +// Fails if the state is locked func DeleteState(ctx *context.Context) { - pv, pf, err := func() (*packages_model.PackageVersion, *packages_model.PackageFile, error) { - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("packagename"), ctx.PathParam("filename")) - if err != nil { - return nil, nil, err - } - - pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, "tfstate", packages_model.EmptyFileKey) - if err != nil { - return nil, nil, err - } + packageName := ctx.PathParam("name") - return pv, pf, nil - }() + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) if err != nil { - if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { + if errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusNotFound, err) return } @@ -231,55 +274,110 @@ func DeleteState(ctx *context.Context) { return } - pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) + pp, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } + if len(pp) > 0 && pp[0].Value != "" { + apiError(ctx, http.StatusLocked, errors.New("terraform state is locked")) + return + } - if len(pfs) == 1 { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: p.ID, + IsInternal: optional.None[bool](), + }) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + for _, pv := range pvs { if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - } else { - if err := packages_service.DeletePackageFile(ctx, pf); err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } } - ctx.Status(http.StatusNoContent) + if err := packages_model.DeletePackageByID(ctx, p.ID); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + ctx.Status(http.StatusOK) } -func DeleteStateBySerial(ctx *context.Context) { - serial := ctx.PathParam("serial") - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("name"), serial) +// LockInfo is the metadata for a terraform lock. +type LockInfo struct { + ID string `json:"ID"` + Operation string `json:"Operation"` + Info string `json:"Info"` + Who string `json:"Who"` + Version string `json:"Version"` + Created time.Time `json:"Created"` + Path string `json:"Path"` +} + +// LockState locks the specific terraform state. +// Internally, it adds a property to the package with the lock information +// Cavieat being that it allocates a package one doesn't exist to attach the property +func LockState(ctx *context.Context) { + var reqLockInfo LockInfo + if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + + packageName := ctx.PathParam("name") + lockKey := fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, packageName) + + release, err := globallock.Lock(ctx, lockKey) if err != nil { - // TODO: check for not exist apiError(ctx, http.StatusInternalServerError, err) return } + defer release() - err = packages_service.DeletePackageVersionAndReferences(ctx, pv) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + if err != nil { + // If the package doesn't exist, allocate it for the lock. + if errors.Is(err, packages_model.ErrPackageNotExist) { + p = &packages_model.Package{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packageName, + LowerName: strings.ToLower(packageName), + } + if p, err = packages_model.TryInsertPackage(ctx, p); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } else { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - ctx.Status(http.StatusNoContent) -} -// LockState locks the specific terraform state. -func LockState(ctx *context.Context) { - packageName := ctx.PathParam("name") + if len(props) > 0 && props[0].Value != "" { + apiError(ctx, http.StatusLocked, errors.New("terraform state is already locked")) + return + } - ok, _, err := globallock.TryLock(ctx, fmt.Sprintf("%d/%s", ctx.Package.Owner.ID, packageName)) + jsonBytes, err := json.Marshal(reqLockInfo) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if !ok { - apiError(ctx, http.StatusLocked, err) + + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock", string(jsonBytes)); err != nil { + apiError(ctx, http.StatusInternalServerError, err) return } @@ -287,10 +385,69 @@ func LockState(ctx *context.Context) { } // UnlockState unlock the specific terraform state. +// Internally, it clears the package property func UnlockState(ctx *context.Context) { + var reqLockInfo LockInfo + if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + packageName := ctx.PathParam("name") + lockKey := fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, packageName) + + release, err := globallock.Lock(ctx, lockKey) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer release() + + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) { + ctx.Status(http.StatusOK) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } - _ = globallock.Unlock(ctx, fmt.Sprintf("%d/%s", ctx.Package.Owner.ID, packageName)) + props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + // If there are no properties or the property is empty, it should be unlocked + if len(props) == 0 || props[0].Value == "" { + ctx.Status(http.StatusOK) + return + } + + var existingLock LockInfo + if err := json.Unmarshal([]byte(props[0].Value), &existingLock); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + // we can bypass messing with the lock since it's empty + if existingLock.ID == "" { + ctx.Status(http.StatusOK) + return + } + + // Unlocking ID must be the same as locker one. + if existingLock.ID != reqLockInfo.ID { + apiError(ctx, http.StatusLocked, errors.New("lock ID mismatch")) + return + } + + // We can clear the state if lock id matches + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock", ""); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } ctx.Status(http.StatusOK) } diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 9b2cb6750f701..3547911a52835 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -4,18 +4,17 @@ package integration import ( - "bytes" "fmt" "net/http" + "strings" "testing" "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,22 +25,18 @@ func TestPackageTerraform(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) packageName := "te-st_pac.kage" - lineage := "bca3c5f6-01dc-cdad-5310-d1b12e02e430" - terraformVersion := "1.10.4" - resourceName := "hello" - resourceType := "null_resource" - - // Build the state JSON - state := `{ + // generate the state json + genState := func(serial int) string { + return fmt.Sprintf(`{ "version": 4, - "terraform_version": "` + terraformVersion + `", - "serial": 1, - "lineage": "` + lineage + `", - "outPOSTs": {}, + "terraform_version": "1.10.4", + "serial": %d, + "lineage": "bca3c5f6-01dc-cdad-5310-d1b12e02e430", + "outputs": {}, "resources": [{ "mode": "managed", - "type": "` + resourceType + `", - "name": "` + resourceName + `", + "type": "hello", + "name": "null_resource", "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", "instances": [{ "schema_version": 0, @@ -53,138 +48,220 @@ func TestPackageTerraform(t *testing.T) { }] }], "check_results": null - }` - content := []byte(state) + }`, serial) + } + genLock := func(uuid string) string { + return fmt.Sprintf(`{ + "ID": "%s", + "Operation": "OperationTypePlan", + "Info": "", + "Who": "test-user@localhost", + "Version": "1.0", + "Created": "2023-01-01T00:00:00Z", + "Path": "test.tfstate" + }`, uuid) + } url := fmt.Sprintf("/api/packages/%s/terraform/state/%s", user.Name, packageName) + lockURL := fmt.Sprintf("/api/packages/%s/terraform/state/%s/lock", user.Name, packageName) - t.Run("Upload", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() + // Covers non-existing package retrieval and deletion + t.Run("GetOrDeleteNonExisting", func(t *testing.T) { + // Package does not exist yet + req := NewRequest(t, "GET", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) - req := NewRequestWithBody(t, "POST", url, bytes.NewReader(content)). - AddBasicAuth(user.Name) + // So deleting it also should not work + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("RegularOperations", func(t *testing.T) { + // 1. Lock the state + lockID := uuid.New().String() + lockInfo := genLock(lockID) + req := NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify lock property in DB + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) + require.NoError(t, err) + props, err := packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") + require.NoError(t, err) + require.Len(t, props, 1) + assert.Contains(t, props[0].Value, lockID) + + // Upload state with correct Lock ID + state1 := genState(1) + req = NewRequestWithBody(t, "POST", url+"?ID="+lockID, strings.NewReader(state1)).AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusCreated) - pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) + // Verify version created + pv, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraform, packageName, "1") assert.NoError(t, err) - assert.Len(t, pvs, 1) + assert.NotNil(t, pv) - pd, err := packages.GetPackageDescriptor(t.Context(), pvs[0]) - assert.NoError(t, err) - assert.Nil(t, pd.Metadata) - assert.Equal(t, packageName, pd.Package.Name) - // assert.Equal(t, filename, pd.Version.Version) + // 3. Unlock the state + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) - pfs, err := packages.GetFilesByVersionID(t.Context(), pvs[0].ID) - assert.NoError(t, err) - assert.Len(t, pfs, 1) - assert.Equal(t, "tfstate", pfs[0].Name) - assert.True(t, pfs[0].IsLead) + // Verify lock property is cleared + props, err = packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") + require.NoError(t, err) + require.Len(t, props, 1) + assert.Empty(t, props[0].Value) - pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID) - assert.NoError(t, err) - assert.Equal(t, int64(len(content)), pb.Size) + // Get latest state + req = NewRequest(t, "GET", url).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, state1, resp.Body.String()) - t.Run("Exists", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() + // Upload new version without lock + state2 := genState(2) + req = NewRequestWithBody(t, "POST", url, strings.NewReader(state2)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) - req := NewRequestWithBody(t, "POST", url, bytes.NewReader(content)). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusCreated) - }) - // TODO: Do we want multiple states in one package? - //t.Run("Additional", func(t *testing.T) { - // defer tests.PrintCurrentTest(t)() - // - // req := NewRequestWithBody(t, "POST", url+"/dummy.bin", bytes.NewReader(content)). - // AddBasicAuth(user.Name) - // MakeRequest(t, req, http.StatusCreated) - // - // // Check deduplication - // pfs, err := packages.GetFilesByVersionID(t.Context(), pvs[0].ID) - // assert.NoError(t, err) - // assert.Len(t, pfs, 1) - // // assert.Equal(t, pfs[0].BlobID, pfs[1].BlobID) - //}) - }) + // 6. Delete the entire package + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) - t.Run("Download", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() + // Verify package is deleted from DB + _, err = packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) + assert.ErrorIs(t, err, packages.ErrPackageNotExist) + }) - checkDownloadCount := func(count int64) { - pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) - require.NoError(t, err) - assert.Len(t, pvs, 1) - assert.Equal(t, count, pvs[0].DownloadCount) + t.Run("StateHistory", func(t *testing.T) { + // Upload 3 versions + for i := range 3 { + state := genState(i) + req := NewRequestWithBody(t, "POST", url, strings.NewReader(state)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) } - checkDownloadCount(0) - - req := NewRequest(t, "GET", url) + // Verify latest is 2 + req := NewRequest(t, "GET", url).AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, genState(2), resp.Body.String()) - assert.Equal(t, content, resp.Body.Bytes()) + // Verify version 1 is accessible + req = NewRequest(t, "GET", url+"/versions/1").AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, genState(1), resp.Body.String()) - checkDownloadCount(1) + // Delete version 1 + req = NewRequest(t, "DELETE", url+"/versions/1").AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) - t.Run("RequireSignInView", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + // Verify version 1 is gone from DB + _, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraform, packageName, "1") + assert.ErrorIs(t, err, packages.ErrPackageNotExist) - req = NewRequest(t, "GET", url) - MakeRequest(t, req, http.StatusUnauthorized) - }) - }) + // Verify version 1 is gone from API + req = NewRequest(t, "GET", url+"/versions/1").AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) - t.Run("Delete", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() + // Deleting latest version (2) should be forbidden + req = NewRequest(t, "DELETE", url+"/versions/2").AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusForbidden) + assert.Contains(t, resp.Body.String(), "cannot delete the latest version") + + // Cleanup + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) - t.Run("File", func(t *testing.T) { + t.Run("BadOperations", func(t *testing.T) { + t.Run("LockingIssues", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "DELETE", url) - MakeRequest(t, req, http.StatusUnauthorized) + lockID1 := uuid.New().String() + lockID2 := uuid.New().String() + lockInfo1 := genLock(lockID1) + lockInfo2 := genLock(lockID2) + + // Pre-create package - it's required for unlock on the non-locked package to work + req := NewRequestWithBody(t, "POST", url, strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // Unlock non-locked state (should return 200) + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Lock the state + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) - req = NewRequest(t, "DELETE", url). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusNoContent) + // Another lock attempt should fail + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo2)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) - req = NewRequest(t, "GET", url) - MakeRequest(t, req, http.StatusNotFound) + // Unlock with wrong ID should fail + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo2)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) - req = NewRequest(t, "DELETE", url). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusNotFound) + // Same user locking again should fail (already locked) + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) - pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) - assert.NoError(t, err) - assert.Empty(t, pvs) + // Unlock with correct ID + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Clean up + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) }) - t.Run("Version", func(t *testing.T) { + t.Run("UploadWithoutValidLock", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequestWithBody(t, "POST", url, bytes.NewReader(content)). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusCreated) + lockID := uuid.New().String() + lockInfo := genLock(lockID) - req = NewRequest(t, "DELETE", url) - MakeRequest(t, req, http.StatusUnauthorized) + // Lock the state + req := NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) - req = NewRequest(t, "DELETE", url). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusNoContent) + // Upload without ID should fail + req = NewRequestWithBody(t, "POST", url, strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) - pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeTerraform) - assert.NoError(t, err) - assert.Empty(t, pvs) + // Upload with wrong ID should fail + req = NewRequestWithBody(t, "POST", url+"?ID="+uuid.New().String(), strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Cleanup lock + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) - req = NewRequest(t, "GET", url) - MakeRequest(t, req, http.StatusNotFound) + t.Run("DeleteWithLock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create package and lock it + req := NewRequestWithBody(t, "POST", url, strings.NewReader(genState(1))).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) - req = NewRequest(t, "DELETE", url). - AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusNotFound) + lockID := uuid.New().String() + lockInfo := genLock(lockID) + req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Delete package should fail + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusLocked) + + // Verify package STILL EXISTS (testing the bug I found, though it might fail if I didn't fix it yet) + // User said "Don't modify the logic that exists currently", so I'll just add the assertion. + // If it fails, it proves the bug. + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) + assert.NoError(t, err, "Package should still exist because it is locked") + assert.NotNil(t, p) + + // Cleanup + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) }) }) } From 2ef423c7f3ff45b949286e5b95e86d4bede2a31d Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Thu, 26 Feb 2026 00:25:49 +0100 Subject: [PATCH 15/61] fix locking not returning the held lock --- routers/api/packages/terraform/terraform.go | 2 +- tests/integration/api_packages_terraform_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index a32c45d1213ea..030915da88e74 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -366,7 +366,7 @@ func LockState(ctx *context.Context) { } if len(props) > 0 && props[0].Value != "" { - apiError(ctx, http.StatusLocked, errors.New("terraform state is already locked")) + apiError(ctx, http.StatusLocked, props[0].Value) return } diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 3547911a52835..bceac52479a25 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -192,7 +192,8 @@ func TestPackageTerraform(t *testing.T) { // Another lock attempt should fail req = NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo2)).AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusLocked) + resp := MakeRequest(t, req, http.StatusLocked) + assert.JSONEq(t, lockInfo1, resp.Body.String()) // Unlock with wrong ID should fail req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo2)).AddBasicAuth(user.Name) From 86d0438517e4d3f3945f7d2f79258bfeb0ad172c Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Fri, 27 Feb 2026 17:34:05 +0100 Subject: [PATCH 16/61] extract consts --- routers/api/packages/terraform/terraform.go | 43 +++++++------------ .../api/packages/terraform/terraform_test.go | 29 ------------- .../api_packages_terraform_test.go | 6 +-- 3 files changed, 17 insertions(+), 61 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 030915da88e74..ae41d46d849fc 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -25,9 +25,11 @@ import ( packages_service "code.gitea.io/gitea/services/packages" ) -var ( - packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) - filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) +var packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) + +const ( + stateFilename = "tfstate" + lockFile = "terraform.lock" ) func apiError(ctx *context.Context, status int, obj any) { @@ -46,7 +48,6 @@ func GetTerraformState(ctx *context.Context) { Sort: packages_model.SortCreatedDesc, }) if err != nil { - // TODO: should this be some other fail? When does this error out? apiError(ctx, http.StatusInternalServerError, err) return } @@ -74,7 +75,7 @@ func streamState(ctx *context.Context, name, serial string) { Version: serial, }, &packages_service.PackageFileInfo{ - Filename: "tfstate", + Filename: stateFilename, // CompositeKey: "state", }, ctx.Req.Method, @@ -98,18 +99,12 @@ func isValidPackageName(packageName string) bool { return packageNameRegex.MatchString(packageName) && packageName != ".." } -func isValidFileName(filename string) bool { - return filenameRegex.MatchString(filename) && - strings.TrimSpace(filename) == filename && - filename != "." && filename != ".." -} - type TFState struct { Version int `json:"version"` TerraformVersion string `json:"terraform_version"` Serial uint64 `json:"serial"` Lineage string `json:"lineage"` - // modules are ommited + // modules are omitted } // UploadState uploads the specific terraform package. @@ -146,7 +141,6 @@ func UploadState(ctx *context.Context) { return } - // Check lineage p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusInternalServerError, err) @@ -154,7 +148,7 @@ func UploadState(ctx *context.Context) { } if p != nil { // Check lock - props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") + props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, lockFile) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -166,9 +160,7 @@ func UploadState(ctx *context.Context) { return } if existingLock.ID != ctx.FormString("ID") { - ctx.Resp.Header().Set("Content-Type", "application/json") - ctx.Resp.WriteHeader(http.StatusLocked) - _, _ = ctx.Resp.Write([]byte(props[0].Value)) + apiError(ctx, http.StatusLocked, props[0].Value) return } } @@ -178,7 +170,7 @@ func UploadState(ctx *context.Context) { apiError(ctx, http.StatusInternalServerError, err) return } - pv, _, err := packages_service.CreatePackageOrAddFileToExisting( + _, _, err = packages_service.CreatePackageOrAddFileToExisting( ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ @@ -191,7 +183,7 @@ func UploadState(ctx *context.Context) { }, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ - Filename: "tfstate", + Filename: stateFilename, }, Creator: ctx.Doer, Data: buf, @@ -211,10 +203,6 @@ func UploadState(ctx *context.Context) { return } - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, pv.PackageID, "terraform.lineage", state.Lineage); err != nil { - log.Error("InsertOrUpdateProperty: %v", err) - } - ctx.Status(http.StatusCreated) } @@ -226,7 +214,6 @@ func DeleteStateBySerial(ctx *context.Context) { apiError(ctx, http.StatusNotFound, err) return } else if err != nil { - apiError(ctx, http.StatusInternalServerError, err) return } @@ -274,7 +261,7 @@ func DeleteState(ctx *context.Context) { return } - pp, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") + pp, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, lockFile) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -323,7 +310,7 @@ type LockInfo struct { // Internally, it adds a property to the package with the lock information // Cavieat being that it allocates a package one doesn't exist to attach the property func LockState(ctx *context.Context) { - var reqLockInfo LockInfo + var reqLockInfo *LockInfo if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { apiError(ctx, http.StatusBadRequest, err) return @@ -376,7 +363,7 @@ func LockState(ctx *context.Context) { return } - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock", string(jsonBytes)); err != nil { + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, lockFile, string(jsonBytes)); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -444,7 +431,7 @@ func UnlockState(ctx *context.Context) { } // We can clear the state if lock id matches - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock", ""); err != nil { + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, lockFile, ""); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } diff --git a/routers/api/packages/terraform/terraform_test.go b/routers/api/packages/terraform/terraform_test.go index 6e69100a28c7e..8b6a589add6e1 100644 --- a/routers/api/packages/terraform/terraform_test.go +++ b/routers/api/packages/terraform/terraform_test.go @@ -34,32 +34,3 @@ func TestValidatePackageName(t *testing.T) { assert.True(t, isValidPackageName(name), "good=%q", name) } } - -func TestValidateFileName(t *testing.T) { - bad := []string{ - "", - ".", - "..", - "a?b", - "a/b", - " a", - "a ", - } - for _, name := range bad { - assert.False(t, isValidFileName(name), "bad=%q", name) - } - - good := []string{ - "-", - "a", - "1", - "a-", - "a_b", - "a b", - "c.d+", - `-_+=:;.()[]{}~!@#$%^& aA1`, - } - for _, name := range good { - assert.True(t, isValidFileName(name), "good=%q", name) - } -} diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index bceac52479a25..7d19fc2d6d8f7 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -251,11 +251,9 @@ func TestPackageTerraform(t *testing.T) { req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusLocked) - // Verify package STILL EXISTS (testing the bug I found, though it might fail if I didn't fix it yet) - // User said "Don't modify the logic that exists currently", so I'll just add the assertion. - // If it fails, it proves the bug. + // Verify package exists p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) - assert.NoError(t, err, "Package should still exist because it is locked") + require.NoError(t, err) assert.NotNil(t, p) // Cleanup From f9851338eaed5325b46dd9336fbbf43c4ba73b63 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Fri, 27 Feb 2026 17:37:54 +0100 Subject: [PATCH 17/61] remove unlocker since state is stored in database now --- modules/globallock/globallock.go | 4 ---- modules/globallock/locker.go | 2 -- modules/globallock/memory_locker.go | 5 ----- modules/globallock/redis_locker.go | 13 ------------- 4 files changed, 24 deletions(-) diff --git a/modules/globallock/globallock.go b/modules/globallock/globallock.go index 470d5f88f0342..24e91881bb338 100644 --- a/modules/globallock/globallock.go +++ b/modules/globallock/globallock.go @@ -45,10 +45,6 @@ func TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) { return DefaultLocker().TryLock(ctx, key) } -func Unlock(ctx context.Context, key string) error { - return DefaultLocker().Unlock(ctx, key) -} - // LockAndDo tries to acquire a lock for the given key and then calls the given function. // It uses the default locker, and it will return an error if failed to acquire the lock. func LockAndDo(ctx context.Context, key string, f func(context.Context) error) error { diff --git a/modules/globallock/locker.go b/modules/globallock/locker.go index 2d399eec886ef..682e24d052aba 100644 --- a/modules/globallock/locker.go +++ b/modules/globallock/locker.go @@ -32,8 +32,6 @@ type Locker interface { // And if it fails to acquire the lock because it's already locked, not other reasons like redis is down, // it will return false without any error. TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) - - Unlock(ctx context.Context, key string) error } // ReleaseFunc is a function that releases a lock. diff --git a/modules/globallock/memory_locker.go b/modules/globallock/memory_locker.go index 086986be6e00b..3f818d8d43929 100644 --- a/modules/globallock/memory_locker.go +++ b/modules/globallock/memory_locker.go @@ -61,11 +61,6 @@ func (l *memoryLocker) TryLock(_ context.Context, key string) (bool, ReleaseFunc return false, func() {}, nil } -func (l *memoryLocker) Unlock(_ context.Context, key string) error { - l.locks.Delete(key) - return nil -} - func (l *memoryLocker) tryLock(key string) bool { _, loaded := l.locks.LoadOrStore(key, struct{}{}) return !loaded diff --git a/modules/globallock/redis_locker.go b/modules/globallock/redis_locker.go index 37b7409a78ed6..45dc769fd499b 100644 --- a/modules/globallock/redis_locker.go +++ b/modules/globallock/redis_locker.go @@ -64,19 +64,6 @@ func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFun return err == nil, f, err } -func (l *redisLocker) Unlock(ctx context.Context, key string) error { - mutex, ok := l.mutexM.Load(key) - if ok { - l.mutexM.Delete(key) - - // It's safe to ignore the error here, - // if it failed to unlock, it will be released automatically after the lock expires. - // Do not call mutex.UnlockContext(ctx) here, or it will fail to release when ctx has timed out. - _, _ = mutex.(*redsync.Mutex).Unlock() - } - return nil -} - // Close closes the locker. // It will stop extending the locks and refuse to acquire new locks. // In actual use, it is not necessary to call this function. From d67b55e9c5bb8270c548d1a04dccdf8d5ddde358 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Fri, 27 Feb 2026 21:19:35 +0100 Subject: [PATCH 18/61] extract common helpers --- routers/api/packages/terraform/terraform.go | 145 ++++++++++---------- 1 file changed, 70 insertions(+), 75 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index ae41d46d849fc..e004b71508005 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -40,23 +40,15 @@ func apiError(ctx *context.Context, status int, obj any) { // GetTerraformState serves the latest version of the state func GetTerraformState(ctx *context.Context) { stateName := ctx.PathParam("name") - pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ - OwnerID: ctx.Package.Owner.ID, - Type: packages_model.TypeTerraform, - Name: packages_model.SearchValue{ExactMatch: true, Value: stateName}, - IsInternal: optional.Some(false), - Sort: packages_model.SortCreatedDesc, - }) - if err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - if len(pvs) == 0 { + pv, err := getLatestVersion(ctx, stateName) + if errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusNotFound, nil) return + } else if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return } - - streamState(ctx, stateName, pvs[0].Version) + streamState(ctx, stateName, pv.Version) } // GetTerraformStateBySerial serves a specific version of terraform state. @@ -76,7 +68,6 @@ func streamState(ctx *context.Context, name, serial string) { }, &packages_service.PackageFileInfo{ Filename: stateFilename, - // CompositeKey: "state", }, ctx.Req.Method, ) @@ -116,6 +107,24 @@ func UploadState(ctx *context.Context) { return } + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if p != nil { + // Check lock + lock, err := getLock(ctx, p.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if lock.ID != ctx.FormString("ID") { + ctx.JSON(http.StatusLocked, lock) + return + } + } + upload, needToClose, err := ctx.UploadStream() if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -141,31 +150,6 @@ func UploadState(ctx *context.Context) { return } - p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) - if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { - apiError(ctx, http.StatusInternalServerError, err) - return - } - if p != nil { - // Check lock - props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, lockFile) - if err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - if len(props) > 0 && props[0].Value != "" { - var existingLock LockInfo - if err := json.Unmarshal([]byte(props[0].Value), &existingLock); err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - if existingLock.ID != ctx.FormString("ID") { - apiError(ctx, http.StatusLocked, props[0].Value) - return - } - } - } - if _, err := buf.Seek(0, io.SeekStart); err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -185,10 +169,9 @@ func UploadState(ctx *context.Context) { PackageFileInfo: packages_service.PackageFileInfo{ Filename: stateFilename, }, - Creator: ctx.Doer, - Data: buf, - IsLead: true, - OverwriteExisting: true, + Creator: ctx.Doer, + Data: buf, + IsLead: true, }, ) if err != nil { @@ -218,22 +201,15 @@ func DeleteStateBySerial(ctx *context.Context) { return } - pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ - OwnerID: ctx.Package.Owner.ID, - Type: packages_model.TypeTerraform, - Name: packages_model.SearchValue{ExactMatch: true, Value: ctx.PathParam("name")}, - IsInternal: optional.Some(false), - Sort: packages_model.SortCreatedDesc, - }) - if err != nil { - apiError(ctx, http.StatusInternalServerError, err) + pvLatest, err := getLatestVersion(ctx, ctx.PathParam("name")) + if errors.Is(err, packages_model.ErrPackageNotExist) { + apiError(ctx, http.StatusNotFound, err) return - } - if len(pvs) == 0 { - apiError(ctx, http.StatusNotFound, nil) + } else if err != nil { + apiError(ctx, http.StatusInternalServerError, err) return } - if pvs[0].ID == pv.ID { + if pvLatest.ID == pv.ID { apiError(ctx, http.StatusForbidden, errors.New("cannot delete the latest version")) return } @@ -261,12 +237,12 @@ func DeleteState(ctx *context.Context) { return } - pp, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, lockFile) + lock, err := getLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if len(pp) > 0 && pp[0].Value != "" { + if lock.ID != "" { apiError(ctx, http.StatusLocked, errors.New("terraform state is locked")) return } @@ -346,14 +322,14 @@ func LockState(ctx *context.Context) { } } - props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") + currentLock, err := getLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if len(props) > 0 && props[0].Value != "" { - apiError(ctx, http.StatusLocked, props[0].Value) + if currentLock.ID != "" { + ctx.JSON(http.StatusLocked, currentLock) return } @@ -400,24 +376,12 @@ func UnlockState(ctx *context.Context) { return } - props, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, p.ID, "terraform.lock") + existingLock, err := getLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - // If there are no properties or the property is empty, it should be unlocked - if len(props) == 0 || props[0].Value == "" { - ctx.Status(http.StatusOK) - return - } - - var existingLock LockInfo - if err := json.Unmarshal([]byte(props[0].Value), &existingLock); err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - // we can bypass messing with the lock since it's empty if existingLock.ID == "" { ctx.Status(http.StatusOK) @@ -438,3 +402,34 @@ func UnlockState(ctx *context.Context) { ctx.Status(http.StatusOK) } + +func getLock(ctx *context.Context, packageID int64) (LockInfo, error) { + var lock LockInfo + locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, lockFile) + if err != nil { + return lock, err + } + if len(locks) == 0 || locks[0].Value == "" { + return lock, nil + } + + err = json.Unmarshal([]byte(locks[0].Value), &lock) + return lock, err +} + +func getLatestVersion(ctx *context.Context, packageName string) (*packages_model.PackageVersion, error) { + pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeTerraform, + Name: packages_model.SearchValue{ExactMatch: true, Value: packageName}, + IsInternal: optional.Some(false), + Sort: packages_model.SortCreatedDesc, + }) + if err != nil { + return nil, err + } + if len(pvs) == 0 { + return nil, packages_model.ErrPackageNotExist + } + return pvs[0], nil +} From cb80dde005dd6dfe69b020b6487641736a317edd Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Fri, 27 Feb 2026 21:34:59 +0100 Subject: [PATCH 19/61] fix svg --- public/assets/img/svg/gitea-terraform.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg index 82db40da8671e..ebc095634af1c 100644 --- a/public/assets/img/svg/gitea-terraform.svg +++ b/public/assets/img/svg/gitea-terraform.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From c0c6d5ba1fd70b417482a8d0b931ee29ad762c79 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 28 Feb 2026 15:39:08 +0100 Subject: [PATCH 20/61] rename const --- models/packages/descriptor.go | 2 +- models/packages/package.go | 52 +++++++++---------- routers/api/packages/terraform/terraform.go | 18 +++---- services/packages/packages.go | 2 +- .../api_packages_terraform_test.go | 10 ++-- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 018407321c74d..7c2df8a3e3496 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -203,7 +203,7 @@ func GetPackageDescriptorWithCache(ctx context.Context, pv *PackageVersion, c *c metadata = &rubygems.Metadata{} case TypeSwift: metadata = &swift.Metadata{} - case TypeTerraform: + case TypeTerraformState: // terraform packages have no metadata case TypeVagrant: metadata = &vagrant.Metadata{} diff --git a/models/packages/package.go b/models/packages/package.go index ae182d715ec48..315eb1b76446e 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -30,29 +30,29 @@ type Type string // List of supported packages const ( - TypeAlpine Type = "alpine" - TypeArch Type = "arch" - TypeCargo Type = "cargo" - TypeChef Type = "chef" - TypeComposer Type = "composer" - TypeConan Type = "conan" - TypeConda Type = "conda" - TypeContainer Type = "container" - TypeCran Type = "cran" - TypeDebian Type = "debian" - TypeGeneric Type = "generic" - TypeGo Type = "go" - TypeHelm Type = "helm" - TypeMaven Type = "maven" - TypeNpm Type = "npm" - TypeNuGet Type = "nuget" - TypePub Type = "pub" - TypePyPI Type = "pypi" - TypeRpm Type = "rpm" - TypeRubyGems Type = "rubygems" - TypeSwift Type = "swift" - TypeTerraform Type = "terraform" - TypeVagrant Type = "vagrant" + TypeAlpine Type = "alpine" + TypeArch Type = "arch" + TypeCargo Type = "cargo" + TypeChef Type = "chef" + TypeComposer Type = "composer" + TypeConan Type = "conan" + TypeConda Type = "conda" + TypeContainer Type = "container" + TypeCran Type = "cran" + TypeDebian Type = "debian" + TypeGeneric Type = "generic" + TypeGo Type = "go" + TypeHelm Type = "helm" + TypeMaven Type = "maven" + TypeNpm Type = "npm" + TypeNuGet Type = "nuget" + TypePub Type = "pub" + TypePyPI Type = "pypi" + TypeRpm Type = "rpm" + TypeRubyGems Type = "rubygems" + TypeSwift Type = "swift" + TypeTerraformState Type = "terraformstate" + TypeVagrant Type = "vagrant" ) var TypeList = []Type{ @@ -77,7 +77,7 @@ var TypeList = []Type{ TypeRpm, TypeRubyGems, TypeSwift, - TypeTerraform, + TypeTerraformState, TypeVagrant, } @@ -126,7 +126,7 @@ func (pt Type) Name() string { return "RubyGems" case TypeSwift: return "Swift" - case TypeTerraform: + case TypeTerraformState: return "Terraform" case TypeVagrant: return "Vagrant" @@ -179,7 +179,7 @@ func (pt Type) SVGName() string { return "gitea-rubygems" case TypeSwift: return "gitea-swift" - case TypeTerraform: + case TypeTerraformState: return "gitea-terraform" case TypeVagrant: return "gitea-vagrant" diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index e004b71508005..c91d5248d08b5 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -62,7 +62,7 @@ func streamState(ctx *context.Context, name, serial string) { ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, - PackageType: packages_model.TypeTerraform, + PackageType: packages_model.TypeTerraformState, Name: name, Version: serial, }, @@ -107,7 +107,7 @@ func UploadState(ctx *context.Context) { return } - p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName) if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusInternalServerError, err) return @@ -159,7 +159,7 @@ func UploadState(ctx *context.Context) { &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, - PackageType: packages_model.TypeTerraform, + PackageType: packages_model.TypeTerraformState, Name: packageName, Version: strconv.FormatUint(state.Serial, 10), }, @@ -192,7 +192,7 @@ func UploadState(ctx *context.Context) { // DeleteStateBySerial deletes the specific serial of a terraform package as long as it's not the latest one. func DeleteStateBySerial(ctx *context.Context) { serial := ctx.PathParam("serial") - pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, ctx.PathParam("name"), serial) + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, ctx.PathParam("name"), serial) if errors.Is(err, packages_model.ErrPackageFileNotExist) { apiError(ctx, http.StatusNotFound, err) return @@ -227,7 +227,7 @@ func DeleteStateBySerial(ctx *context.Context) { func DeleteState(ctx *context.Context) { packageName := ctx.PathParam("name") - p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName) if err != nil { if errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusNotFound, err) @@ -302,13 +302,13 @@ func LockState(ctx *context.Context) { } defer release() - p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName) if err != nil { // If the package doesn't exist, allocate it for the lock. if errors.Is(err, packages_model.ErrPackageNotExist) { p = &packages_model.Package{ OwnerID: ctx.Package.Owner.ID, - Type: packages_model.TypeTerraform, + Type: packages_model.TypeTerraformState, Name: packageName, LowerName: strings.ToLower(packageName), } @@ -366,7 +366,7 @@ func UnlockState(ctx *context.Context) { } defer release() - p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraform, packageName) + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName) if err != nil { if errors.Is(err, packages_model.ErrPackageNotExist) { ctx.Status(http.StatusOK) @@ -420,7 +420,7 @@ func getLock(ctx *context.Context, packageID int64) (LockInfo, error) { func getLatestVersion(ctx *context.Context, packageName string) (*packages_model.PackageVersion, error) { pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, - Type: packages_model.TypeTerraform, + Type: packages_model.TypeTerraformState, Name: packages_model.SearchValue{ExactMatch: true, Value: packageName}, IsInternal: optional.Some(false), Sort: packages_model.SortCreatedDesc, diff --git a/services/packages/packages.go b/services/packages/packages.go index 98299c9928988..1125c405e061b 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -394,7 +394,7 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p typeSpecificSize = setting.Packages.LimitSizeRubyGems case packages_model.TypeSwift: typeSpecificSize = setting.Packages.LimitSizeSwift - case packages_model.TypeTerraform: + case packages_model.TypeTerraformState: typeSpecificSize = setting.Packages.LimitSizeTerraform case packages_model.TypeVagrant: typeSpecificSize = setting.Packages.LimitSizeVagrant diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 7d19fc2d6d8f7..98b570dec129a 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -83,7 +83,7 @@ func TestPackageTerraform(t *testing.T) { MakeRequest(t, req, http.StatusOK) // Verify lock property in DB - p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) require.NoError(t, err) props, err := packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") require.NoError(t, err) @@ -96,7 +96,7 @@ func TestPackageTerraform(t *testing.T) { MakeRequest(t, req, http.StatusCreated) // Verify version created - pv, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraform, packageName, "1") + pv, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "1") assert.NoError(t, err) assert.NotNil(t, pv) @@ -125,7 +125,7 @@ func TestPackageTerraform(t *testing.T) { MakeRequest(t, req, http.StatusOK) // Verify package is deleted from DB - _, err = packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) + _, err = packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) assert.ErrorIs(t, err, packages.ErrPackageNotExist) }) @@ -152,7 +152,7 @@ func TestPackageTerraform(t *testing.T) { MakeRequest(t, req, http.StatusNoContent) // Verify version 1 is gone from DB - _, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraform, packageName, "1") + _, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "1") assert.ErrorIs(t, err, packages.ErrPackageNotExist) // Verify version 1 is gone from API @@ -252,7 +252,7 @@ func TestPackageTerraform(t *testing.T) { MakeRequest(t, req, http.StatusLocked) // Verify package exists - p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraform, packageName) + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) require.NoError(t, err) assert.NotNil(t, p) From d29cc852c7aeff81ffd983f0f6c4e1726bf10afe Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sat, 28 Feb 2026 15:43:10 +0100 Subject: [PATCH 21/61] rephrase the setup section --- options/locale/locale_en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 489bb82d485aa..83d86c0ce27d5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3569,7 +3569,7 @@ "packages.swift.registry": "Set up this registry from the command line:", "packages.swift.install": "Add the package in your Package.swift file:", "packages.swift.install2": "and run the following command:", - "packages.terraform.install": "Setup this registry to your backend config", + "packages.terraform.install": "Set your state to use the http backend", "packages.terraform.install2": "and run the following command:", "packages.vagrant.install": "To add a Vagrant box, run the following command:", "packages.settings.link": "Link this package to a repository", From 98f01181c06d498248d16ffcccd0fa1ae724d375 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 8 Mar 2026 21:59:23 +0100 Subject: [PATCH 22/61] add lock display in UI Assisted-by: gemini 3 via gemini-cli --- models/packages/package.go | 2 +- options/locale/locale_en-US.json | 7 ++ routers/web/user/package.go | 80 +++++++++++++++++++++++ routers/web/web.go | 2 + templates/package/metadata/terraform.tmpl | 42 ++++++++++++ templates/package/shared/list.tmpl | 2 +- 6 files changed, 133 insertions(+), 2 deletions(-) diff --git a/models/packages/package.go b/models/packages/package.go index 315eb1b76446e..7d152ff3064b2 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -51,7 +51,7 @@ const ( TypeRpm Type = "rpm" TypeRubyGems Type = "rubygems" TypeSwift Type = "swift" - TypeTerraformState Type = "terraformstate" + TypeTerraformState Type = "terraform" TypeVagrant Type = "vagrant" ) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 29ce9c6f698bd..9e1dda58bb9a4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3565,6 +3565,13 @@ "packages.swift.install2": "and run the following command:", "packages.terraform.install": "Set your state to use the http backend", "packages.terraform.install2": "and run the following command:", + "packages.terraform.lock_status": "Lock Status", + "packages.terraform.locked_by": "Locked by %s", + "packages.terraform.unlocked": "Unlocked", + "packages.terraform.lock": "Lock", + "packages.terraform.unlock": "Unlock", + "packages.terraform.lock.success": "Terraform state was successfully locked.", + "packages.terraform.unlock.success": "Terraform state was successfully unlocked.", "packages.vagrant.install": "To add a Vagrant box, run the following command:", "packages.settings.link": "Link this package to a repository", "packages.settings.link.description": "If you link a package with a repository, the package will appear in the repository's package list. Only repositories under the same owner can be linked. Leaving the field empty will remove the link.", diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 2dad5be554f5a..55f7cb24857d7 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "net/url" + "time" "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" @@ -18,6 +19,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" alpine_module "code.gitea.io/gitea/modules/packages/alpine" @@ -35,6 +37,8 @@ import ( "code.gitea.io/gitea/services/forms" packages_service "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" + + "github.com/google/uuid" ) const ( @@ -316,6 +320,34 @@ func ViewPackageVersion(ctx *context.Context) { ctx.Data["LatestVersions"] = pvs ctx.Data["TotalVersionCount"] = pvsTotal + if pd.Package.Type == packages_model.TypeTerraformState { + latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: pd.Package.ID, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchLatestVersions", err) + return + } + isLatest := len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID + ctx.Data["IsLatestVersion"] = isLatest + + if isLatest { + lockProps, _ := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, pd.Package.ID, "terraform.lock") + if len(lockProps) > 0 && lockProps[0].Value != "" { + var lockInfo struct { + ID string `json:"ID"` + Who string `json:"Who"` + Operation string `json:"Operation"` + Created time.Time `json:"Created"` + } + if err := json.Unmarshal([]byte(lockProps[0].Value), &lockInfo); err == nil { + ctx.Data["TerraformLock"] = lockInfo + } + } + } + } + ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() hasRepositoryAccess := false @@ -528,3 +560,51 @@ func DownloadPackageFile(ctx *context.Context) { packages_helper.ServePackageFile(ctx, s, u, pf) } + +// ActionPackageTerraformLock locks a terraform state +func ActionPackageTerraformLock(ctx *context.Context) { + pd := ctx.Package.Descriptor + if pd.Package.Type != packages_model.TypeTerraformState { + ctx.NotFound(nil) + return + } + + lockID := uuid.New().String() + lockInfo := struct { + ID string `json:"ID"` + Operation string `json:"Operation"` + Who string `json:"Who"` + Created time.Time `json:"Created"` + }{ + ID: lockID, + Operation: "Manual UI Lock", + Who: ctx.Doer.Name, + Created: time.Now(), + } + + lockJSON, _ := json.Marshal(lockInfo) + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, pd.Package.ID, "terraform.lock", string(lockJSON)); err != nil { + ctx.ServerError("InsertOrUpdateProperty", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.terraform.lock.success")) + ctx.Redirect(pd.VersionWebLink()) +} + +// ActionPackageTerraformUnlock unlocks a terraform state +func ActionPackageTerraformUnlock(ctx *context.Context) { + pd := ctx.Package.Descriptor + if pd.Package.Type != packages_model.TypeTerraformState { + ctx.NotFound(nil) + return + } + + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, pd.Package.ID, "terraform.lock", ""); err != nil { + ctx.ServerError("InsertOrUpdateProperty", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.terraform.unlock.success")) + ctx.Redirect(pd.VersionWebLink()) +} diff --git a/routers/web/web.go b/routers/web/web.go index 95a54b5244bdb..4b965cf7fd919 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1054,6 +1054,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/{version}", func() { m.Get("", user.ViewPackageVersion) m.Get("/{version_sub}", user.ViewPackageVersion) + m.Post("/terraform/lock", user.ActionPackageTerraformLock) + m.Post("/terraform/unlock", user.ActionPackageTerraformUnlock) m.Get("/files/{fileid}", user.DownloadPackageFile) m.Group("/settings", func() { m.Get("", user.PackageSettings) diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl index e69de29bb2d1d..fa83071c5c327 100644 --- a/templates/package/metadata/terraform.tmpl +++ b/templates/package/metadata/terraform.tmpl @@ -0,0 +1,42 @@ +{{if eq .PackageDescriptor.Package.Type "terraform"}} + {{if .IsLatestVersion}} +
+
+
+ {{ctx.Locale.Tr "packages.terraform.lock_status"}} +
+
+ {{if .TerraformLock}} +
+ {{svg "octicon-lock" 16 "tw-text-red"}} + {{ctx.Locale.Tr "packages.terraform.locked_by" .TerraformLock.Who}} +
+
+ {{DateUtils.TimeSince .TerraformLock.Created}} ({{.TerraformLock.Operation}}) +
+ {{if .CanWritePackages}} +
+
+ {{$.CsrfTokenHtml}} + +
+
+ {{end}} + {{else}} +
+ {{svg "octicon-lock-unlocked" 16 "tw-text-green"}} + {{ctx.Locale.Tr "packages.terraform.unlocked"}} +
+ {{if .CanWritePackages}} +
+
+ {{$.CsrfTokenHtml}} + +
+
+ {{end}} + {{end}} +
+
+ {{end}} +{{end}} diff --git a/templates/package/shared/list.tmpl b/templates/package/shared/list.tmpl index 3306d8aa945eb..abd52da931bc1 100644 --- a/templates/package/shared/list.tmpl +++ b/templates/package/shared/list.tmpl @@ -21,7 +21,7 @@
{{if eq .Package.Type "terraform"}} - {{.Package.Name}} + {{.Package.Name}} {{else}} {{.Package.Name}} {{end}} From ce4bf248bbfee73770817958b539a41d1105dc25 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 8 Mar 2026 22:04:54 +0100 Subject: [PATCH 23/61] fix icon --- templates/package/metadata/terraform.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl index fa83071c5c327..741409aab2b2a 100644 --- a/templates/package/metadata/terraform.tmpl +++ b/templates/package/metadata/terraform.tmpl @@ -8,7 +8,7 @@
{{if .TerraformLock}}
- {{svg "octicon-lock" 16 "tw-text-red"}} + {{svg "octicon-lock" 16 "tw-text-red"}} {{ctx.Locale.Tr "packages.terraform.locked_by" .TerraformLock.Who}}
@@ -24,7 +24,7 @@ {{end}} {{else}}
- {{svg "octicon-lock-unlocked" 16 "tw-text-green"}} + {{svg "octicon-unlock" 16 "tw-text-green"}} {{ctx.Locale.Tr "packages.terraform.unlocked"}}
{{if .CanWritePackages}} From 21da29ac43809e2588ca5aa9b46392b4de1e2e39 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 8 Mar 2026 22:34:59 +0100 Subject: [PATCH 24/61] prevent deletion of locked and latest packages in UI Assisted-by: gemini 3 via gemini-cli --- options/locale/locale_en-US.json | 2 + routers/web/user/package.go | 68 ++++++++++++++++++++++++++------ templates/package/view.tmpl | 2 + 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 9e1dda58bb9a4..d434c972ce3ca 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3572,6 +3572,8 @@ "packages.terraform.unlock": "Unlock", "packages.terraform.lock.success": "Terraform state was successfully locked.", "packages.terraform.unlock.success": "Terraform state was successfully unlocked.", + "packages.terraform.delete.locked": "Terraform state is locked and cannot be deleted.", + "packages.terraform.delete.latest": "The latest version of a Terraform state cannot be deleted.", "packages.vagrant.install": "To add a Vagrant box, run the following command:", "packages.settings.link": "Link this package to a repository", "packages.settings.link.description": "If you link a package with a repository, the package will appear in the repository's package list. Only repositories under the same owner can be linked. Leaving the field empty will remove the link.", diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 55f7cb24857d7..f4fd760865d7c 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -48,6 +48,29 @@ const ( tplPackagesSettings templates.TplName = "package/settings" ) +type terraformLockInfo struct { + ID string `json:"ID"` + Operation string `json:"Operation"` + Who string `json:"Who"` + Created time.Time `json:"Created"` +} + +func getTerraformLock(ctx *context.Context, packageID int64) (*terraformLockInfo, error) { + locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, "terraform.lock") + if err != nil { + return nil, err + } + if len(locks) == 0 || locks[0].Value == "" { + return nil, nil + } + + var lock terraformLockInfo + if err := json.Unmarshal([]byte(locks[0].Value), &lock); err != nil { + return nil, err + } + return &lock, nil +} + // ListPackages displays a list of all packages of the context user func ListPackages(ctx *context.Context) { if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { @@ -333,18 +356,8 @@ func ViewPackageVersion(ctx *context.Context) { ctx.Data["IsLatestVersion"] = isLatest if isLatest { - lockProps, _ := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, pd.Package.ID, "terraform.lock") - if len(lockProps) > 0 && lockProps[0].Value != "" { - var lockInfo struct { - ID string `json:"ID"` - Who string `json:"Who"` - Operation string `json:"Operation"` - Created time.Time `json:"Created"` - } - if err := json.Unmarshal([]byte(lockProps[0].Value), &lockInfo); err == nil { - ctx.Data["TerraformLock"] = lockInfo - } - } + lockInfo, _ := getTerraformLock(ctx, pd.Package.ID) + ctx.Data["TerraformLock"] = lockInfo } } @@ -523,7 +536,36 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett } func packageSettingsPostActionDelete(ctx *context.Context) { - err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) + pd := ctx.Package.Descriptor + + if pd.Package.Type == packages_model.TypeTerraformState { + lock, err := getTerraformLock(ctx, pd.Package.ID) + if err != nil { + ctx.ServerError("getTerraformLock", err) + return + } + if lock != nil { + ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked")) + ctx.Redirect(pd.VersionWebLink() + "/settings") + return + } + + latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: pd.Package.ID, + IsInternal: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchLatestVersions", err) + return + } + if len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID { + ctx.Flash.Error(ctx.Tr("packages.terraform.delete.latest")) + ctx.Redirect(pd.VersionWebLink() + "/settings") + return + } + } + + err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version) if err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 9067f44296b72..e3943fe341ec2 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -3,12 +3,14 @@
{{template "org/header" .}}
+ {{template "base/alert" .}} {{template "package/shared/view" .}}
{{else}}
+ {{template "base/alert" .}}
{{template "shared/user/profile_big_avatar" .}} From c455ccd11a921e258f1603ff13e89a744206c6be Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 8 Mar 2026 22:43:54 +0100 Subject: [PATCH 25/61] fix nilnil --- routers/web/user/package.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/routers/web/user/package.go b/routers/web/user/package.go index f4fd760865d7c..80c117db0557e 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -55,20 +55,18 @@ type terraformLockInfo struct { Created time.Time `json:"Created"` } -func getTerraformLock(ctx *context.Context, packageID int64) (*terraformLockInfo, error) { +func getTerraformLock(ctx *context.Context, packageID int64) (terraformLockInfo, error) { + var lock terraformLockInfo locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, "terraform.lock") if err != nil { - return nil, err + return lock, err } if len(locks) == 0 || locks[0].Value == "" { - return nil, nil + return lock, nil } - var lock terraformLockInfo - if err := json.Unmarshal([]byte(locks[0].Value), &lock); err != nil { - return nil, err - } - return &lock, nil + err = json.Unmarshal([]byte(locks[0].Value), &lock) + return lock, err } // ListPackages displays a list of all packages of the context user @@ -544,7 +542,7 @@ func packageSettingsPostActionDelete(ctx *context.Context) { ctx.ServerError("getTerraformLock", err) return } - if lock != nil { + if lock.ID != "" { ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked")) ctx.Redirect(pd.VersionWebLink() + "/settings") return From 799d7c02582055f207cf7ef79bbf5fbb3dff36a5 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 9 Mar 2026 09:12:23 +0100 Subject: [PATCH 26/61] factor out common parts to a module block UI from overwriting an existing lock Assissted-By: gemini-3 via gemini-cli --- models/packages/package.go | 2 +- models/packages/terraform/lock.go | 57 +++++++++++++++++++ options/locale/locale_en-US.json | 1 + routers/api/packages/terraform/terraform.go | 48 ++++------------ routers/web/user/package.go | 61 ++++++++------------- templates/package/metadata/terraform.tmpl | 2 - 6 files changed, 94 insertions(+), 77 deletions(-) create mode 100644 models/packages/terraform/lock.go diff --git a/models/packages/package.go b/models/packages/package.go index 7d152ff3064b2..17e5d4eee3502 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -127,7 +127,7 @@ func (pt Type) Name() string { case TypeSwift: return "Swift" case TypeTerraformState: - return "Terraform" + return "Terraform State" case TypeVagrant: return "Vagrant" } diff --git a/models/packages/terraform/lock.go b/models/packages/terraform/lock.go new file mode 100644 index 0000000000000..c79cdaff06a5e --- /dev/null +++ b/models/packages/terraform/lock.go @@ -0,0 +1,57 @@ +package terraform + +import ( + "context" + "time" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/json" +) + +const LockFile = "terraform.lock" + +// LockInfo is the metadata for a terraform lock. +type LockInfo struct { + ID string `json:"ID"` + Operation string `json:"Operation"` + Info string `json:"Info"` + Who string `json:"Who"` + Version string `json:"Version"` + Created time.Time `json:"Created"` + Path string `json:"Path"` +} + +func (l *LockInfo) IsLocked() bool { + return l.ID != "" +} + +// GetLock returns the terraform lock for the given package. +// Lock is empty if no lock exists. +func GetLock(ctx context.Context, packageID int64) (LockInfo, error) { + var lock LockInfo + locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, LockFile) + if err != nil { + return lock, err + } + if len(locks) == 0 || locks[0].Value == "" { + return lock, nil + } + + err = json.Unmarshal([]byte(locks[0].Value), &lock) + return lock, err +} + +// SetLock sets the terraform lock for the given package. +func SetLock(ctx context.Context, packageID int64, lock *LockInfo) error { + jsonBytes, err := json.Marshal(lock) + if err != nil { + return err + } + + return packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, packageID, LockFile, string(jsonBytes)) +} + +// RemoveLock removes the terraform lock for the given package. +func RemoveLock(ctx context.Context, packageID int64) error { + return packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, packageID, LockFile, "") +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index d434c972ce3ca..ce619d5fc051f 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3572,6 +3572,7 @@ "packages.terraform.unlock": "Unlock", "packages.terraform.lock.success": "Terraform state was successfully locked.", "packages.terraform.unlock.success": "Terraform state was successfully unlocked.", + "packages.terraform.lock.error.already_locked": "Terraform state is already locked.", "packages.terraform.delete.locked": "Terraform state is locked and cannot be deleted.", "packages.terraform.delete.latest": "The latest version of a Terraform state cannot be deleted.", "packages.vagrant.install": "To add a Vagrant box, run the following command:", diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index c91d5248d08b5..115edfcc981a9 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -11,10 +11,10 @@ import ( "regexp" "strconv" "strings" - "time" "unicode" packages_model "code.gitea.io/gitea/models/packages" + terraform_model "code.gitea.io/gitea/models/packages/terraform" "code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -29,7 +29,6 @@ var packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) const ( stateFilename = "tfstate" - lockFile = "terraform.lock" ) func apiError(ctx *context.Context, status int, obj any) { @@ -114,7 +113,7 @@ func UploadState(ctx *context.Context) { } if p != nil { // Check lock - lock, err := getLock(ctx, p.ID) + lock, err := terraform_model.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -237,12 +236,12 @@ func DeleteState(ctx *context.Context) { return } - lock, err := getLock(ctx, p.ID) + lock, err := terraform_model.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if lock.ID != "" { + if lock.IsLocked() { apiError(ctx, http.StatusLocked, errors.New("terraform state is locked")) return } @@ -271,22 +270,11 @@ func DeleteState(ctx *context.Context) { ctx.Status(http.StatusOK) } -// LockInfo is the metadata for a terraform lock. -type LockInfo struct { - ID string `json:"ID"` - Operation string `json:"Operation"` - Info string `json:"Info"` - Who string `json:"Who"` - Version string `json:"Version"` - Created time.Time `json:"Created"` - Path string `json:"Path"` -} - // LockState locks the specific terraform state. // Internally, it adds a property to the package with the lock information // Cavieat being that it allocates a package one doesn't exist to attach the property func LockState(ctx *context.Context) { - var reqLockInfo *LockInfo + var reqLockInfo *terraform_model.LockInfo if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { apiError(ctx, http.StatusBadRequest, err) return @@ -322,13 +310,13 @@ func LockState(ctx *context.Context) { } } - currentLock, err := getLock(ctx, p.ID) + currentLock, err := terraform_model.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if currentLock.ID != "" { + if currentLock.IsLocked() { ctx.JSON(http.StatusLocked, currentLock) return } @@ -339,7 +327,7 @@ func LockState(ctx *context.Context) { return } - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, lockFile, string(jsonBytes)); err != nil { + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, terraform_model.LockFile, string(jsonBytes)); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -350,7 +338,7 @@ func LockState(ctx *context.Context) { // UnlockState unlock the specific terraform state. // Internally, it clears the package property func UnlockState(ctx *context.Context) { - var reqLockInfo LockInfo + var reqLockInfo terraform_model.LockInfo if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { apiError(ctx, http.StatusBadRequest, err) return @@ -376,7 +364,7 @@ func UnlockState(ctx *context.Context) { return } - existingLock, err := getLock(ctx, p.ID) + existingLock, err := terraform_model.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -395,7 +383,7 @@ func UnlockState(ctx *context.Context) { } // We can clear the state if lock id matches - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, lockFile, ""); err != nil { + if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, terraform_model.LockFile, ""); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -403,20 +391,6 @@ func UnlockState(ctx *context.Context) { ctx.Status(http.StatusOK) } -func getLock(ctx *context.Context, packageID int64) (LockInfo, error) { - var lock LockInfo - locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, lockFile) - if err != nil { - return lock, err - } - if len(locks) == 0 || locks[0].Value == "" { - return lock, nil - } - - err = json.Unmarshal([]byte(locks[0].Value), &lock) - return lock, err -} - func getLatestVersion(ctx *context.Context, packageName string) (*packages_model.PackageVersion, error) { pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 80c117db0557e..b802306e3b4a2 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -14,12 +14,12 @@ import ( org_model "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" container_model "code.gitea.io/gitea/models/packages/container" + terraform_model "code.gitea.io/gitea/models/packages/terraform" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httplib" - "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" alpine_module "code.gitea.io/gitea/modules/packages/alpine" @@ -27,6 +27,7 @@ import ( container_module "code.gitea.io/gitea/modules/packages/container" debian_module "code.gitea.io/gitea/modules/packages/debian" rpm_module "code.gitea.io/gitea/modules/packages/rpm" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" @@ -48,27 +49,6 @@ const ( tplPackagesSettings templates.TplName = "package/settings" ) -type terraformLockInfo struct { - ID string `json:"ID"` - Operation string `json:"Operation"` - Who string `json:"Who"` - Created time.Time `json:"Created"` -} - -func getTerraformLock(ctx *context.Context, packageID int64) (terraformLockInfo, error) { - var lock terraformLockInfo - locks, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypePackage, packageID, "terraform.lock") - if err != nil { - return lock, err - } - if len(locks) == 0 || locks[0].Value == "" { - return lock, nil - } - - err = json.Unmarshal([]byte(locks[0].Value), &lock) - return lock, err -} - // ListPackages displays a list of all packages of the context user func ListPackages(ctx *context.Context) { if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { @@ -354,8 +334,10 @@ func ViewPackageVersion(ctx *context.Context) { ctx.Data["IsLatestVersion"] = isLatest if isLatest { - lockInfo, _ := getTerraformLock(ctx, pd.Package.ID) - ctx.Data["TerraformLock"] = lockInfo + lockInfo, _ := terraform_model.GetLock(ctx, pd.Package.ID) + if lockInfo.IsLocked() { + ctx.Data["TerraformLock"] = lockInfo + } } } @@ -537,12 +519,12 @@ func packageSettingsPostActionDelete(ctx *context.Context) { pd := ctx.Package.Descriptor if pd.Package.Type == packages_model.TypeTerraformState { - lock, err := getTerraformLock(ctx, pd.Package.ID) + lock, err := terraform_model.GetLock(ctx, pd.Package.ID) if err != nil { ctx.ServerError("getTerraformLock", err) return } - if lock.ID != "" { + if lock.IsLocked() { ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked")) ctx.Redirect(pd.VersionWebLink() + "/settings") return @@ -609,22 +591,27 @@ func ActionPackageTerraformLock(ctx *context.Context) { return } + existingLock, err := terraform_model.GetLock(ctx, pd.Package.ID) + if err != nil { + ctx.ServerError("GetLock", err) + return + } + if existingLock.IsLocked() { + ctx.Flash.Error(ctx.Tr("packages.terraform.lock.error.already_locked")) + ctx.Redirect(pd.VersionWebLink()) + return + } + lockID := uuid.New().String() - lockInfo := struct { - ID string `json:"ID"` - Operation string `json:"Operation"` - Who string `json:"Who"` - Created time.Time `json:"Created"` - }{ + lockInfo := &terraform_model.LockInfo{ ID: lockID, Operation: "Manual UI Lock", Who: ctx.Doer.Name, Created: time.Now(), } - lockJSON, _ := json.Marshal(lockInfo) - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, pd.Package.ID, "terraform.lock", string(lockJSON)); err != nil { - ctx.ServerError("InsertOrUpdateProperty", err) + if err := terraform_model.SetLock(ctx, pd.Package.ID, lockInfo); err != nil { + ctx.ServerError("SetLock", err) return } @@ -640,8 +627,8 @@ func ActionPackageTerraformUnlock(ctx *context.Context) { return } - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, pd.Package.ID, "terraform.lock", ""); err != nil { - ctx.ServerError("InsertOrUpdateProperty", err) + if err := terraform_model.RemoveLock(ctx, pd.Package.ID); err != nil { + ctx.ServerError("RemoveLock", err) return } diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl index 741409aab2b2a..d07b872179060 100644 --- a/templates/package/metadata/terraform.tmpl +++ b/templates/package/metadata/terraform.tmpl @@ -17,7 +17,6 @@ {{if .CanWritePackages}}
- {{$.CsrfTokenHtml}}
@@ -30,7 +29,6 @@ {{if .CanWritePackages}}
- {{$.CsrfTokenHtml}}
From 16dca47c9d4b331fb74112e88e7d9ee23625661e Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 9 Mar 2026 09:28:22 +0100 Subject: [PATCH 27/61] fix lint --- routers/web/user/package.go | 1 - 1 file changed, 1 deletion(-) diff --git a/routers/web/user/package.go b/routers/web/user/package.go index b802306e3b4a2..34478eefc72f2 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -27,7 +27,6 @@ import ( container_module "code.gitea.io/gitea/modules/packages/container" debian_module "code.gitea.io/gitea/modules/packages/debian" rpm_module "code.gitea.io/gitea/modules/packages/rpm" - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" From adb2cf2c9035c647f91db6bfb28b8ff9cdaab46d Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 9 Mar 2026 17:35:15 +0100 Subject: [PATCH 28/61] add copyright header --- models/packages/terraform/lock.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/models/packages/terraform/lock.go b/models/packages/terraform/lock.go index c79cdaff06a5e..3b35ddb011a17 100644 --- a/models/packages/terraform/lock.go +++ b/models/packages/terraform/lock.go @@ -1,3 +1,5 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT package terraform import ( From 21ec4c9b01c786e18d2aa8b8b24e16dbdc767b32 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 09:27:22 +0100 Subject: [PATCH 29/61] update copyright date --- models/packages/terraform/lock.go | 1 + routers/api/packages/terraform/terraform.go | 2 +- routers/api/packages/terraform/terraform_test.go | 2 +- tests/integration/api_packages_terraform_test.go | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/models/packages/terraform/lock.go b/models/packages/terraform/lock.go index 3b35ddb011a17..1d4e9a4734bf7 100644 --- a/models/packages/terraform/lock.go +++ b/models/packages/terraform/lock.go @@ -1,5 +1,6 @@ // Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT + package terraform import ( diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 115edfcc981a9..ccaf42001c361 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package terraform diff --git a/routers/api/packages/terraform/terraform_test.go b/routers/api/packages/terraform/terraform_test.go index 8b6a589add6e1..e4705d0ccf24f 100644 --- a/routers/api/packages/terraform/terraform_test.go +++ b/routers/api/packages/terraform/terraform_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package terraform diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 98b570dec129a..51b7ee93c5e4f 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration From 6504ed3c1a66ddbbddd7a20ef156d4f6f4c597e0 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 09:30:43 +0100 Subject: [PATCH 30/61] fix duplicated logic --- routers/api/packages/terraform/terraform.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index ccaf42001c361..f43ebdcd2199a 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -321,17 +321,12 @@ func LockState(ctx *context.Context) { return } - jsonBytes, err := json.Marshal(reqLockInfo) + err = terraform_model.SetLock(ctx, p.ID, reqLockInfo) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, terraform_model.LockFile, string(jsonBytes)); err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - ctx.Status(http.StatusOK) } @@ -381,9 +376,9 @@ func UnlockState(ctx *context.Context) { apiError(ctx, http.StatusLocked, errors.New("lock ID mismatch")) return } - // We can clear the state if lock id matches - if err := packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, p.ID, terraform_model.LockFile, ""); err != nil { + err = terraform_model.RemoveLock(ctx, p.ID) + if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } From b76bbc23a4966cfcae6b2cfe1373634a274fca49 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 09:35:45 +0100 Subject: [PATCH 31/61] add lock on api upload --- routers/api/packages/terraform/terraform.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index f43ebdcd2199a..9def949d2883a 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -105,6 +105,13 @@ func UploadState(ctx *context.Context) { apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) return } + lockKey := getLockKey(ctx) + release, err := globallock.Lock(ctx, lockKey) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer release() p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName) if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { @@ -279,10 +286,8 @@ func LockState(ctx *context.Context) { apiError(ctx, http.StatusBadRequest, err) return } - packageName := ctx.PathParam("name") - lockKey := fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, packageName) - + lockKey := getLockKey(ctx) release, err := globallock.Lock(ctx, lockKey) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -338,10 +343,8 @@ func UnlockState(ctx *context.Context) { apiError(ctx, http.StatusBadRequest, err) return } - packageName := ctx.PathParam("name") - lockKey := fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, packageName) - + lockKey := getLockKey(ctx) release, err := globallock.Lock(ctx, lockKey) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -402,3 +405,7 @@ func getLatestVersion(ctx *context.Context, packageName string) (*packages_model } return pvs[0], nil } + +func getLockKey(ctx *context.Context) string { + return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, ctx.PathParam("name")) +} From e93b1de1bf1812c62cf7a43751e6b4413e17178a Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 10:04:50 +0100 Subject: [PATCH 32/61] fix error comparison --- routers/api/packages/terraform/terraform.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 9def949d2883a..d7f7f40d735ca 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -199,7 +199,7 @@ func UploadState(ctx *context.Context) { func DeleteStateBySerial(ctx *context.Context) { serial := ctx.PathParam("serial") pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, ctx.PathParam("name"), serial) - if errors.Is(err, packages_model.ErrPackageFileNotExist) { + if errors.Is(err, packages_model.ErrPackageNotExist) { apiError(ctx, http.StatusNotFound, err) return } else if err != nil { From c8318f50132668cb0d264f0940816599487fec74 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 10:33:55 +0100 Subject: [PATCH 33/61] update svg --- public/assets/img/svg/gitea-terraform.svg | 2 +- web_src/svg/gitea-terraform.svg | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg index ebc095634af1c..809b7e6fe1764 100644 --- a/public/assets/img/svg/gitea-terraform.svg +++ b/public/assets/img/svg/gitea-terraform.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/web_src/svg/gitea-terraform.svg b/web_src/svg/gitea-terraform.svg index 24d340f0f8c87..384ce5a921edf 100644 --- a/web_src/svg/gitea-terraform.svg +++ b/web_src/svg/gitea-terraform.svg @@ -1 +1,2 @@ - \ No newline at end of file + + From f80116f022e3882aa05819d291a146cb248ae638 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 10:34:53 +0100 Subject: [PATCH 34/61] group routes and enforce read write on locks User with read only permissions should not be able to lock or unlock the state. --- routers/web/web.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 4b965cf7fd919..06504be5ebf30 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1054,8 +1054,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/{version}", func() { m.Get("", user.ViewPackageVersion) m.Get("/{version_sub}", user.ViewPackageVersion) - m.Post("/terraform/lock", user.ActionPackageTerraformLock) - m.Post("/terraform/unlock", user.ActionPackageTerraformUnlock) + m.Group("/terraform", func() { + m.Post("/terraform/lock", user.ActionPackageTerraformLock) + m.Post("/terraform/unlock", user.ActionPackageTerraformUnlock) + }, reqPackageAccess(perm.AccessModeWrite)) m.Get("/files/{fileid}", user.DownloadPackageFile) m.Group("/settings", func() { m.Get("", user.PackageSettings) From fb3f13fffebce1f573272be3f13b23c0b98b8b79 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 15 Mar 2026 10:53:06 +0100 Subject: [PATCH 35/61] fix route --- routers/web/web.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 06504be5ebf30..90de54c268bec 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1055,8 +1055,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("", user.ViewPackageVersion) m.Get("/{version_sub}", user.ViewPackageVersion) m.Group("/terraform", func() { - m.Post("/terraform/lock", user.ActionPackageTerraformLock) - m.Post("/terraform/unlock", user.ActionPackageTerraformUnlock) + m.Post("/lock", user.ActionPackageTerraformLock) + m.Post("/unlock", user.ActionPackageTerraformUnlock) }, reqPackageAccess(perm.AccessModeWrite)) m.Get("/files/{fileid}", user.DownloadPackageFile) m.Group("/settings", func() { From b4e98ea95d87e117533078a137e817c7b14e042e Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Thu, 19 Mar 2026 00:39:31 +0100 Subject: [PATCH 36/61] remove if in template Initially introduced in the base PR which had no version, now that we have state version this can be dropped --- templates/package/shared/list.tmpl | 4 ---- 1 file changed, 4 deletions(-) diff --git a/templates/package/shared/list.tmpl b/templates/package/shared/list.tmpl index abd52da931bc1..e621c04b438bd 100644 --- a/templates/package/shared/list.tmpl +++ b/templates/package/shared/list.tmpl @@ -20,11 +20,7 @@
- {{if eq .Package.Type "terraform"}} - {{.Package.Name}} - {{else}} {{.Package.Name}} - {{end}} {{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}
From 3ab2acf10278c99075e6cd6ffecc8b22ec7f2f40 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Mar 2026 21:56:58 +0100 Subject: [PATCH 37/61] parse the state and lock file gets rid of panic possibility by not allowing nils and wraps additional validation on the state so an invalid one won't be accepted adds name validation on the locks --- .../packages/terraform/lock.go | 17 +++++ modules/packages/terraform/state.go | 60 ++++++++++++++++++ routers/api/packages/terraform/terraform.go | 63 ++++++++++--------- routers/web/user/package.go | 18 +++--- .../api_packages_terraform_test.go | 35 ++++++----- 5 files changed, 143 insertions(+), 50 deletions(-) rename {models => modules}/packages/terraform/lock.go (80%) create mode 100644 modules/packages/terraform/state.go diff --git a/models/packages/terraform/lock.go b/modules/packages/terraform/lock.go similarity index 80% rename from models/packages/terraform/lock.go rename to modules/packages/terraform/lock.go index 1d4e9a4734bf7..9f259ca985230 100644 --- a/models/packages/terraform/lock.go +++ b/modules/packages/terraform/lock.go @@ -5,14 +5,18 @@ package terraform import ( "context" + "io" "time" packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" ) const LockFile = "terraform.lock" +var ErrMissingLockID = util.NewInvalidArgumentErrorf("terraform lock is missing an ID") + // LockInfo is the metadata for a terraform lock. type LockInfo struct { ID string `json:"ID"` @@ -28,6 +32,19 @@ func (l *LockInfo) IsLocked() bool { return l.ID != "" } +func ParseLockInfo(r io.Reader) (*LockInfo, error) { + var lock LockInfo + err := json.NewDecoder(r).Decode(&lock) + if err != nil { + return nil, err + } + // ID is required. Rest is less important. + if lock.ID == "" { + return nil, ErrMissingLockID + } + return &lock, nil +} + // GetLock returns the terraform lock for the given package. // Lock is empty if no lock exists. func GetLock(ctx context.Context, packageID int64) (LockInfo, error) { diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go new file mode 100644 index 0000000000000..eccbace2dc05d --- /dev/null +++ b/modules/packages/terraform/state.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terraform + +import ( + "io" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" + gojson "encoding/json" //nolint:depguard +) + +var ( + ErrNoSerial = util.NewInvalidArgumentErrorf("state serial is missing") + ErrNoLineage = util.NewInvalidArgumentErrorf("state lineage is missing") + ErrNoTerraformVersion = util.NewInvalidArgumentErrorf("state terraform version is missing") + ErrNoResources = util.NewInvalidArgumentErrorf("state resources are missing") + ErrNoOutputs = util.NewInvalidArgumentErrorf("state outputs are missing") +) + +type State struct { + Version int `json:"version"` + TerraformVersion string `json:"terraform_version"` + Serial uint64 `json:"serial"` + Lineage string `json:"lineage"` + Resources gojson.RawMessage `json:"resources"` + Outputs gojson.RawMessage `json:"outputs"` +} + +// ParseState parses the Terraform state file +func ParseState(r io.Reader) (*State, error) { + var state State + err := json.NewDecoder(r).Decode(&state) + if err != nil { + return nil, err + } + // Serial starts at 1; 0 means it wasn't set in the state file + if state.Serial == 0 { + return nil, ErrNoSerial + } + if state.Version != 4 { + return nil, util.NewInvalidArgumentErrorf("state version %d is not supported", state.Version) + } + if state.TerraformVersion == "" { + return nil, ErrNoTerraformVersion + } + // Lineage should always be set + if state.Lineage == "" { + return nil, ErrNoLineage + } + if state.Resources == nil { + return nil, ErrNoResources + } + if state.Outputs == nil { + return nil, ErrNoOutputs + } + + return &state, nil +} diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index d7f7f40d735ca..2eb888df12445 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -14,12 +14,11 @@ import ( "unicode" packages_model "code.gitea.io/gitea/models/packages" - terraform_model "code.gitea.io/gitea/models/packages/terraform" "code.gitea.io/gitea/modules/globallock" - "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" + terraform_module "code.gitea.io/gitea/modules/packages/terraform" "code.gitea.io/gitea/routers/api/packages/helper" "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" @@ -89,14 +88,6 @@ func isValidPackageName(packageName string) bool { return packageNameRegex.MatchString(packageName) && packageName != ".." } -type TFState struct { - Version int `json:"version"` - TerraformVersion string `json:"terraform_version"` - Serial uint64 `json:"serial"` - Lineage string `json:"lineage"` - // modules are omitted -} - // UploadState uploads the specific terraform package. func UploadState(ctx *context.Context) { packageName := ctx.PathParam("name") @@ -120,12 +111,14 @@ func UploadState(ctx *context.Context) { } if p != nil { // Check lock - lock, err := terraform_model.GetLock(ctx, p.ID) + lock, err := terraform_module.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if lock.ID != ctx.FormString("ID") { + + // If the state is locked, enforce the lock + if lock.IsLocked() && lock.ID != ctx.FormString("ID") { ctx.JSON(http.StatusLocked, lock) return } @@ -148,10 +141,9 @@ func UploadState(ctx *context.Context) { } defer buf.Close() - var state *TFState - err = json.NewDecoder(buf).Decode(&state) + state, err := terraform_module.ParseState(buf) if err != nil { - log.Error("Error decoding json: %v", err) + log.Error("Error decoding state: %v", err) apiError(ctx, http.StatusBadRequest, err) return } @@ -243,7 +235,7 @@ func DeleteState(ctx *context.Context) { return } - lock, err := terraform_model.GetLock(ctx, p.ID) + lock, err := terraform_module.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -279,14 +271,21 @@ func DeleteState(ctx *context.Context) { // LockState locks the specific terraform state. // Internally, it adds a property to the package with the lock information -// Cavieat being that it allocates a package one doesn't exist to attach the property +// Caveat being that it allocates a package if one doesn't exist to attach the property func LockState(ctx *context.Context) { - var reqLockInfo *terraform_model.LockInfo - if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { + packageName := ctx.PathParam("name") + if !isValidPackageName(packageName) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) + return + } + + var reqLockInfo *terraform_module.LockInfo + reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body) + if err != nil { apiError(ctx, http.StatusBadRequest, err) return } - packageName := ctx.PathParam("name") + lockKey := getLockKey(ctx) release, err := globallock.Lock(ctx, lockKey) if err != nil { @@ -315,7 +314,7 @@ func LockState(ctx *context.Context) { } } - currentLock, err := terraform_model.GetLock(ctx, p.ID) + currentLock, err := terraform_module.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -326,7 +325,7 @@ func LockState(ctx *context.Context) { return } - err = terraform_model.SetLock(ctx, p.ID, reqLockInfo) + err = terraform_module.SetLock(ctx, p.ID, reqLockInfo) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -338,12 +337,18 @@ func LockState(ctx *context.Context) { // UnlockState unlock the specific terraform state. // Internally, it clears the package property func UnlockState(ctx *context.Context) { - var reqLockInfo terraform_model.LockInfo - if err := json.NewDecoder(ctx.Req.Body).Decode(&reqLockInfo); err != nil { + packageName := ctx.PathParam("name") + if !isValidPackageName(packageName) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) + return + } + + reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body) + if err != nil { apiError(ctx, http.StatusBadRequest, err) return } - packageName := ctx.PathParam("name") + lockKey := getLockKey(ctx) release, err := globallock.Lock(ctx, lockKey) if err != nil { @@ -362,14 +367,14 @@ func UnlockState(ctx *context.Context) { return } - existingLock, err := terraform_model.GetLock(ctx, p.ID) + existingLock, err := terraform_module.GetLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } // we can bypass messing with the lock since it's empty - if existingLock.ID == "" { + if !existingLock.IsLocked() { ctx.Status(http.StatusOK) return } @@ -380,7 +385,7 @@ func UnlockState(ctx *context.Context) { return } // We can clear the state if lock id matches - err = terraform_model.RemoveLock(ctx, p.ID) + err = terraform_module.RemoveLock(ctx, p.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -407,5 +412,5 @@ func getLatestVersion(ctx *context.Context, packageName string) (*packages_model } func getLockKey(ctx *context.Context) string { - return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, ctx.PathParam("name")) + return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, strings.ToLower(ctx.PathParam("name"))) } diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 34478eefc72f2..526a4338be849 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -14,7 +14,6 @@ import ( org_model "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" container_model "code.gitea.io/gitea/models/packages/container" - terraform_model "code.gitea.io/gitea/models/packages/terraform" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" @@ -27,6 +26,7 @@ import ( container_module "code.gitea.io/gitea/modules/packages/container" debian_module "code.gitea.io/gitea/modules/packages/debian" rpm_module "code.gitea.io/gitea/modules/packages/rpm" + terraform_module "code.gitea.io/gitea/modules/packages/terraform" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" @@ -333,7 +333,11 @@ func ViewPackageVersion(ctx *context.Context) { ctx.Data["IsLatestVersion"] = isLatest if isLatest { - lockInfo, _ := terraform_model.GetLock(ctx, pd.Package.ID) + lockInfo, err := terraform_module.GetLock(ctx, pd.Package.ID) + if err != nil { + ctx.ServerError("GetLock", err) + return + } if lockInfo.IsLocked() { ctx.Data["TerraformLock"] = lockInfo } @@ -518,7 +522,7 @@ func packageSettingsPostActionDelete(ctx *context.Context) { pd := ctx.Package.Descriptor if pd.Package.Type == packages_model.TypeTerraformState { - lock, err := terraform_model.GetLock(ctx, pd.Package.ID) + lock, err := terraform_module.GetLock(ctx, pd.Package.ID) if err != nil { ctx.ServerError("getTerraformLock", err) return @@ -590,7 +594,7 @@ func ActionPackageTerraformLock(ctx *context.Context) { return } - existingLock, err := terraform_model.GetLock(ctx, pd.Package.ID) + existingLock, err := terraform_module.GetLock(ctx, pd.Package.ID) if err != nil { ctx.ServerError("GetLock", err) return @@ -602,14 +606,14 @@ func ActionPackageTerraformLock(ctx *context.Context) { } lockID := uuid.New().String() - lockInfo := &terraform_model.LockInfo{ + lockInfo := &terraform_module.LockInfo{ ID: lockID, Operation: "Manual UI Lock", Who: ctx.Doer.Name, Created: time.Now(), } - if err := terraform_model.SetLock(ctx, pd.Package.ID, lockInfo); err != nil { + if err := terraform_module.SetLock(ctx, pd.Package.ID, lockInfo); err != nil { ctx.ServerError("SetLock", err) return } @@ -626,7 +630,7 @@ func ActionPackageTerraformUnlock(ctx *context.Context) { return } - if err := terraform_model.RemoveLock(ctx, pd.Package.ID); err != nil { + if err := terraform_module.RemoveLock(ctx, pd.Package.ID); err != nil { ctx.ServerError("RemoveLock", err) return } diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 51b7ee93c5e4f..33a6c3780266c 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -132,35 +132,35 @@ func TestPackageTerraform(t *testing.T) { t.Run("StateHistory", func(t *testing.T) { // Upload 3 versions for i := range 3 { - state := genState(i) + state := genState(i + 1) // 1-based req := NewRequestWithBody(t, "POST", url, strings.NewReader(state)).AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusCreated) } - // Verify latest is 2 + // Verify latest is 3 req := NewRequest(t, "GET", url).AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, genState(2), resp.Body.String()) + assert.Equal(t, genState(3), resp.Body.String()) - // Verify version 1 is accessible - req = NewRequest(t, "GET", url+"/versions/1").AddBasicAuth(user.Name) + // Verify version 2 is accessible + req = NewRequest(t, "GET", url+"/versions/2").AddBasicAuth(user.Name) resp = MakeRequest(t, req, http.StatusOK) - assert.Equal(t, genState(1), resp.Body.String()) + assert.Equal(t, genState(2), resp.Body.String()) - // Delete version 1 - req = NewRequest(t, "DELETE", url+"/versions/1").AddBasicAuth(user.Name) + // Delete version 2 + req = NewRequest(t, "DELETE", url+"/versions/2").AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusNoContent) - // Verify version 1 is gone from DB - _, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "1") + // Verify version 2 is gone from DB + _, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "2") assert.ErrorIs(t, err, packages.ErrPackageNotExist) - // Verify version 1 is gone from API - req = NewRequest(t, "GET", url+"/versions/1").AddBasicAuth(user.Name) + // Verify version 2 is gone from API + req = NewRequest(t, "GET", url+"/versions/2").AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusNotFound) - // Deleting latest version (2) should be forbidden - req = NewRequest(t, "DELETE", url+"/versions/2").AddBasicAuth(user.Name) + // Deleting latest version (3) should be forbidden + req = NewRequest(t, "DELETE", url+"/versions/3").AddBasicAuth(user.Name) resp = MakeRequest(t, req, http.StatusForbidden) assert.Contains(t, resp.Body.String(), "cannot delete the latest version") @@ -262,5 +262,12 @@ func TestPackageTerraform(t *testing.T) { req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusOK) }) + t.Run("PutEmpty", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // safeguard against null payload + req := NewRequestWithBody(t, "POST", url, strings.NewReader("null")).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + }) }) } From 9d3f2e50b3835723d077d0f5d82c7a40c61f6741 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Mar 2026 22:26:23 +0100 Subject: [PATCH 38/61] address remaining points of review --- routers/api/packages/terraform/terraform.go | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/routers/api/packages/terraform/terraform.go b/routers/api/packages/terraform/terraform.go index 2eb888df12445..8b731b7dd2674 100644 --- a/routers/api/packages/terraform/terraform.go +++ b/routers/api/packages/terraform/terraform.go @@ -189,6 +189,14 @@ func UploadState(ctx *context.Context) { // DeleteStateBySerial deletes the specific serial of a terraform package as long as it's not the latest one. func DeleteStateBySerial(ctx *context.Context) { + lockKey := getLockKey(ctx) + release, err := globallock.Lock(ctx, lockKey) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer release() + serial := ctx.PathParam("serial") pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, ctx.PathParam("name"), serial) if errors.Is(err, packages_model.ErrPackageNotExist) { @@ -225,6 +233,14 @@ func DeleteStateBySerial(ctx *context.Context) { func DeleteState(ctx *context.Context) { packageName := ctx.PathParam("name") + lockKey := getLockKey(ctx) + release, err := globallock.Lock(ctx, lockKey) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer release() + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName) if err != nil { if errors.Is(err, packages_model.ErrPackageNotExist) { @@ -254,6 +270,12 @@ func DeleteState(ctx *context.Context) { return } + err = packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypePackage, p.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + for _, pv := range pvs { if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { apiError(ctx, http.StatusInternalServerError, err) From 00c27205e67ee58ccc7a66b21cbd74478f71973c Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Mar 2026 23:19:43 +0100 Subject: [PATCH 39/61] refactor set and remove state to ignore nonempty/empty state --- modules/packages/terraform/lock.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/modules/packages/terraform/lock.go b/modules/packages/terraform/lock.go index 9f259ca985230..55cca9c07252b 100644 --- a/modules/packages/terraform/lock.go +++ b/modules/packages/terraform/lock.go @@ -5,12 +5,15 @@ package terraform import ( "context" + "errors" "io" "time" + "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/util" + "xorm.io/builder" ) const LockFile = "terraform.lock" @@ -68,10 +71,31 @@ func SetLock(ctx context.Context, packageID int64, lock *LockInfo) error { return err } - return packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, packageID, LockFile, string(jsonBytes)) + return updateLock(ctx, packageID, string(jsonBytes), builder.Eq{"value": ""}) } // RemoveLock removes the terraform lock for the given package. func RemoveLock(ctx context.Context, packageID int64) error { - return packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypePackage, packageID, LockFile, "") + return updateLock(ctx, packageID, "", builder.Neq{"value": ""}) +} + +func updateLock(ctx context.Context, refID int64, value string, cond builder.Cond) error { + pp := packages_model.PackageProperty{RefType: packages_model.PropertyTypePackage, RefID: refID, Name: LockFile} + ok, err := db.GetEngine(ctx).Get(&pp) + if err != nil { + return err + } + if ok { + n, err := db.GetEngine(ctx).Where("ref_type=? AND ref_id=? AND name=?", packages_model.PropertyTypePackage, refID, LockFile).And(cond).Cols("value").Update(&packages_model.PackageProperty{Value: value}) + if err != nil { + return err + } + if n == 0 { + return errors.New("failed to update lock state") + } + + return nil + } + _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, refID, LockFile, value) + return err } From e5b5a4e6bf2e00df827f9200b9d57e10cdc44fdb Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 22 Mar 2026 23:23:37 +0100 Subject: [PATCH 40/61] fix formatting --- modules/packages/terraform/lock.go | 1 + modules/packages/terraform/state.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/packages/terraform/lock.go b/modules/packages/terraform/lock.go index 55cca9c07252b..c1bd5a92a3c11 100644 --- a/modules/packages/terraform/lock.go +++ b/modules/packages/terraform/lock.go @@ -13,6 +13,7 @@ import ( packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/util" + "xorm.io/builder" ) diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go index eccbace2dc05d..feba496765ac9 100644 --- a/modules/packages/terraform/state.go +++ b/modules/packages/terraform/state.go @@ -4,11 +4,11 @@ package terraform import ( + gojson "encoding/json" //nolint:depguard // go package provides RawMessage which is useful here "io" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/util" - gojson "encoding/json" //nolint:depguard ) var ( From a1c9ddc21ea32883c35c1b35cd741a0003e39f02 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 24 Mar 2026 17:53:13 +0100 Subject: [PATCH 41/61] Update options/locale/locale_en-US.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: techknowlogick --- options/locale/locale_en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index ce619d5fc051f..799d24a4436f2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3563,7 +3563,7 @@ "packages.swift.registry": "Set up this registry from the command line:", "packages.swift.install": "Add the package in your Package.swift file:", "packages.swift.install2": "and run the following command:", - "packages.terraform.install": "Set your state to use the http backend", + "packages.terraform.install": "Set your state to use the HTTP backend", "packages.terraform.install2": "and run the following command:", "packages.terraform.lock_status": "Lock Status", "packages.terraform.locked_by": "Locked by %s", From e9f0c351b7cc52cce75eb6cdfaf691027a173165 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 5 Apr 2026 22:10:54 +0200 Subject: [PATCH 42/61] forbid from removing the state from UI when its locked --- routers/web/user/package.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/routers/web/user/package.go b/routers/web/user/package.go index eaf33095d4c61..8d95718c64d1c 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -527,6 +527,18 @@ func packageSettingsPostActionDelete(ctx *context.Context) { return } + if pd.Package.Type == packages_model.TypeTerraformState { + lock, err := terraform_module.GetLock(ctx, pd.Package.ID) + if err != nil { + ctx.ServerError("getTerraformLock", err) + } + if lock.IsLocked() { + ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked")) + ctx.Redirect(pd.PackageSettingsLink()) + return + } + } + if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) From b84389a832ea2cd2b4ca8aaef0426e06cccd8b90 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 14:12:34 +0200 Subject: [PATCH 43/61] drop origin-url --- templates/package/content/terraform.tmpl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl index 2678bb9618483..6006bee9aa76b 100644 --- a/templates/package/content/terraform.tmpl +++ b/templates/package/content/terraform.tmpl @@ -6,9 +6,9 @@
terraform {
 	backend "http" {
-		address = ""
-		lock_address = ""
-		unlock_address = ""
+		address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}""
+		lock_address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}/lock"
+		unlock_address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}/lock"
 		lock_method = "POST"
 		unlock_method = "DELETE"
 	}

From 5972ab596dd2805f7d84e62161f9572079ad5e0f Mon Sep 17 00:00:00 2001
From: TheFox0x7 
Date: Mon, 6 Apr 2026 14:13:07 +0200
Subject: [PATCH 44/61] add service helpers for state

---
 routers/web/user/package.go          | 75 +++++++++++++++-------------
 services/packages/terraform/state.go | 41 +++++++++++++++
 2 files changed, 81 insertions(+), 35 deletions(-)
 create mode 100644 services/packages/terraform/state.go

diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index 8d95718c64d1c..cf82d46303da7 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -37,6 +37,7 @@ import (
 	"code.gitea.io/gitea/services/forms"
 	packages_service "code.gitea.io/gitea/services/packages"
 	container_service "code.gitea.io/gitea/services/packages/container"
+	terraform_service "code.gitea.io/gitea/services/packages/terraform"
 
 	"github.com/google/uuid"
 )
@@ -527,16 +528,15 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
 		return
 	}
 
-	if pd.Package.Type == packages_model.TypeTerraformState {
-		lock, err := terraform_module.GetLock(ctx, pd.Package.ID)
-		if err != nil {
-			ctx.ServerError("getTerraformLock", err)
-		}
-		if lock.IsLocked() {
-			ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked"))
-			ctx.Redirect(pd.PackageSettingsLink())
-			return
-		}
+	locked, err := terraform_service.IsLocked(ctx, pd.Package)
+	if err != nil {
+		ctx.ServerError("IsLocked", err)
+		return
+	}
+	if locked {
+		ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked"))
+		ctx.Redirect(pd.PackageSettingsLink())
+		return
 	}
 
 	if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
@@ -549,6 +549,34 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
 	ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
 }
 
+// canDeleteTerraformStateVersion is a wrapper for terraform state checks to run before deleting a version
+// Return true if it's okay to delete and fills appropriate errors for UI if not.
+func canDeleteTerraformStateVersion(ctx *context.Context) bool {
+	pd := ctx.Package.Descriptor
+	locked, err := terraform_service.IsLocked(ctx, pd.Package)
+	if err != nil {
+		ctx.ServerError("IsLocked", err)
+		return false
+	}
+	if locked {
+		ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked"))
+		ctx.Redirect(pd.VersionWebLink())
+		return false
+	}
+
+	latest, err := terraform_service.IsLatest(ctx, pd)
+	if err != nil {
+		ctx.ServerError("IsLatest", err)
+		return false
+	}
+	if latest {
+		ctx.Flash.Error(ctx.Tr("packages.terraform.delete.latest"))
+		ctx.Redirect(pd.VersionWebLink())
+		return false
+	}
+	return true
+}
+
 // PackageVersionDelete deletes a package version
 func PackageVersionDelete(ctx *context.Context) {
 	pd := ctx.Package.Descriptor
@@ -557,31 +585,8 @@ func PackageVersionDelete(ctx *context.Context) {
 		return
 	}
 
-	if pd.Package.Type == packages_model.TypeTerraformState {
-		lock, err := terraform_module.GetLock(ctx, pd.Package.ID)
-		if err != nil {
-			ctx.ServerError("getTerraformLock", err)
-			return
-		}
-		if lock.IsLocked() {
-			ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked"))
-			ctx.Redirect(pd.VersionWebLink())
-			return
-		}
-
-		latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
-			PackageID:  pd.Package.ID,
-			IsInternal: optional.Some(false),
-		})
-		if err != nil {
-			ctx.ServerError("SearchLatestVersions", err)
-			return
-		}
-		if len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID {
-			ctx.Flash.Error(ctx.Tr("packages.terraform.delete.latest"))
-			ctx.Redirect(pd.VersionWebLink())
-			return
-		}
+	if canDeleteTerraformStateVersion(ctx) {
+		return
 	}
 
 	if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
diff --git a/services/packages/terraform/state.go b/services/packages/terraform/state.go
new file mode 100644
index 0000000000000..e97015c352e37
--- /dev/null
+++ b/services/packages/terraform/state.go
@@ -0,0 +1,41 @@
+package terraform
+
+import (
+	"context"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	"code.gitea.io/gitea/modules/optional"
+	terraform_module "code.gitea.io/gitea/modules/packages/terraform"
+)
+
+// IsLocked is a helper function to check if the terraform state is locked
+func IsLocked(ctx context.Context, pkg *packages_model.Package) (bool, error) {
+	// Non terraform state packages aren't handled here
+	if pkg.Type == packages_model.TypeTerraformState {
+		return false, nil
+	}
+
+	lock, err := terraform_module.GetLock(ctx, pkg.ID)
+	if err != nil {
+		return false, err
+	}
+	return lock.IsLocked(), nil
+}
+
+// IsLatest is a helper function to check if the terraform state is the latest version
+func IsLatest(ctx context.Context, pd *packages_model.PackageDescriptor) (bool, error) {
+	if pd.Package.Type == packages_model.TypeTerraformState {
+		return false, nil
+	}
+	latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+		PackageID:  pd.Package.ID,
+		IsInternal: optional.Some(false),
+	})
+	if err != nil {
+		return false, err
+	}
+	if len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID {
+		return true, nil
+	}
+	return false, nil
+}

From 0d6c9a67bc0943a98b0469c7f21f5078e9fb1abf Mon Sep 17 00:00:00 2001
From: TheFox0x7 
Date: Mon, 6 Apr 2026 14:27:34 +0200
Subject: [PATCH 45/61] add Value to gitea's json module

mapped to RawMessage for legacy but there should be no difference
https://cs.opensource.google/go/go/+/refs/tags/go1.26.1:src/encoding/json/jsontext/value.go
---
 modules/json/jsonlegacy.go          |  2 ++
 modules/json/jsonv2.go              |  5 ++++-
 modules/packages/terraform/state.go | 13 ++++++-------
 3 files changed, 12 insertions(+), 8 deletions(-)

diff --git a/modules/json/jsonlegacy.go b/modules/json/jsonlegacy.go
index 156e4560418c2..1317497ff3bb8 100644
--- a/modules/json/jsonlegacy.go
+++ b/modules/json/jsonlegacy.go
@@ -20,3 +20,5 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
 func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
 	return DefaultJSONHandler.NewDecoder(reader)
 }
+
+type Value = json.RawMessage
diff --git a/modules/json/jsonv2.go b/modules/json/jsonv2.go
index 0bba2783bcb41..322b02a1beb72 100644
--- a/modules/json/jsonv2.go
+++ b/modules/json/jsonv2.go
@@ -7,7 +7,8 @@ package json
 
 import (
 	"bytes"
-	jsonv1 "encoding/json"    //nolint:depguard // this package wraps it
+	jsonv1 "encoding/json" //nolint:depguard // this package wraps it
+	"encoding/json/jsontext"
 	jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it
 	"io"
 )
@@ -90,3 +91,5 @@ func (d *jsonV2Decoder) Decode(v any) error {
 func NewDecoderCaseInsensitive(reader io.Reader) Decoder {
 	return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions}
 }
+
+type Value = jsontext.Value
diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go
index feba496765ac9..70298296f080d 100644
--- a/modules/packages/terraform/state.go
+++ b/modules/packages/terraform/state.go
@@ -4,7 +4,6 @@
 package terraform
 
 import (
-	gojson "encoding/json" //nolint:depguard // go package provides RawMessage which is useful here
 	"io"
 
 	"code.gitea.io/gitea/modules/json"
@@ -20,12 +19,12 @@ var (
 )
 
 type State struct {
-	Version          int               `json:"version"`
-	TerraformVersion string            `json:"terraform_version"`
-	Serial           uint64            `json:"serial"`
-	Lineage          string            `json:"lineage"`
-	Resources        gojson.RawMessage `json:"resources"`
-	Outputs          gojson.RawMessage `json:"outputs"`
+	Version          int    `json:"version"`
+	TerraformVersion string `json:"terraform_version"`
+	Serial           uint64 `json:"serial"`
+	Lineage          string `json:"lineage"`
+	Resources        json.Value    `json:"resources"`
+	Outputs          json.Value    `json:"outputs"`
 }
 
 // ParseState parses the Terraform state file

From 4e8a48fb0a0de9a0fa011f266dd5d2bdbdcf743a Mon Sep 17 00:00:00 2001
From: TheFox0x7 
Date: Mon, 6 Apr 2026 14:28:16 +0200
Subject: [PATCH 46/61] formatting

---
 modules/packages/terraform/state.go | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go
index 70298296f080d..b1e19024ac18a 100644
--- a/modules/packages/terraform/state.go
+++ b/modules/packages/terraform/state.go
@@ -19,12 +19,12 @@ var (
 )
 
 type State struct {
-	Version          int    `json:"version"`
-	TerraformVersion string `json:"terraform_version"`
-	Serial           uint64 `json:"serial"`
-	Lineage          string `json:"lineage"`
-	Resources        json.Value    `json:"resources"`
-	Outputs          json.Value    `json:"outputs"`
+	Version          int        `json:"version"`
+	TerraformVersion string     `json:"terraform_version"`
+	Serial           uint64     `json:"serial"`
+	Lineage          string     `json:"lineage"`
+	Resources        json.Value `json:"resources"`
+	Outputs          json.Value `json:"outputs"`
 }
 
 // ParseState parses the Terraform state file

From 0e7394f43b6a620f6f1e54807000e974e50d21fa Mon Sep 17 00:00:00 2001
From: TheFox0x7 
Date: Mon, 6 Apr 2026 14:32:18 +0200
Subject: [PATCH 47/61] missed import

---
 modules/json/jsonlegacy.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/modules/json/jsonlegacy.go b/modules/json/jsonlegacy.go
index 1317497ff3bb8..83eabad4526f1 100644
--- a/modules/json/jsonlegacy.go
+++ b/modules/json/jsonlegacy.go
@@ -6,6 +6,7 @@
 package json
 
 import (
+	"encoding/json"
 	"io"
 )
 

From 141229001e770062d3b5e88efa3c3372edeadae9 Mon Sep 17 00:00:00 2001
From: wxiaoguang 
Date: Mon, 6 Apr 2026 21:16:52 +0800
Subject: [PATCH 48/61] json value

---
 modules/json/jsonv2.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/modules/json/jsonv2.go b/modules/json/jsonv2.go
index 322b02a1beb72..c4afc9513b7c4 100644
--- a/modules/json/jsonv2.go
+++ b/modules/json/jsonv2.go
@@ -7,8 +7,8 @@ package json
 
 import (
 	"bytes"
-	jsonv1 "encoding/json" //nolint:depguard // this package wraps it
-	"encoding/json/jsontext"
+	jsonv1 "encoding/json"    //nolint:depguard // this package wraps it
+	"encoding/json/jsontext"  //nolint:depguard // this package wraps it
 	jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it
 	"io"
 )

From 907dc68588a554624adac6e08ba333435ccf4b3a Mon Sep 17 00:00:00 2001
From: wxiaoguang 
Date: Mon, 6 Apr 2026 21:24:49 +0800
Subject: [PATCH 49/61] fix lint

---
 services/packages/terraform/state.go | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/services/packages/terraform/state.go b/services/packages/terraform/state.go
index e97015c352e37..cdd6f0c593503 100644
--- a/services/packages/terraform/state.go
+++ b/services/packages/terraform/state.go
@@ -1,3 +1,6 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
 package terraform
 
 import (

From 2bbc654866fb80e4f8b177e7062e9f76fa462dd4 Mon Sep 17 00:00:00 2001
From: wxiaoguang 
Date: Mon, 6 Apr 2026 22:02:52 +0800
Subject: [PATCH 50/61] refactor

---
 routers/init.go                      |  3 +-
 routers/web/user/package.go          | 69 ++++++----------------------
 services/packages/packages.go        | 12 +++++
 services/packages/pkgspec/manager.go | 15 ++++++
 services/packages/spec.go            | 47 +++++++++++++++++++
 services/packages/terraform/spec.go  | 56 ++++++++++++++++++++++
 6 files changed, 147 insertions(+), 55 deletions(-)
 create mode 100644 services/packages/pkgspec/manager.go
 create mode 100644 services/packages/spec.go
 create mode 100644 services/packages/terraform/spec.go

diff --git a/routers/init.go b/routers/init.go
index 2ed7a57e5c8fb..faa824ded5b86 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -47,6 +47,7 @@ import (
 	repo_migrations "code.gitea.io/gitea/services/migrations"
 	mirror_service "code.gitea.io/gitea/services/mirror"
 	"code.gitea.io/gitea/services/oauth2_provider"
+	packages_spec "code.gitea.io/gitea/services/packages/pkgspec"
 	pull_service "code.gitea.io/gitea/services/pull"
 	release_service "code.gitea.io/gitea/services/release"
 	repo_service "code.gitea.io/gitea/services/repository"
@@ -145,10 +146,10 @@ func InitWebInstalled(ctx context.Context) {
 	mustInitCtx(ctx, oauth2.Init)
 	mustInitCtx(ctx, oauth2_provider.Init)
 	mustInit(release_service.Init)
-
 	mustInitCtx(ctx, models.Init)
 	mustInitCtx(ctx, authmodel.Init)
 	mustInitCtx(ctx, repo_service.Init)
+	mustInit(packages_spec.InitManager)
 
 	// Booting long running goroutines.
 	mustInit(indexer_service.Init)
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index cf82d46303da7..2d4ae8af73af6 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -19,7 +19,6 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/httplib"
-	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 	alpine_module "code.gitea.io/gitea/modules/packages/alpine"
 	arch_module "code.gitea.io/gitea/modules/packages/arch"
@@ -37,7 +36,6 @@ import (
 	"code.gitea.io/gitea/services/forms"
 	packages_service "code.gitea.io/gitea/services/packages"
 	container_service "code.gitea.io/gitea/services/packages/container"
-	terraform_service "code.gitea.io/gitea/services/packages/terraform"
 
 	"github.com/google/uuid"
 )
@@ -527,56 +525,20 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
 		ctx.Redirect(pd.PackageSettingsLink())
 		return
 	}
-
-	locked, err := terraform_service.IsLocked(ctx, pd.Package)
-	if err != nil {
-		ctx.ServerError("IsLocked", err)
-		return
-	}
-	if locked {
-		ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked"))
-		ctx.Redirect(pd.PackageSettingsLink())
-		return
-	}
-
 	if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
-		log.Error("Error deleting package: %v", err)
-		ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
-	} else {
-		ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
+		errTr := util.ErrorAsTranslatable(err)
+		if errTr == nil {
+			ctx.ServerError("RemovePackage", err)
+			return
+		}
+		ctx.Flash.Error(errTr.Translate(ctx.Locale))
+		ctx.Redirect(pd.PackageSettingsLink())
 	}
 
+	ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
 	ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
 }
 
-// canDeleteTerraformStateVersion is a wrapper for terraform state checks to run before deleting a version
-// Return true if it's okay to delete and fills appropriate errors for UI if not.
-func canDeleteTerraformStateVersion(ctx *context.Context) bool {
-	pd := ctx.Package.Descriptor
-	locked, err := terraform_service.IsLocked(ctx, pd.Package)
-	if err != nil {
-		ctx.ServerError("IsLocked", err)
-		return false
-	}
-	if locked {
-		ctx.Flash.Error(ctx.Tr("packages.terraform.delete.locked"))
-		ctx.Redirect(pd.VersionWebLink())
-		return false
-	}
-
-	latest, err := terraform_service.IsLatest(ctx, pd)
-	if err != nil {
-		ctx.ServerError("IsLatest", err)
-		return false
-	}
-	if latest {
-		ctx.Flash.Error(ctx.Tr("packages.terraform.delete.latest"))
-		ctx.Redirect(pd.VersionWebLink())
-		return false
-	}
-	return true
-}
-
 // PackageVersionDelete deletes a package version
 func PackageVersionDelete(ctx *context.Context) {
 	pd := ctx.Package.Descriptor
@@ -585,23 +547,22 @@ func PackageVersionDelete(ctx *context.Context) {
 		return
 	}
 
-	if canDeleteTerraformStateVersion(ctx) {
-		return
-	}
-
 	if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
-		log.Error("Error deleting package version: %v", err)
-		ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
+		errTr := util.ErrorAsTranslatable(err)
+		if errTr == nil {
+			ctx.ServerError("RemovePackageVersion", err)
+			return
+		}
+		ctx.Flash.Error(errTr.Translate(ctx.Locale))
 	} else {
 		ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
 	}
 
-	redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
 	// redirect to the package if there are still versions available
+	redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
 	if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
 		redirectURL = pd.PackageWebLink()
 	}
-
 	ctx.Redirect(redirectURL)
 }
 
diff --git a/services/packages/packages.go b/services/packages/packages.go
index f9f3c006132fe..35912dd208cf0 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -32,6 +32,11 @@ var (
 	ErrQuotaTotalCount = errors.New("maximum allowed package count exceeded")
 )
 
+type Specialization interface {
+	OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error
+	OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error
+}
+
 // PackageInfo describes a package
 type PackageInfo struct {
 	Owner       *user_model.User
@@ -475,6 +480,9 @@ func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packag
 	if err != nil {
 		return err
 	}
+	if err := GetSpecManager().Get(pd.Package.Type).OnBeforeRemovePackageVersion(ctx, doer, pd); err != nil {
+		return err
+	}
 	// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by the cleanup_packages cron task.
 	// If there are no more versions for the package, the same task removes that as well.
 	if err := db.WithTx(ctx, func(ctx context.Context) error {
@@ -633,6 +641,10 @@ func RemovePackage(ctx context.Context, doer *user_model.User, p *packages_model
 	if err != nil {
 		return err
 	}
+	if err := GetSpecManager().Get(p.Type).OnBeforeRemovePackageAll(ctx, doer, p, pds); err != nil {
+		return err
+	}
+
 	// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by cleanup_packages cron task.
 	err = db.WithTx(ctx, func(ctx context.Context) error {
 		err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID)
diff --git a/services/packages/pkgspec/manager.go b/services/packages/pkgspec/manager.go
new file mode 100644
index 0000000000000..792754519c54b
--- /dev/null
+++ b/services/packages/pkgspec/manager.go
@@ -0,0 +1,15 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pkgspec
+
+import (
+	packages_model "code.gitea.io/gitea/models/packages"
+	packages_service "code.gitea.io/gitea/services/packages"
+	"code.gitea.io/gitea/services/packages/terraform"
+)
+
+func InitManager() error {
+	packages_service.GetSpecManager().Add(packages_model.TypeTerraformState, &terraform.Specialization{})
+	return nil
+}
diff --git a/services/packages/spec.go b/services/packages/spec.go
new file mode 100644
index 0000000000000..745362050c264
--- /dev/null
+++ b/services/packages/spec.go
@@ -0,0 +1,47 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+	"context"
+	"sync"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+type nop struct{}
+
+func (n *nop) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error {
+	return nil
+}
+
+func (n *nop) OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error {
+	return nil
+}
+
+var _ Specialization = (*nop)(nil)
+
+type SpecManagerType struct {
+	specMap map[packages_model.Type]Specialization
+}
+
+func (m *SpecManagerType) Add(t packages_model.Type, spec Specialization) {
+	m.specMap[t] = spec
+}
+
+func (m *SpecManagerType) Get(t packages_model.Type) Specialization {
+	if len(m.specMap) == 0 {
+		panic("specialization not initialized")
+	}
+	spec := m.specMap[t]
+	if spec == nil {
+		return &nop{}
+	}
+	return spec
+}
+
+var GetSpecManager = sync.OnceValue(func() *SpecManagerType {
+	return &SpecManagerType{specMap: make(map[packages_model.Type]Specialization)}
+})
diff --git a/services/packages/terraform/spec.go b/services/packages/terraform/spec.go
new file mode 100644
index 0000000000000..48214c6cddd16
--- /dev/null
+++ b/services/packages/terraform/spec.go
@@ -0,0 +1,56 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package terraform
+
+import (
+	"context"
+
+	packages_model "code.gitea.io/gitea/models/packages"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/util"
+	packages_service "code.gitea.io/gitea/services/packages"
+)
+
+type Specialization struct{}
+
+var _ packages_service.Specialization = (*Specialization)(nil)
+
+func (s Specialization) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error {
+	locked, err := IsLocked(ctx, pkg)
+	if err != nil {
+		return err
+	}
+	if locked {
+		return util.ErrorWrapTranslatable(
+			util.ErrUnprocessableContent,
+			"packages.terraform.delete.locked",
+		)
+	}
+	return nil
+}
+
+func (s Specialization) OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error {
+	locked, err := IsLocked(ctx, pd.Package)
+	if err != nil {
+		return err
+	}
+	if locked {
+		return util.ErrorWrapTranslatable(
+			util.ErrUnprocessableContent,
+			"packages.terraform.delete.locked",
+		)
+	}
+
+	latest, err := IsLatest(ctx, pd)
+	if err != nil {
+		return err
+	}
+	if latest {
+		return util.ErrorWrapTranslatable(
+			util.ErrUnprocessableContent,
+			"packages.terraform.delete.latest",
+		)
+	}
+	return nil
+}

From 8e79712f59db29fbf9f1c9720649c069bb06dc05 Mon Sep 17 00:00:00 2001
From: wxiaoguang 
Date: Mon, 6 Apr 2026 22:14:40 +0800
Subject: [PATCH 51/61] refactor view package version

---
 routers/init.go                           |  1 +
 routers/web/user/package.go               | 27 ++++-----------------
 services/packages/packages.go             |  1 +
 services/packages/spec.go                 |  4 ++++
 services/packages/terraform/spec.go       | 29 +++++++++++++++++++++++
 templates/package/metadata/terraform.tmpl |  9 +++----
 6 files changed, 44 insertions(+), 27 deletions(-)

diff --git a/routers/init.go b/routers/init.go
index faa824ded5b86..92eab5eaf28a1 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -146,6 +146,7 @@ func InitWebInstalled(ctx context.Context) {
 	mustInitCtx(ctx, oauth2.Init)
 	mustInitCtx(ctx, oauth2_provider.Init)
 	mustInit(release_service.Init)
+
 	mustInitCtx(ctx, models.Init)
 	mustInitCtx(ctx, authmodel.Init)
 	mustInitCtx(ctx, repo_service.Init)
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index 2d4ae8af73af6..ef18da1a29bb5 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -318,29 +318,10 @@ func ViewPackageVersion(ctx *context.Context) {
 	}
 	ctx.Data["LatestVersions"] = pvs
 	ctx.Data["TotalVersionCount"] = pvsTotal
-
-	if pd.Package.Type == packages_model.TypeTerraformState {
-		latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
-			PackageID:  pd.Package.ID,
-			IsInternal: optional.Some(false),
-		})
-		if err != nil {
-			ctx.ServerError("SearchLatestVersions", err)
-			return
-		}
-		isLatest := len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID
-		ctx.Data["IsLatestVersion"] = isLatest
-
-		if isLatest {
-			lockInfo, err := terraform_module.GetLock(ctx, pd.Package.ID)
-			if err != nil {
-				ctx.ServerError("GetLock", err)
-				return
-			}
-			if lockInfo.IsLocked() {
-				ctx.Data["TerraformLock"] = lockInfo
-			}
-		}
+	ctx.Data["PackageVersionViewData"], err = packages_service.GetSpecManager().Get(pd.Package.Type).GetViewPackageVersionData(ctx, pd)
+	if err != nil {
+		ctx.ServerError("GetViewPackageVersionData", err)
+		return
 	}
 
 	ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
diff --git a/services/packages/packages.go b/services/packages/packages.go
index 35912dd208cf0..48a0f176cb3c1 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -35,6 +35,7 @@ var (
 type Specialization interface {
 	OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error
 	OnBeforeRemovePackageVersion(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) error
+	GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error)
 }
 
 // PackageInfo describes a package
diff --git a/services/packages/spec.go b/services/packages/spec.go
index 745362050c264..4471413773ea3 100644
--- a/services/packages/spec.go
+++ b/services/packages/spec.go
@@ -13,6 +13,10 @@ import (
 
 type nop struct{}
 
+func (n *nop) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) {
+	return nil, nil
+}
+
 func (n *nop) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error {
 	return nil
 }
diff --git a/services/packages/terraform/spec.go b/services/packages/terraform/spec.go
index 48214c6cddd16..5aa52d6e77a4e 100644
--- a/services/packages/terraform/spec.go
+++ b/services/packages/terraform/spec.go
@@ -8,6 +8,8 @@ import (
 
 	packages_model "code.gitea.io/gitea/models/packages"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/optional"
+	terraform_module "code.gitea.io/gitea/modules/packages/terraform"
 	"code.gitea.io/gitea/modules/util"
 	packages_service "code.gitea.io/gitea/services/packages"
 )
@@ -16,6 +18,33 @@ type Specialization struct{}
 
 var _ packages_service.Specialization = (*Specialization)(nil)
 
+func (s Specialization) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) {
+	var ret struct {
+		IsLatestVersion bool
+		TerraformLock   terraform_module.LockInfo
+	}
+	latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+		PackageID:  pd.Package.ID,
+		IsInternal: optional.Some(false),
+	})
+	if err != nil {
+		return ret, err
+	}
+	isLatest := len(latestPvs) > 0 && latestPvs[0].ID == pd.Version.ID
+	ret.IsLatestVersion = isLatest
+
+	if isLatest {
+		lockInfo, err := terraform_module.GetLock(ctx, pd.Package.ID)
+		if err != nil {
+			return ret, nil
+		}
+		if lockInfo.IsLocked() {
+			ret.TerraformLock = lockInfo
+		}
+	}
+	return ret, nil
+}
+
 func (s Specialization) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error {
 	locked, err := IsLocked(ctx, pkg)
 	if err != nil {
diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl
index d07b872179060..ad7715387997d 100644
--- a/templates/package/metadata/terraform.tmpl
+++ b/templates/package/metadata/terraform.tmpl
@@ -1,18 +1,19 @@
 {{if eq .PackageDescriptor.Package.Type "terraform"}}
-	{{if .IsLatestVersion}}
+	{{$data := $.PackageVersionViewData}}
+	{{if $data.IsLatestVersion}}
 		
{{ctx.Locale.Tr "packages.terraform.lock_status"}}
- {{if .TerraformLock}} + {{if $data.TerraformLock}}
{{svg "octicon-lock" 16 "tw-text-red"}} - {{ctx.Locale.Tr "packages.terraform.locked_by" .TerraformLock.Who}} + {{ctx.Locale.Tr "packages.terraform.locked_by" $data.TerraformLock.Who}}
- {{DateUtils.TimeSince .TerraformLock.Created}} ({{.TerraformLock.Operation}}) + {{DateUtils.TimeSince $data.TerraformLock.Created}} ({{$data.TerraformLock.Operation}})
{{if .CanWritePackages}}
From f995892b87f05f65f61ab96c8372c562694fc980 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Apr 2026 22:19:40 +0800 Subject: [PATCH 52/61] fix err msg --- services/packages/terraform/spec.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/packages/terraform/spec.go b/services/packages/terraform/spec.go index 5aa52d6e77a4e..b12edc7feb8e2 100644 --- a/services/packages/terraform/spec.go +++ b/services/packages/terraform/spec.go @@ -52,7 +52,7 @@ func (s Specialization) OnBeforeRemovePackageAll(ctx context.Context, doer *user } if locked { return util.ErrorWrapTranslatable( - util.ErrUnprocessableContent, + util.ErrorWrap(util.ErrUnprocessableContent, "terraform state is locked and cannot be deleted"), "packages.terraform.delete.locked", ) } @@ -66,7 +66,7 @@ func (s Specialization) OnBeforeRemovePackageVersion(ctx context.Context, doer * } if locked { return util.ErrorWrapTranslatable( - util.ErrUnprocessableContent, + util.ErrorWrap(util.ErrUnprocessableContent, "terraform state is locked and cannot be deleted"), "packages.terraform.delete.locked", ) } @@ -77,7 +77,7 @@ func (s Specialization) OnBeforeRemovePackageVersion(ctx context.Context, doer * } if latest { return util.ErrorWrapTranslatable( - util.ErrUnprocessableContent, + util.ErrorWrap(util.ErrUnprocessableContent, "the latest version of a Terraform state cannot be deleted"), "packages.terraform.delete.latest", ) } From 700781c3d58bbd3103fddde7fc6037f977a5d1fd Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Apr 2026 22:29:15 +0800 Subject: [PATCH 53/61] fix lint --- services/packages/pkgspec/manager.go | 4 +++- services/packages/spec.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/packages/pkgspec/manager.go b/services/packages/pkgspec/manager.go index 792754519c54b..d2f33e0d47dde 100644 --- a/services/packages/pkgspec/manager.go +++ b/services/packages/pkgspec/manager.go @@ -10,6 +10,8 @@ import ( ) func InitManager() error { - packages_service.GetSpecManager().Add(packages_model.TypeTerraformState, &terraform.Specialization{}) + mgr := packages_service.GetSpecManager() + mgr.Add(packages_model.TypeTerraformState, &terraform.Specialization{}) + // TODO: add more in the future, refactor the existing code to use this approach return nil } diff --git a/services/packages/spec.go b/services/packages/spec.go index 4471413773ea3..0815bdc98d29a 100644 --- a/services/packages/spec.go +++ b/services/packages/spec.go @@ -14,7 +14,7 @@ import ( type nop struct{} func (n *nop) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) { - return nil, nil + return nil, nil //nolint:nilnil // no data, no error } func (n *nop) OnBeforeRemovePackageAll(ctx context.Context, doer *user_model.User, pkg *packages_model.Package, pds []*packages_model.PackageDescriptor) error { From f8adfafbeabb34af25d0af0f81676177328c2b3a Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 17:48:14 +0200 Subject: [PATCH 54/61] fix UI regression where lock could not have been added on the package --- services/packages/terraform/spec.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/packages/terraform/spec.go b/services/packages/terraform/spec.go index b12edc7feb8e2..982f29fb81823 100644 --- a/services/packages/terraform/spec.go +++ b/services/packages/terraform/spec.go @@ -21,7 +21,7 @@ var _ packages_service.Specialization = (*Specialization)(nil) func (s Specialization) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) { var ret struct { IsLatestVersion bool - TerraformLock terraform_module.LockInfo + TerraformLock *terraform_module.LockInfo } latestPvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ PackageID: pd.Package.ID, @@ -39,7 +39,7 @@ func (s Specialization) GetViewPackageVersionData(ctx context.Context, pd *packa return ret, nil } if lockInfo.IsLocked() { - ret.TerraformLock = lockInfo + ret.TerraformLock = &lockInfo } } return ret, nil From 67ab2a220e11fdc1d2e5c88144750d1121a4e47a Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 17:55:30 +0200 Subject: [PATCH 55/61] use flex-text-block and drop margins --- templates/package/metadata/terraform.tmpl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/package/metadata/terraform.tmpl b/templates/package/metadata/terraform.tmpl index ad7715387997d..3cecdc38ba183 100644 --- a/templates/package/metadata/terraform.tmpl +++ b/templates/package/metadata/terraform.tmpl @@ -8,7 +8,7 @@
{{if $data.TerraformLock}} -
+
{{svg "octicon-lock" 16 "tw-text-red"}} {{ctx.Locale.Tr "packages.terraform.locked_by" $data.TerraformLock.Who}}
@@ -16,19 +16,19 @@ {{DateUtils.TimeSince $data.TerraformLock.Created}} ({{$data.TerraformLock.Operation}})
{{if .CanWritePackages}} -
+
{{end}} {{else}} -
+
{{svg "octicon-unlock" 16 "tw-text-green"}} {{ctx.Locale.Tr "packages.terraform.unlocked"}}
{{if .CanWritePackages}} -
+
From f17d92cf248dc7ddb822c3afab48513b7b850a64 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 18:00:35 +0200 Subject: [PATCH 56/61] update example ini with state limit --- custom/conf/app.example.ini | 2 ++ modules/setting/packages.go | 52 +++++++++++++++++------------------ services/packages/packages.go | 2 +- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 4df50f5cc6c05..e5d5ca669fe22 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2790,6 +2790,8 @@ LEVEL = Info ;LIMIT_SIZE_SWIFT = -1 ;; Maximum size of a Vagrant upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_VAGRANT = -1 +;; Maximum size of a Terraform state upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +;LIMIT_SIZE_TERRAFORM_STATE = -1 ;; Enable RPM re-signing by default. (It will overwrite the old signature ,using v4 format, not compatible with CentOS 6 or older) ;DEFAULT_RPM_SIGN_ENABLED = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/modules/setting/packages.go b/modules/setting/packages.go index e873aa3001caa..38ee2ad55ebb4 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -16,31 +16,31 @@ var ( Storage *Storage Enabled bool - LimitTotalOwnerCount int64 - LimitTotalOwnerSize int64 - LimitSizeAlpine int64 - LimitSizeArch int64 - LimitSizeCargo int64 - LimitSizeChef int64 - LimitSizeComposer int64 - LimitSizeConan int64 - LimitSizeConda int64 - LimitSizeContainer int64 - LimitSizeCran int64 - LimitSizeDebian int64 - LimitSizeGeneric int64 - LimitSizeGo int64 - LimitSizeHelm int64 - LimitSizeMaven int64 - LimitSizeNpm int64 - LimitSizeNuGet int64 - LimitSizePub int64 - LimitSizePyPI int64 - LimitSizeRpm int64 - LimitSizeRubyGems int64 - LimitSizeSwift int64 - LimitSizeTerraform int64 - LimitSizeVagrant int64 + LimitTotalOwnerCount int64 + LimitTotalOwnerSize int64 + LimitSizeAlpine int64 + LimitSizeArch int64 + LimitSizeCargo int64 + LimitSizeChef int64 + LimitSizeComposer int64 + LimitSizeConan int64 + LimitSizeConda int64 + LimitSizeContainer int64 + LimitSizeCran int64 + LimitSizeDebian int64 + LimitSizeGeneric int64 + LimitSizeGo int64 + LimitSizeHelm int64 + LimitSizeMaven int64 + LimitSizeNpm int64 + LimitSizeNuGet int64 + LimitSizePub int64 + LimitSizePyPI int64 + LimitSizeRpm int64 + LimitSizeRubyGems int64 + LimitSizeSwift int64 + LimitSizeTerraformState int64 + LimitSizeVagrant int64 DefaultRPMSignEnabled bool }{ @@ -87,7 +87,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) { Packages.LimitSizeRpm = mustBytes(sec, "LIMIT_SIZE_RPM") Packages.LimitSizeRubyGems = mustBytes(sec, "LIMIT_SIZE_RUBYGEMS") Packages.LimitSizeSwift = mustBytes(sec, "LIMIT_SIZE_SWIFT") - Packages.LimitSizeTerraform = mustBytes(sec, "LIMIT_SIZE_TERRAFORM") + Packages.LimitSizeTerraformState = mustBytes(sec, "LIMIT_SIZE_TERRAFORM_STATE") Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT") Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false) return nil diff --git a/services/packages/packages.go b/services/packages/packages.go index 48a0f176cb3c1..03b2803297f9f 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -401,7 +401,7 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p case packages_model.TypeSwift: typeSpecificSize = setting.Packages.LimitSizeSwift case packages_model.TypeTerraformState: - typeSpecificSize = setting.Packages.LimitSizeTerraform + typeSpecificSize = setting.Packages.LimitSizeTerraformState case packages_model.TypeVagrant: typeSpecificSize = setting.Packages.LimitSizeVagrant } From a073c7270465b4793db9bb6b8ef440fb48c87e8a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 7 Apr 2026 00:04:17 +0800 Subject: [PATCH 57/61] remove unused global error var --- modules/packages/terraform/lock.go | 4 +--- modules/packages/terraform/state.go | 18 +++++------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/modules/packages/terraform/lock.go b/modules/packages/terraform/lock.go index c1bd5a92a3c11..3c326c04e9beb 100644 --- a/modules/packages/terraform/lock.go +++ b/modules/packages/terraform/lock.go @@ -19,8 +19,6 @@ import ( const LockFile = "terraform.lock" -var ErrMissingLockID = util.NewInvalidArgumentErrorf("terraform lock is missing an ID") - // LockInfo is the metadata for a terraform lock. type LockInfo struct { ID string `json:"ID"` @@ -44,7 +42,7 @@ func ParseLockInfo(r io.Reader) (*LockInfo, error) { } // ID is required. Rest is less important. if lock.ID == "" { - return nil, ErrMissingLockID + return nil, util.NewInvalidArgumentErrorf("terraform lock is missing an ID") } return &lock, nil } diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go index b1e19024ac18a..0cf7de73ea11a 100644 --- a/modules/packages/terraform/state.go +++ b/modules/packages/terraform/state.go @@ -10,14 +10,6 @@ import ( "code.gitea.io/gitea/modules/util" ) -var ( - ErrNoSerial = util.NewInvalidArgumentErrorf("state serial is missing") - ErrNoLineage = util.NewInvalidArgumentErrorf("state lineage is missing") - ErrNoTerraformVersion = util.NewInvalidArgumentErrorf("state terraform version is missing") - ErrNoResources = util.NewInvalidArgumentErrorf("state resources are missing") - ErrNoOutputs = util.NewInvalidArgumentErrorf("state outputs are missing") -) - type State struct { Version int `json:"version"` TerraformVersion string `json:"terraform_version"` @@ -36,23 +28,23 @@ func ParseState(r io.Reader) (*State, error) { } // Serial starts at 1; 0 means it wasn't set in the state file if state.Serial == 0 { - return nil, ErrNoSerial + return nil, util.NewInvalidArgumentErrorf("state serial is missing") } if state.Version != 4 { return nil, util.NewInvalidArgumentErrorf("state version %d is not supported", state.Version) } if state.TerraformVersion == "" { - return nil, ErrNoTerraformVersion + return nil, util.NewInvalidArgumentErrorf("state terraform version is missing") } // Lineage should always be set if state.Lineage == "" { - return nil, ErrNoLineage + return nil, util.NewInvalidArgumentErrorf("state lineage is missing") } if state.Resources == nil { - return nil, ErrNoResources + return nil, util.NewInvalidArgumentErrorf("state resources are missing") } if state.Outputs == nil { - return nil, ErrNoOutputs + return nil, util.NewInvalidArgumentErrorf("state outputs are missing") } return &state, nil From 7bd9134a0e8ca0d4ea9df7803d9a7228aa4aece7 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 18:44:36 +0200 Subject: [PATCH 58/61] drop full validation of state file as it was interrupting the encrypted state test for encrypted state was added --- modules/packages/terraform/state.go | 20 +-- .../api_packages_terraform_test.go | 131 +++++++++++------- 2 files changed, 83 insertions(+), 68 deletions(-) diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go index 0cf7de73ea11a..b5c7e48032128 100644 --- a/modules/packages/terraform/state.go +++ b/modules/packages/terraform/state.go @@ -10,16 +10,14 @@ import ( "code.gitea.io/gitea/modules/util" ) +// Note: This only checks correctness of required parame + type State struct { - Version int `json:"version"` - TerraformVersion string `json:"terraform_version"` Serial uint64 `json:"serial"` Lineage string `json:"lineage"` - Resources json.Value `json:"resources"` - Outputs json.Value `json:"outputs"` } -// ParseState parses the Terraform state file +// ParseState parses the required parts of Terraform state file func ParseState(r io.Reader) (*State, error) { var state State err := json.NewDecoder(r).Decode(&state) @@ -30,22 +28,10 @@ func ParseState(r io.Reader) (*State, error) { if state.Serial == 0 { return nil, util.NewInvalidArgumentErrorf("state serial is missing") } - if state.Version != 4 { - return nil, util.NewInvalidArgumentErrorf("state version %d is not supported", state.Version) - } - if state.TerraformVersion == "" { - return nil, util.NewInvalidArgumentErrorf("state terraform version is missing") - } // Lineage should always be set if state.Lineage == "" { return nil, util.NewInvalidArgumentErrorf("state lineage is missing") } - if state.Resources == nil { - return nil, util.NewInvalidArgumentErrorf("state resources are missing") - } - if state.Outputs == nil { - return nil, util.NewInvalidArgumentErrorf("state outputs are missing") - } return &state, nil } diff --git a/tests/integration/api_packages_terraform_test.go b/tests/integration/api_packages_terraform_test.go index 33a6c3780266c..3f39aa5bfcad1 100644 --- a/tests/integration/api_packages_terraform_test.go +++ b/tests/integration/api_packages_terraform_test.go @@ -50,6 +50,18 @@ func TestPackageTerraform(t *testing.T) { "check_results": null }`, serial) } + genEncryptedState := func(serial int) string { + // json taken from wireshark inspection + return fmt.Sprintf(`{ + "serial": %d, + "lineage": "4dad5e35-cbd5-ca2f-3c20-97c5e13b7033", + "meta": { + "key_provider.pbkdf2.foo": "eyJzYWx0IjoiWE55NnRDZTlSQnFNWGRqWm5xc202TU1DREZNbW5FbXRHczc2UXI0NEpXST0iLCJpdGVyYXRpb25zIjo2MDAwMDAsImhhc2hfZnVuY3Rpb24iOiJzaGE1MTIiLCJrZXlfbGVuZ3RoIjozMn0=" + }, + "encrypted_data": "Q4YE7v2NzQK7d+4Qk5tEmsTiQpKsIdhk9mpgKw4r98impellasWdS/8LW0FWVj7HWiwhlcD93ys1WxBcp2xPM8bfYx8TET+beHua+hAo3kuUVdco+U7l0pydpO2UHvc5yScN1WWgdyyFhjdIIR5R9v86epr3YD8AxPB2As/poKTW2BuFDyzrF98JzZY+XW2MxVvUh5xUMDUp4kOWzN1Qg68gppajeTtcu1Q2G3I6SIksdakyC9XT7d/LmmYgFLkjK/rZzKxb31rVXfkpULkHd1GSyVNUKXRKgBZw7Hb4OIaXQ4UXgmTQswYJnlXI7n+LXFpskUdEArjZZ9DbixSyX9B5qHaV/8lJ1WRtAWY5U+FfrEYKFNnDX9cLFOTZt9cmBua7Bpw5aROy9a1JTkJLdO5TT7+//KNc3pkMQ0D0yeKMCHF111yn33unfKTDPf9RQyOXuIGS5cE9+FFSBFYu+bpatF6SFLPfA74W2vdvOazOpWPQLopT+OYMKXkxMQNbmLaMFvZZRdib5ER44/SwKssPeyqms6Opx+qrRATkF6WyDZtCVlzA9nRjJbtT9clTDEnOn9m/Fr0EB4a8xJXuQ93q7no23IlZFoKhaQgQWSgClcDRTTXkITV5tavIey3VN+ybRNNimiPcvzWYLtjQN7ZjhDpQ1a90ju9XY+LOIswCrXx4Uxb7mAq8ZZDrrekerSdimDPG5d+TQOLjtMJbBS0kE3IdrUtlgssST+EAxwlmZiBWs3pJoOYaTuy7wQ4ZUb/cc9AE3DH7iVGFbZDZNKu/oDKo4asQ5L6cUYFf3PVJu0CuNAYEiqNnyh57GnaQ9Wi9iaEALgAYIR/7faQHgENLmLzw4fNIAaort2N4PehWmatEgzvr+9jSqY3ZXxiKFJqo/uNWBhfZACdigrx8Jkz7CjC/mnzi9aggDFvIUh1hdsbuf6FxXRU1mF+kyrOkLYDQnkmNOAhDAWY/f+ICFn3BUL9yFD5hQeaWCt7apCrICil2cUGE2VYUYda8PzS/2f27qnchnd09f+6nl0FnfKvE60zbY2iTmNFHPszqEaSOXrK5caWkpgTZf890E7KlbxSPM+P/jWQo76G3+mOxqhCxxRlFqjT13jhtPMjiVxtQJhQibA70nop2X3akJnIAe7bpniO3jYg4M1gc4smNMYzusL4C7N0Om4JxA5SdqS6E+9ZmO4yFaNDfK/BfskESkqIqM7sYf5t/lBDqdJYw4tfBmQRux5hyGk3zqP/vTlMs040LoXmajeenmg9WWEF27aNmT2qKZ/v/YQbuT3uCphIkPdiVOYSNq4mF8YvzGw0tPHv3fOJogpXG/Q9igHkIwOigtmvyTaIyJc4A9gwUWv91QH21w/XukIS37Ws4wPjnMekaTbFDd47CA6wHU/54CVvyQZZKk9TFHTNlm5Kqnb691RxherUoL//THQNkAl8n1ZiKv+fn8eMZ412VeR4eWO2xmI1hpRW52mc/wd48izboYS7vHRG8fPs/Bth3eSTtwMk21Ed5A8AZIakeQ+L76bZP0BEY330jfImANh7eqpWEgb5URQtP4utqIJPlIWJ1f6iHqdymB9Xx3E0zU0h76sm/tAqtzjMuWp/UZuaF4EdX0PGGMBe06M0dCe7FuDA1UAEX126ox3vD1+kcrteLeWV8p3FRtVSmV0u1W5VsuA6MtGkvAJnUUgqdkPhbfcc1zfRE5r9KwWzdL1B6Xtb055Hb7AmH9KYQFi0qTuqf+cYUrsrG8lsIa3RHY1U8/+u7U6aMs2pkl0hRmTtHcC2+DdmQMsOj9hriDkGF0xne3DaeSmMJJ/pFm+d80FHO7nhHKSZNLho2JjGmq4AA1104IBi+j1/+9ICpk/4iaQvss1m7gB/2SQGOsqi7dPXAIRiAjIgES9RK5/R0ZgyeLsTutM0aTYKq+Ee6NlGiCocOc5MXZsv9tAsg3SJBaQAMkE8hHbEh+hvY3qTTu2i7BRl1taUU/vAhUWZoC6BNnLpxnhP7TdV6uqgYVUKTILjWBeY3QsikIPY9ybxFy3tiqgdLbmqiq+gPJ1LSWZuhJkjbpS9VnUi2odYJFKoe9oiWD5EKOcHXxmmc2YOOBaa8jrjhWswoOi4AEhNT39vISQT0sX8Dd7IN0fpeU5cpDQsz+fRa+fDu8+oa87NoetUJ3leEotXEXDFa/L75wSkBwYmCjuAyxl+CEI5m/Yze4eURRRkmS2RoedhsdiRGm4FPwLKFqGNvMJvdOu8GGfWOIXDwFbm9MS/dNG4oOOhKmfmIdaysuHujo7HGpepOAKnzOOVa02/EDeLlwiHftYsTxXg5ly3GJwE6eAwzSKHX1/AbedZfk5E1WkIx64j+iDCc1EgH6s73x6M4YXGv46nJym9LADtXoS9K8x6CA4a2dKfBJs70+PxusZW9GFSpZSZF1lcA6Uztib2b2c/qIe49hoVE2CwR054L5c7XoQgbbHMnGpGNvKAkS9X+3/GOncKs+MmdqUgs3DTUa5Jt6uH1r2io3jjldkfzBmlNdDHOUZK8oWSIHPEbumhrcZycxZy+t9shVp6QHr1ymMVMrWHA6Bbm3nJRnP57ZX5gOV2T0HtQc/x0V6bELwkWofatfbwn4YjP8xPNKq3onCSbJlB48+9SvgE7Hzgieq02oxiu2zCaqsDdenbLaLQho3Z7Apc1YU5yXF2ByPU5nQZc83le//I2CTlCAuRNbnaHeKVkzRuYAOcAAGkH1Fgzh3ae4XfHQHUSkKNj2R7j24zczHHznhGK5d4fsP8rYDzklSc/ux5ZQLOCXnTch/pGGVrUYqTe4cX9+VCoxAENLChYQT7PggiH3cPrs+2kBkpvIf9XisyBPuiH12kQLXNfBFZU5tT0At7+blcQn/ziJWN8i0Xz/1/x/zXvGR7AH5bkrr4OIgSkb9C+fi/kn0cTgsv76gJbn8ABMBpiZ4KA3HgSO1H0HWetaQ3Mfzia6t/kUWJ2+QVAaK5ryRQ6sS6oAUElRb73mka5tYGuEJdGjdMugfQrDgVNjUWMsPVAA1hhzFV0wetHnZdSqODxQdXhQ5zbhOtxJhyOLxyM+8IzsZP1Hide+1sxAST8E1HwrBOPFhfuYZmqhKixE4x2K2nGs11shx5vaLMcXdYitnStRr+9jfjcw9OfyYY3svs0PUbtkkHgPgmccZCH2uS+ftviQse85FAAnGKItPPgoJgkReBbigqFrLogyKO55t5avUuKWubONhnKGShn4u5Q5F92H3srLRjk49c1Pt/P1Yplv1dn3aNPZ8oRbJCHh/T1/LHSY8BtI6Zh40GnNb0X+OFwpyW1i6Hn04oLciYN84Mm89eR+YJ8Ec1NbuVy9xTTE8QCZVwKpf9dFmK7FalqFr+e6pFq4nvpyvUEwNuBVXiFu9cAM+zP9/dHlk+ZaRwjpPfYRFFxLedrLMHk1NZ/fKE0VwzqxCE9NDPAR7mpumgDPeODSlYGBMkCAIGKuNW7dTDJ+quXDfpo5ZTLaC9PqXVzJBTFaht8SbT5TjzdmMMrVc33YPPsaFeoMmnEwTlvahBTRlrBqe10ddIEoMKQkcyLVv1GZ8kTEKy4cmpqXDxhzNsZXt7bzxmGw6ESC1oRxtM6+nplg1Dv19EGhqeWkdHffQcSbxbCzwcmF1K7YdMMwerdVjCQXtFGmvAlmlFAHncb9rHBI32mMMKC+L1shjhXU5yLh9pt40JKp6DKXdZze4duP0fQeeDIvVSfLtz0lLcD5nVlthJ1jPvl84UOcWDTFvIEQ514l6Ko0aIzTMGKNFCDemi4K70qCPYGTiZGDQxJaJs3AYibz8shyo/5PbgoEV6JYW+4TrAuUcPwH7H185UawFiFx7KTadmfV+SBRAwuLCXzQZf4SYeQ2Q8ctOS/TBjnhiUIkxz1YPoHiQ71auuiaeSYWnHs3COu8MSxj90VcYeEOl5K96Eeksg6GoWbx+eYGrcTOtm9GIXcN6wJb19Yqk4uXG1+qhyNfOsh3jgddzUaGJw1TK6WNskBHP3uIKeC4C+FvEoSkdwSFb1QdeWCo/MPuzQIXrg76evg5Dcg45qj8MBjcUpZ5wxhxKH0jIdJlI6eZrIx+Iqgol5VR5JGetbxgm20aQSrxBX5bet9lS1gPnQJxtsnJ96rFwR2QteZQsqgT1GWivDQOZTMhxrkPr1wUYKNNKwQg67el/IS4Wj7ct1xSrDYA7Eno2mZlyBxb8dFpd5Yv22WZYL3sJDhRikjt5cZVlT6QvqIE00UFYHE/YtyBZMk8Xj/w+8Pm9oKJFjHX9/26kwj55/WE6gusfaHLf2fUF3/EIXO1PnD3IWkAaSFwc/qd28dEdWuF5kZcqg3Cg9KaH3kzaZTbVG/cN4IOPDKZGVloPPsQFMU/dJ06o3n49Q2p8Nv3gXkYFgWdtsVpNUhkvPtu7PhEHFgeY8+D964tPAdumJd6IqIOZQPUPoFvTHSC8G5RAH1cQkycbZiy+fTvyv/1SNvp8b7jSfw0hxmuujvsV3kypLMea2BJ1aTkOKMNNuY8WVDhrgsPvaApqXddl0s85D7JWKs7Na+rFCKaVST3wWb4UPTfkbJ3zBV9q9a11VQWiXTcnwtKW2uujzPWcCZB0TMSopFUOlpnVPi8GfmyU9xp/oMWZ8LBivhYYcf/PUwxNvhQAhbLv+/bz74OPCRg9XuIjbZnIMxoZI0/+U8Rd+g8UzkfBHhRsGdTuHi6m30xNggyn8HWbW0CtPGcuUSoagawKPj03A5Tent5uc2Pv3m62s9RXgiiYsFbzoNE1idATlCSZImgem+irUs+kcG1vfa8qwjTbOhJ0VHk0833g03ZhzX447IaLPByD1Cp534ZI2NL69JALEHLDAlPsf8LcJhU3zujaKi7xRr+e+TLZ0gDBO0LJ/erZspmR+TgRQ4Z6hLlAEA7UoVDOXU46h8Bod9g3qIHKidNyjcfGiO+m2xTMriiHoWDWsQYICkAKYPn5pTjzyKkbq/qreLwyjqpjiw6mYyLILS5Qr7oD329zV9jv0Xxb9sZxZbpoCsI4ekWMm75erEosvdEByYdnN+FW7xz0qB1v/Etr76V6B1XExs3E8MiILLhYriKeRs1QFhb1kvErbZz0ZuRAQlXL8qbBrkOUgnL49Ezb7CA4HsaLMZd4L12IVc5mrMVLqzHxw6mnWb8d26z4BlhXWbJL70M2brxtZS2bAcUatjUC7trK94xhwrWU1XD//LuHEiRKyn/IC8BwCMprh1GRjVVq5FZi/cad/HIkVWMKzTlUg4t3uD6f0vUzVTD0uYudRAettAm+EI18R34SbKgwVReRyNizI42UXidurv2SpJk8/2alVj/Pem/LIdNz5APJqTNWv9rd2tgrzGYLojtrfkSROInvk=", + "encryption_version": "v0" +}`, serial) + } genLock := func(uuid string) string { return fmt.Sprintf(`{ "ID": "%s", @@ -76,57 +88,74 @@ func TestPackageTerraform(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) }) t.Run("RegularOperations", func(t *testing.T) { - // 1. Lock the state - lockID := uuid.New().String() - lockInfo := genLock(lockID) - req := NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusOK) - - // Verify lock property in DB - p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) - require.NoError(t, err) - props, err := packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") - require.NoError(t, err) - require.Len(t, props, 1) - assert.Contains(t, props[0].Value, lockID) - - // Upload state with correct Lock ID - state1 := genState(1) - req = NewRequestWithBody(t, "POST", url+"?ID="+lockID, strings.NewReader(state1)).AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusCreated) - - // Verify version created - pv, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "1") - assert.NoError(t, err) - assert.NotNil(t, pv) - - // 3. Unlock the state - req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusOK) - - // Verify lock property is cleared - props, err = packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") - require.NoError(t, err) - require.Len(t, props, 1) - assert.Empty(t, props[0].Value) - - // Get latest state - req = NewRequest(t, "GET", url).AddBasicAuth(user.Name) - resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, state1, resp.Body.String()) - - // Upload new version without lock - state2 := genState(2) - req = NewRequestWithBody(t, "POST", url, strings.NewReader(state2)).AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusCreated) - - // 6. Delete the entire package - req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) - MakeRequest(t, req, http.StatusOK) - - // Verify package is deleted from DB - _, err = packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) - assert.ErrorIs(t, err, packages.ErrPackageNotExist) + cases := []struct { + name string + statefunc func(int) string + }{ + { + name: "Plain", + statefunc: genState, + }, + { + name: "Encrypted", + statefunc: genEncryptedState, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 1. Lock the state + lockID := uuid.New().String() + lockInfo := genLock(lockID) + req := NewRequestWithBody(t, "POST", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify lock property in DB + p, err := packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) + require.NoError(t, err) + props, err := packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") + require.NoError(t, err) + require.Len(t, props, 1) + assert.Contains(t, props[0].Value, lockID) + + // Upload state with correct Lock ID + state1 := tc.statefunc(1) + req = NewRequestWithBody(t, "POST", url+"?ID="+lockID, strings.NewReader(state1)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // Verify version created + pv, err := packages.GetVersionByNameAndVersion(t.Context(), user.ID, packages.TypeTerraformState, packageName, "1") + assert.NoError(t, err) + assert.NotNil(t, pv) + + // 3. Unlock the state + req = NewRequestWithBody(t, "DELETE", lockURL, strings.NewReader(lockInfo)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify lock property is cleared + props, err = packages.GetPropertiesByName(t.Context(), packages.PropertyTypePackage, p.ID, "terraform.lock") + require.NoError(t, err) + require.Len(t, props, 1) + assert.Empty(t, props[0].Value) + + // Get latest state + req = NewRequest(t, "GET", url).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, state1, resp.Body.String()) + + // Upload new version without lock + state2 := genState(2) + req = NewRequestWithBody(t, "POST", url, strings.NewReader(state2)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // 6. Delete the entire package + req = NewRequest(t, "DELETE", url).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // Verify package is deleted from DB + _, err = packages.GetPackageByName(t.Context(), user.ID, packages.TypeTerraformState, packageName) + assert.ErrorIs(t, err, packages.ErrPackageNotExist) + }) + } }) t.Run("StateHistory", func(t *testing.T) { From e45dcbbd76d2af2ea92495bdd9b4d6c3fb1d423f Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 18:46:07 +0200 Subject: [PATCH 59/61] someday I'll setup a pre-push hook to stop me from pushing before formatting --- modules/packages/terraform/state.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go index b5c7e48032128..9b64726dcfd89 100644 --- a/modules/packages/terraform/state.go +++ b/modules/packages/terraform/state.go @@ -13,8 +13,8 @@ import ( // Note: This only checks correctness of required parame type State struct { - Serial uint64 `json:"serial"` - Lineage string `json:"lineage"` + Serial uint64 `json:"serial"` + Lineage string `json:"lineage"` } // ParseState parses the required parts of Terraform state file From 34d86766e23ffc9a0ff47ddc798adba68c26ddb0 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 18:49:51 +0200 Subject: [PATCH 60/61] expand comment --- modules/packages/terraform/state.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go index 9b64726dcfd89..57631286991b9 100644 --- a/modules/packages/terraform/state.go +++ b/modules/packages/terraform/state.go @@ -10,7 +10,8 @@ import ( "code.gitea.io/gitea/modules/util" ) -// Note: This only checks correctness of required parame +// Note: this is a subset of the Terraform state file format as the full one has two forms. +// If needed, it can be expanded in the future. type State struct { Serial uint64 `json:"serial"` From 134637b1c7eb941024c57221a04aee3c3635e150 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 6 Apr 2026 19:38:59 +0200 Subject: [PATCH 61/61] return --- routers/web/user/package.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/web/user/package.go b/routers/web/user/package.go index ef18da1a29bb5..1484ba2fdff15 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -514,6 +514,7 @@ func packageSettingsPostActionDelete(ctx *context.Context) { } ctx.Flash.Error(errTr.Translate(ctx.Locale)) ctx.Redirect(pd.PackageSettingsLink()) + return } ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))