diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 4df50f5cc6..e5d5ca669f 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/models/packages/descriptor.go b/models/packages/descriptor.go
index 58f16c9eca..2ef27051ee 100644
--- a/models/packages/descriptor.go
+++ b/models/packages/descriptor.go
@@ -212,6 +212,8 @@ func GetPackageDescriptorWithCache(ctx context.Context, pv *PackageVersion, c *c
metadata = &rubygems.Metadata{}
case TypeSwift:
metadata = &swift.Metadata{}
+ case TypeTerraformState:
+ // terraform packages have no metadata
case TypeVagrant:
metadata = &vagrant.Metadata{}
default:
diff --git a/models/packages/package.go b/models/packages/package.go
index 38d1cdcf66..17e5d4eee3 100644
--- a/models/packages/package.go
+++ b/models/packages/package.go
@@ -30,28 +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"
- 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 = "terraform"
+ TypeVagrant Type = "vagrant"
)
var TypeList = []Type{
@@ -76,6 +77,7 @@ var TypeList = []Type{
TypeRpm,
TypeRubyGems,
TypeSwift,
+ TypeTerraformState,
TypeVagrant,
}
@@ -124,6 +126,8 @@ func (pt Type) Name() string {
return "RubyGems"
case TypeSwift:
return "Swift"
+ case TypeTerraformState:
+ return "Terraform State"
case TypeVagrant:
return "Vagrant"
}
@@ -175,6 +179,8 @@ func (pt Type) SVGName() string {
return "gitea-rubygems"
case TypeSwift:
return "gitea-swift"
+ case TypeTerraformState:
+ return "gitea-terraform"
case TypeVagrant:
return "gitea-vagrant"
}
diff --git a/modules/json/jsonlegacy.go b/modules/json/jsonlegacy.go
index 156e456041..83eabad452 100644
--- a/modules/json/jsonlegacy.go
+++ b/modules/json/jsonlegacy.go
@@ -6,6 +6,7 @@
package json
import (
+ "encoding/json"
"io"
)
@@ -20,3 +21,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 0bba2783bc..c4afc9513b 100644
--- a/modules/json/jsonv2.go
+++ b/modules/json/jsonv2.go
@@ -8,6 +8,7 @@ package json
import (
"bytes"
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"
)
@@ -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/lock.go b/modules/packages/terraform/lock.go
new file mode 100644
index 0000000000..3c326c04e9
--- /dev/null
+++ b/modules/packages/terraform/lock.go
@@ -0,0 +1,100 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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"
+
+// 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 != ""
+}
+
+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, util.NewInvalidArgumentErrorf("terraform lock is missing an ID")
+ }
+ 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) {
+ 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 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 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
+}
diff --git a/modules/packages/terraform/state.go b/modules/packages/terraform/state.go
new file mode 100644
index 0000000000..5763128699
--- /dev/null
+++ b/modules/packages/terraform/state.go
@@ -0,0 +1,38 @@
+// 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"
+)
+
+// 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"`
+ Lineage string `json:"lineage"`
+}
+
+// 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)
+ 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, util.NewInvalidArgumentErrorf("state serial is missing")
+ }
+ // Lineage should always be set
+ if state.Lineage == "" {
+ return nil, util.NewInvalidArgumentErrorf("state lineage is missing")
+ }
+
+ return &state, nil
+}
diff --git a/modules/setting/packages.go b/modules/setting/packages.go
index b598424064..38ee2ad55e 100644
--- a/modules/setting/packages.go
+++ b/modules/setting/packages.go
@@ -16,30 +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
- 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
}{
@@ -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.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/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index d289da6e57..9d61e3f1d7 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -3611,6 +3611,18 @@
"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.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.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:",
"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/public/assets/img/svg/gitea-terraform.svg b/public/assets/img/svg/gitea-terraform.svg
new file mode 100644
index 0000000000..809b7e6fe1
--- /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 ec5326130e..876d6aaa62 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -32,6 +32,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"
@@ -514,6 +515,21 @@ func CommonRoutes() *web.Router {
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
}, reqPackageAccess(perm.AccessModeRead))
})
+ // 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() {
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 0000000000..8b731b7dd2
--- /dev/null
+++ b/routers/api/packages/terraform/terraform.go
@@ -0,0 +1,438 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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/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"
+)
+
+var packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
+
+const (
+ stateFilename = "tfstate"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ message := helper.ProcessErrorForUser(ctx, status, obj)
+ ctx.PlainText(status, message)
+}
+
+// GetTerraformState serves the latest version of the state
+func GetTerraformState(ctx *context.Context) {
+ stateName := ctx.PathParam("name")
+ 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, pv.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.TypeTerraformState,
+ Name: name,
+ Version: serial,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: stateFilename,
+ },
+ ctx.Req.Method,
+ )
+ 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 != ".."
+}
+
+// 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
+ }
+ 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) {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if p != nil {
+ // Check lock
+ lock, err := terraform_module.GetLock(ctx, p.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ // If the state is locked, enforce the lock
+ if lock.IsLocked() && lock.ID != ctx.FormString("ID") {
+ ctx.JSON(http.StatusLocked, lock)
+ 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()
+
+ state, err := terraform_module.ParseState(buf)
+ if err != nil {
+ log.Error("Error decoding state: %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{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeTerraformState,
+ Name: packageName,
+ Version: strconv.FormatUint(state.Serial, 10),
+ },
+ Creator: ctx.Doer,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: stateFilename,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch {
+ case errors.Is(err, packages_model.ErrDuplicatePackageFile):
+ apiError(ctx, http.StatusConflict, err)
+ 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)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// 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) {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ } else if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pvLatest, err := getLatestVersion(ctx, ctx.PathParam("name"))
+ if errors.Is(err, packages_model.ErrPackageNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ } else if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if pvLatest.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) {
+ 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) {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ lock, err := terraform_module.GetLock(ctx, p.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if lock.IsLocked() {
+ apiError(ctx, http.StatusLocked, errors.New("terraform state is locked"))
+ return
+ }
+
+ 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
+ }
+
+ 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)
+ return
+ }
+ }
+
+ if err := packages_model.DeletePackageByID(ctx, p.ID); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+// LockState locks the specific terraform state.
+// Internally, it adds a property to the package with the lock information
+// Caveat being that it allocates a package if one doesn't exist to attach the property
+func LockState(ctx *context.Context) {
+ 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
+ }
+
+ 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 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.TypeTerraformState,
+ 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
+ }
+ }
+
+ currentLock, err := terraform_module.GetLock(ctx, p.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if currentLock.IsLocked() {
+ ctx.JSON(http.StatusLocked, currentLock)
+ return
+ }
+
+ err = terraform_module.SetLock(ctx, p.ID, reqLockInfo)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+// UnlockState unlock the specific terraform state.
+// Internally, it clears the package property
+func UnlockState(ctx *context.Context) {
+ 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
+ }
+
+ 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) {
+ ctx.Status(http.StatusOK)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ 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.IsLocked() {
+ 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
+ err = terraform_module.RemoveLock(ctx, p.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+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.TypeTerraformState,
+ 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
+}
+
+func getLockKey(ctx *context.Context) string {
+ return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, strings.ToLower(ctx.PathParam("name")))
+}
diff --git a/routers/api/packages/terraform/terraform_test.go b/routers/api/packages/terraform/terraform_test.go
new file mode 100644
index 0000000000..e4705d0ccf
--- /dev/null
+++ b/routers/api/packages/terraform/terraform_test.go
@@ -0,0 +1,36 @@
+// Copyright 2026 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)
+ }
+}
diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go
index 376867ab82..c7c66f549c 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/routers/init.go b/routers/init.go
index 2ed7a57e5c..92eab5eaf2 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"
@@ -149,6 +150,7 @@ func InitWebInstalled(ctx context.Context) {
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 b748ead543..1484ba2fdf 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,13 +19,13 @@ 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"
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"
@@ -35,6 +36,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 (
@@ -315,6 +318,11 @@ func ViewPackageVersion(ctx *context.Context) {
}
ctx.Data["LatestVersions"] = pvs
ctx.Data["TotalVersionCount"] = pvsTotal
+ 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()
@@ -498,14 +506,18 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
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())
+ return
}
+ ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
}
@@ -518,18 +530,21 @@ func PackageVersionDelete(ctx *context.Context) {
}
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)
}
@@ -553,3 +568,56 @@ 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
+ }
+
+ existingLock, err := terraform_module.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 := &terraform_module.LockInfo{
+ ID: lockID,
+ Operation: "Manual UI Lock",
+ Who: ctx.Doer.Name,
+ Created: time.Now(),
+ }
+
+ if err := terraform_module.SetLock(ctx, pd.Package.ID, lockInfo); err != nil {
+ ctx.ServerError("SetLock", 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 := terraform_module.RemoveLock(ctx, pd.Package.ID); err != nil {
+ ctx.ServerError("RemoveLock", 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 f85c2f7501..61d1fdc142 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1073,6 +1073,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("", user.ViewPackageVersion)
m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete)
m.Get("/{version_sub}", user.ViewPackageVersion)
+ m.Group("/terraform", func() {
+ m.Post("/lock", user.ActionPackageTerraformLock)
+ m.Post("/unlock", user.ActionPackageTerraformUnlock)
+ }, reqPackageAccess(perm.AccessModeWrite))
m.Get("/files/{fileid}", user.DownloadPackageFile)
})
})
diff --git a/services/forms/package_form.go b/services/forms/package_form.go
index 9b6f907164..d1a2b8587c 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 47714add82..03b2803297 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -32,6 +32,12 @@ 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
+ GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error)
+}
+
// PackageInfo describes a package
type PackageInfo struct {
Owner *user_model.User
@@ -394,6 +400,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.TypeTerraformState:
+ typeSpecificSize = setting.Packages.LimitSizeTerraformState
case packages_model.TypeVagrant:
typeSpecificSize = setting.Packages.LimitSizeVagrant
}
@@ -473,6 +481,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 {
@@ -631,6 +642,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 0000000000..d2f33e0d47
--- /dev/null
+++ b/services/packages/pkgspec/manager.go
@@ -0,0 +1,17 @@
+// 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 {
+ 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
new file mode 100644
index 0000000000..0815bdc98d
--- /dev/null
+++ b/services/packages/spec.go
@@ -0,0 +1,51 @@
+// 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) GetViewPackageVersionData(ctx context.Context, pd *packages_model.PackageDescriptor) (any, error) {
+ 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 {
+ 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 0000000000..982f29fb81
--- /dev/null
+++ b/services/packages/terraform/spec.go
@@ -0,0 +1,85 @@
+// 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/optional"
+ terraform_module "code.gitea.io/gitea/modules/packages/terraform"
+ "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) 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 {
+ return err
+ }
+ if locked {
+ return util.ErrorWrapTranslatable(
+ util.ErrorWrap(util.ErrUnprocessableContent, "terraform state is locked and cannot be deleted"),
+ "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.ErrorWrap(util.ErrUnprocessableContent, "terraform state is locked and cannot be deleted"),
+ "packages.terraform.delete.locked",
+ )
+ }
+
+ latest, err := IsLatest(ctx, pd)
+ if err != nil {
+ return err
+ }
+ if latest {
+ return util.ErrorWrapTranslatable(
+ util.ErrorWrap(util.ErrUnprocessableContent, "the latest version of a Terraform state cannot be deleted"),
+ "packages.terraform.delete.latest",
+ )
+ }
+ return nil
+}
diff --git a/services/packages/terraform/state.go b/services/packages/terraform/state.go
new file mode 100644
index 0000000000..cdd6f0c593
--- /dev/null
+++ b/services/packages/terraform/state.go
@@ -0,0 +1,44 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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
+}
diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl
new file mode 100644
index 0000000000..6006bee9aa
--- /dev/null
+++ b/templates/package/content/terraform.tmpl
@@ -0,0 +1,26 @@
+{{if eq .PackageDescriptor.Package.Type "terraform"}}
+
terraform {
+ backend "http" {
+ 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"
+ }
+}terraform init -migrate-state