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/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/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/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/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg
new file mode 100644
index 0000000000000..82db40da8671e
--- /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..f18794f26687d 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,22 @@ 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))
+ r.Group("/lock", func() {
+ r.Post("", terraform.LockPackage)
+ r.Delete("", terraform.UnlockPackage)
+ }, 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..4d2d441ec069e
--- /dev/null
+++ b/routers/api/packages/terraform/terraform.go
@@ -0,0 +1,253 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package terraform
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+ "unicode"
+
+ 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"
+ "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`)
+)
+
+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)
+}
+
+// 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
+ }
+
+ 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
+ }
+
+ _ = globallock.Unlock(ctx, fmt.Sprintf("%s/%s", packageName, pv.LowerVersion))
+
+ ctx.Status(http.StatusOK)
+}
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/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
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..afd46427a1411
--- /dev/null
+++ b/templates/package/content/terraform.tmpl
@@ -0,0 +1,29 @@
+{{if eq .PackageDescriptor.Package.Type "terraform"}}
+
+terraform {
+ backend "http" {
+ address = " "
+ update_method = "PUT"
+ lock_address = " "
+ unlock_address = " "
+ lock_method = "POST"
+ unlock_method = "DELETE"
+ }
+}
+
terraform init -migrate-state